Skip to content

Commit

Permalink
feat: consent message prompt with Ok or Warn (#406)
Browse files Browse the repository at this point in the history
* feat: consent message prompt with Ok or Warn

* docs: jsdocs

* feat: adapt demo to support Ok or Warn

* chore: lint

* chore: fmt

---------

Co-authored-by: Antonio Ventilii <[email protected]>
  • Loading branch information
peterpeterparker and AntonioVentilii authored Jan 6, 2025
1 parent 5a876dd commit e04dd93
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 20 deletions.
12 changes: 9 additions & 3 deletions demo/src/wallet_frontend/src/lib/ConsentMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,15 @@
prompt: ({ status, ...rest }: ConsentMessagePromptPayload) => {
switch (status) {
case 'result': {
approve = (rest as ResultConsentMessage).approve;
reject = (rest as ResultConsentMessage).reject;
consentInfo = (rest as ResultConsentMessage).consentInfo;
const result = rest as ResultConsentMessage;
approve = result.approve;
reject = result.reject;
consentInfo =
'Warn' in result.consentInfo
? result.consentInfo.Warn.consentInfo
: result.consentInfo.Ok;
loading = false;
break;
}
Expand Down
4 changes: 3 additions & 1 deletion src/mocks/icrc-ledger.mocks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const mockIcrcLedgerMetadata = [
import type {Value} from '../declarations/icrc-1';

export const mockIcrcLedgerMetadata: [string, Value][] = [
['icrc1:name', {Text: 'Token'}],
['icrc1:symbol', {Text: 'TKN'}],
['icrc1:decimals', {Nat: 11n}],
Expand Down
67 changes: 52 additions & 15 deletions src/services/signer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {Principal} from '@dfinity/principal';
import {isNullish, notEmptyString} from '@dfinity/utils';
import {SignerApi} from '../api/signer.api';
import {SIGNER_BUILDERS} from '../constants/signer.builders.constants';
import {icrc21_consent_info, icrc21_consent_message_response} from '../declarations/icrc-21';
import {icrc21_consent_message_response} from '../declarations/icrc-21';
import {
notifyErrorActionAborted,
notifyErrorMissingPrompt,
Expand All @@ -15,8 +15,9 @@ import {notifyCallCanister} from '../handlers/signer-success.handlers';
import type {IcrcCallCanisterRequestParams} from '../types/icrc-requests';
import type {Notify} from '../types/signer-handlers';
import type {SignerOptions} from '../types/signer-options';
import type {
import {
CallCanisterPrompt,
ConsentInfoWarn,
ConsentMessageApproval,
ConsentMessagePrompt,
ResultConsentMessage
Expand Down Expand Up @@ -73,14 +74,11 @@ export class SignerService {
return {result: 'error'};
}

const {Ok: consentInfo} = response;

// TODO: change consent message prompt payload
// {
// {Ok: consentInfo} | {Warn: {consentInfo?: string, method, arg, canisterId, owner}}
// }

const {result} = await this.promptConsentMessage({consentInfo, prompt, origin});
const {result} = await this.promptConsentMessage({
consentInfo: response,
prompt,
origin
});

if (result === 'rejected') {
notifyErrorActionAborted(notify);
Expand Down Expand Up @@ -241,34 +239,60 @@ export class SignerService {
* @param {Object} params - The parameters for loading the consent message.
* @param {Omit<IcrcCallCanisterRequestParams, 'sender'>} params.params - The ICRC call canister parameters minus the sender.
* @param {SignerOptions} params.options - The signer options - host and owner.
* @returns {Promise<icrc21_consent_message_response>} - The consent message response.
* @returns {Promise<icrc21_consent_message_response | ConsentInfoWarn>} - A consent message response. Returns "Ok" if the message was decoded by the targeted canister, or "Warn" if the fallback builder was used.
* @throws The potential original error from the ICRC-21 call. The errors related to
* the custom builder is ignored.
**/
private async loadConsentMessage(params: {
params: Omit<IcrcCallCanisterRequestParams, 'sender'>;
options: SignerOptions;
}): Promise<icrc21_consent_message_response> {
}): Promise<icrc21_consent_message_response | ConsentInfoWarn> {
try {
return await this.callConsentMessage(params);
} catch (err: unknown) {
const fallbackMessage = await this.tryBuildConsentMessageOnError(params);

if ('Ok' in fallbackMessage) {
if ('Warn' in fallbackMessage) {
return fallbackMessage;
}

throw err;
}
}

/**
* Attempts to build a consent message when the signer cannot decode the arguments
* with the targeted canister. When decoding is attempted locally, user must be warned
* as specified by the ICRC-49 standards.
*
* Instead of returning "Ok" upon success, this function returns "Warn" to indicate
* that the signer performed the decoding rather than the canister.
*
* @see {@link https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_49_call_canister.md#message-processing ICRC-49 Message Processing}
*
* @param {Object} params - The parameters for building the consent message.
* @param {Object} params.params - The ICRC call canister parameters excluding the sender.
* @param {string} params.params.method - The method being called on the canister.
* @param {string} params.params.arg - The encoded arguments for the canister call.
* @param {string} params.params.canisterId - The ID of the targeted canister.
* @param {Object} params.options - The signer options including host and owner.
* @param {string} params.options.owner - The principal ID of the signer (caller).
* @param {string} params.options.host - The host URL for the signer environment.
*
* @returns {Promise<{NoFallback: null} | ConsentInfoWarn | {Err: unknown}>} -
* - `{NoFallback: null}` if no fallback method is available.
* - `ConsentInfoWarn` if a warning response is built successfully.
* - `{Err: unknown}` if an error occurs during processing.
*
* @throws {Error} - Throws an error if building the consent message fails completely.
*/
private async tryBuildConsentMessageOnError({
params: {method, arg, canisterId},
options: {owner, host}
}: {
params: Omit<IcrcCallCanisterRequestParams, 'sender'>;
options: SignerOptions;
}): Promise<{NoFallback: null} | {Ok: icrc21_consent_info} | {Err: unknown}> {
}): Promise<{NoFallback: null} | ConsentInfoWarn | {Err: unknown}> {
const fn = SIGNER_BUILDERS[method];

if (isNullish(fn)) {
Expand All @@ -288,11 +312,24 @@ export class SignerService {
return {Err: new Error('Incomplete token metadata.')};
}

return await fn({
const result = await fn({
arg: base64ToUint8Array(arg),
token,
owner: owner.getPrincipal()
});

if ('Err' in result) {
return {Err: result.Err};
}

return {
Warn: {
consentInfo: result.Ok,
method,
arg,
canisterId
}
};
} catch (err: unknown) {
return {Err: err};
}
Expand Down
74 changes: 74 additions & 0 deletions src/services/signer.services.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {Ed25519KeyIdentity} from '@dfinity/identity';
import {IcrcTokenMetadata, mapTokenMetadata} from '@dfinity/ledger-icrc';
import {assertNonNullish} from '@dfinity/utils';
import type {Mock, MockInstance} from 'vitest';
import {Icrc21Canister} from '../api/icrc21-canister.api';
import {SignerApi} from '../api/signer.api';
import {SIGNER_BUILDERS} from '../constants/signer.builders.constants';
import {SignerErrorCode} from '../constants/signer.constants';
import * as signerSuccessHandlers from '../handlers/signer-success.handlers';
import * as signerHandlers from '../handlers/signer.handlers';
Expand Down Expand Up @@ -337,6 +340,29 @@ describe('Signer services', () => {
});
});

it('should provide a valid consent message with Ok', () =>
// eslint-disable-next-line no-async-promise-executor
new Promise<void>(async (done) => {
spyIcrc21CanisterConsentMessage.mockResolvedValue({
Ok: mockConsentInfo
});

const prompt = ({status, ...rest}: ConsentMessagePromptPayload): void => {
if (status === 'result' && 'consentInfo' in rest && 'Ok' in rest.consentInfo) {
expect(rest.consentInfo.Ok).toEqual(mockConsentInfo);

done();
}
};

await signerService.assertAndPromptConsentMessage({
notify,
params,
prompt,
options: signerOptions
});
}));

it('should return error if consentMessage throws', async () => {
spyIcrc21CanisterConsentMessage.mockRejectedValue(new Error('Test Error'));

Expand Down Expand Up @@ -423,6 +449,54 @@ describe('Signer services', () => {
});
});

it('should provide a valid consent message with Warn', () =>
// eslint-disable-next-line no-async-promise-executor
new Promise<void>(async (done) => {
spyIcrc21CanisterConsentMessage.mockRejectedValue(new Error('Test Error'));
spySignerApiLedgerMedatada.mockResolvedValue(mockIcrcLedgerMetadata);

const prompt = async ({
status,
...rest
}: ConsentMessagePromptPayload): Promise<void> => {
if (status === 'result' && 'consentInfo' in rest && 'Warn' in rest.consentInfo) {
expect(rest.consentInfo.Warn.method).toEqual(method);
expect(rest.consentInfo.Warn.arg).toEqual(arg);
expect(rest.consentInfo.Warn.canisterId).toEqual(params.canisterId);

const fn = SIGNER_BUILDERS[method];

assertNonNullish(fn);

const result = await fn({
arg: base64ToUint8Array(arg),
token: mapTokenMetadata(mockIcrcLedgerMetadata) as IcrcTokenMetadata,
owner: owner.getPrincipal()
});

if ('Err' in result) {
expect(true).toBeFalsy();
return;
}

expect(rest.consentInfo.Warn.consentInfo).toEqual(result.Ok);

done();
}
};

await signerService.assertAndPromptConsentMessage({
notify,
params: {
...params,
method,
arg
},
prompt,
options: signerOptions
});
}));

it('should return error if consentMessage throws and ledger metadata throws', async () => {
const error = new Error('Test Error');
const ledgerError = new Error('Test Error');
Expand Down
23 changes: 22 additions & 1 deletion src/types/signer-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '../constants/icrc.constants';
import type {icrc21_consent_info} from '../declarations/icrc-21';
import {IcrcAccountsSchema} from './icrc-accounts';
import {IcrcCallCanisterRequestParamsSchema} from './icrc-requests';
import {IcrcCallCanisterResultSchema, IcrcScopesArraySchema} from './icrc-responses';
import {OriginSchema} from './post-message';

Expand Down Expand Up @@ -120,9 +121,29 @@ const LoadingConsentMessageSchema = PayloadOriginSchema.extend({
status: z.literal(LoadingConsentMessageStatusSchema.enum.loading)
});

const ConsentInfoSchema = z.custom<icrc21_consent_info>();

const ConsentInfoOkSchema = z.object({
Ok: ConsentInfoSchema
});

const ConsentInfoWarnSchema = z.object({
Warn: IcrcCallCanisterRequestParamsSchema.pick({
canisterId: true,
method: true,
arg: true
}).extend({
consentInfo: ConsentInfoSchema
})
});

export type ConsentInfoWarn = z.infer<typeof ConsentInfoWarnSchema>;

const ResultConsentInfoSchema = z.union([ConsentInfoOkSchema, ConsentInfoWarnSchema]);

const ResultConsentMessageSchema = PayloadOriginSchema.extend({
status: z.literal(StatusSchema.enum.result),
consentInfo: z.custom<icrc21_consent_info>(),
consentInfo: ResultConsentInfoSchema,
approve: ConsentMessageApprovalSchema,
reject: RejectionSchema
});
Expand Down

0 comments on commit e04dd93

Please sign in to comment.