From eb8406bf6f466c8eaf9804c719e0457a0533421d Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Sat, 21 Dec 2024 10:57:34 +0100 Subject: [PATCH 01/31] feat: build icrc1_transfer consent message if target does not implement ICRC-21 --- .github/dependabot.yml | 20 ++++++++-------- src/builders/signer.builders.spec.ts | 35 ++++++++++++++++++++++++++++ src/builders/signer.builders.ts | 17 ++++++++++++++ src/icp-wallet.ts | 4 ++-- src/icrc-wallet.spec.ts | 23 ++++++++---------- src/icrc-wallet.ts | 4 ++-- src/mocks/icrc-call-utils.mocks.ts | 9 +++++++ src/types/signer-builders.ts | 11 +++++++++ src/utils/call.utils.ts | 6 ++--- src/utils/idl.utils.spec.ts | 18 +++++++------- src/utils/idl.utils.ts | 10 ++++---- 11 files changed, 113 insertions(+), 44 deletions(-) create mode 100644 src/builders/signer.builders.spec.ts create mode 100644 src/builders/signer.builders.ts create mode 100644 src/mocks/icrc-call-utils.mocks.ts create mode 100644 src/types/signer-builders.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2d2d9f6c..3910bd44 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,23 +4,23 @@ version: 2 updates: - package-ecosystem: npm - directory: "/" + directory: '/' schedule: interval: weekly allow: - dependency-type: development ignore: - - dependency-name: "@types/*" - - dependency-name: "@typescript-eslint/*" - - dependency-name: "eslint" - - dependency-name: "eslint-*" + - dependency-name: '@types/*' + - dependency-name: '@typescript-eslint/*' + - dependency-name: 'eslint' + - dependency-name: 'eslint-*' - package-ecosystem: npm - directory: "/demo" + directory: '/demo' schedule: interval: weekly ignore: - - dependency-name: "@types/*" - - dependency-name: "@typescript-eslint/*" - - dependency-name: "eslint" - - dependency-name: "eslint-*" \ No newline at end of file + - dependency-name: '@types/*' + - dependency-name: '@typescript-eslint/*' + - dependency-name: 'eslint' + - dependency-name: 'eslint-*' diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts new file mode 100644 index 00000000..50bf5685 --- /dev/null +++ b/src/builders/signer.builders.spec.ts @@ -0,0 +1,35 @@ +import {mockCallCanisterParams} from '../mocks/call-canister.mocks'; +import {mockIcrcLocalCallParams} from '../mocks/icrc-call-utils.mocks'; +import {SignerBuildersResultError, SignerBuildersResultSuccess} from '../types/signer-builders'; +import {base64ToUint8Array} from '../utils/base64.utils'; +import {buildContentMessageIcrc1Transfer} from './signer.builders'; + +describe('Signer builders', () => { + describe('icrc1_transfer', () => { + it('should build a consent message for valid arg', () => { + const result = buildContentMessageIcrc1Transfer( + base64ToUint8Array(mockIcrcLocalCallParams.arg) + ); + + expect(result.success).toBeTruthy(); + + const {message} = result as SignerBuildersResultSuccess; + + expect(message).not.toBeUndefined(); + // TODO: test formatted message + }); + + it('should not build a consent message for invalid arg', () => { + const result = buildContentMessageIcrc1Transfer( + base64ToUint8Array(mockCallCanisterParams.arg) + ); + + expect(result.success).toBeFalsy(); + + const {err} = result as SignerBuildersResultError; + + expect(err).not.toBeUndefined(); + expect((err as Error).message).toContain('Wrong magic number'); + }); + }); +}); diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts new file mode 100644 index 00000000..74711010 --- /dev/null +++ b/src/builders/signer.builders.ts @@ -0,0 +1,17 @@ +import {IcrcTransferArg} from '@dfinity/ledger-icrc'; +import {TransferArgs} from '../constants/icrc.idl.constants'; +import {SignerBuildersResult} from '../types/signer-builders'; +import {decodeIdl} from '../utils/idl.utils'; + +export const buildContentMessageIcrc1Transfer = (arg: ArrayBuffer): SignerBuildersResult => { + try { + const result = decodeIdl({ + recordClass: TransferArgs, + bytes: arg + }); + + return {success: true, message: 'TODO'}; + } catch (err: unknown) { + return {success: false, err}; + } +}; diff --git a/src/icp-wallet.ts b/src/icp-wallet.ts index 512f1b3f..0208b9e5 100644 --- a/src/icp-wallet.ts +++ b/src/icp-wallet.ts @@ -14,7 +14,7 @@ import type {PrincipalText} from './types/principal'; import {RelyingPartyOptions} from './types/relying-party-options'; import type {RelyingPartyRequestOptions} from './types/relying-party-requests'; import {decodeResponse} from './utils/call.utils'; -import {encodeArg} from './utils/idl.utils'; +import {encodeIdl} from './utils/idl.utils'; const ICP_LEDGER_CANISTER_ID = 'ryjl3-tyaaa-aaaaa-aaaba-cai'; @@ -63,7 +63,7 @@ export class IcpWallet extends RelyingParty { // TODO: should we convert ic-js to zod? or should we map Icrc1TransferRequest to zod? const rawArgs = toIcrc1TransferRawRequest(request); - const arg = encodeArg({ + const arg = encodeIdl({ recordClass: TransferArgs, rawArgs }); diff --git a/src/icrc-wallet.spec.ts b/src/icrc-wallet.spec.ts index a9260634..c5e7705e 100644 --- a/src/icrc-wallet.spec.ts +++ b/src/icrc-wallet.spec.ts @@ -4,12 +4,12 @@ import {toNullable} from '@dfinity/utils'; import {IcrcWallet} from './icrc-wallet'; import { mockLocalBlockHeight, - mockLocalCallParams, mockLocalCallResult, mockLocalCallTime, mockLocalRelyingPartyPrincipal } from './mocks/call-utils.mocks'; import {mockLocalIcRootKey} from './mocks/custom-http-agent-responses.mocks'; +import {mockIcrcLocalCallParams, mockLedgerCanisterId} from './mocks/icrc-call-utils.mocks'; import {RelyingPartyOptions} from './types/relying-party-options'; import {JSON_RPC_VERSION_2} from './types/rpc'; import * as callUtils from './utils/call.utils'; @@ -45,8 +45,6 @@ describe('icrc-wallet', () => { host: 'http://localhost:8080' }; - const mockCanisterId = 'ryjl3-tyaaa-aaaaa-aaaba-cai'; - const messageEventReady = new MessageEvent('message', { origin: mockParameters.url, data: { @@ -98,12 +96,6 @@ describe('icrc-wallet', () => { amount: 5000000n }; - const mockIcrcLocalCallParams = { - ...mockLocalCallParams, - canisterId: mockCanisterId, - arg: 'RElETAZte24AbAKzsNrDA2ithsqDBQFufW54bAb7ygECxvy2AgO6ieXCBAGi3pTrBgGC8/ORDATYo4yoDX0BBQEdP0Duk4WbdYJC1svDpO9SpE+aElxKU7FNBuH2LAIAAAAAAMCWsQI=' - }; - const {sender} = mockIcrcLocalCallParams; it('should call `call` with the correct parameters when transfer is invoked', async () => { @@ -115,7 +107,7 @@ describe('icrc-wallet', () => { const result = await icrcWallet.transfer({ params, owner: sender, - ledgerCanisterId: mockCanisterId + ledgerCanisterId: mockLedgerCanisterId }); expect(result).toEqual(mockLocalBlockHeight); @@ -137,7 +129,7 @@ describe('icrc-wallet', () => { const owner = Ed25519KeyIdentity.generate().getPrincipal().toText(); - await icrcWallet.transfer({params, owner, ledgerCanisterId: mockCanisterId}); + await icrcWallet.transfer({params, owner, ledgerCanisterId: mockLedgerCanisterId}); expect(mockCall).toHaveBeenCalledWith({ params: { @@ -160,7 +152,12 @@ describe('icrc-wallet', () => { timeoutInMilliseconds: 120000 }; - await icrcWallet.transfer({params, owner: sender, options, ledgerCanisterId: mockCanisterId}); + await icrcWallet.transfer({ + params, + owner: sender, + options, + ledgerCanisterId: mockLedgerCanisterId + }); expect(mockCall).toHaveBeenCalledWith({ params: mockIcrcLocalCallParams, @@ -181,7 +178,7 @@ describe('icrc-wallet', () => { await icrcWallet.transfer({ params, owner: sender, - ledgerCanisterId: mockCanisterId + ledgerCanisterId: mockLedgerCanisterId }); expect(spy).toHaveBeenCalledWith( diff --git a/src/icrc-wallet.ts b/src/icrc-wallet.ts index 17725f02..c694bcaa 100644 --- a/src/icrc-wallet.ts +++ b/src/icrc-wallet.ts @@ -16,7 +16,7 @@ import type { } from './index'; import {RelyingParty} from './relying-party'; import {decodeResponse} from './utils/call.utils'; -import {encodeArg} from './utils/idl.utils'; +import {encodeIdl} from './utils/idl.utils'; export class IcrcWallet extends RelyingParty { /** @@ -62,7 +62,7 @@ export class IcrcWallet extends RelyingParty { } & Pick): Promise => { const rawArgs = toTransferArg(params); - const arg = encodeArg({ + const arg = encodeIdl({ recordClass: TransferArgs, rawArgs }); diff --git a/src/mocks/icrc-call-utils.mocks.ts b/src/mocks/icrc-call-utils.mocks.ts new file mode 100644 index 00000000..4ff71bad --- /dev/null +++ b/src/mocks/icrc-call-utils.mocks.ts @@ -0,0 +1,9 @@ +import {mockLocalCallParams} from './call-utils.mocks'; + +export const mockLedgerCanisterId = 'ryjl3-tyaaa-aaaaa-aaaba-cai'; + +export const mockIcrcLocalCallParams = { + ...mockLocalCallParams, + canisterId: mockLedgerCanisterId, + arg: 'RElETAZte24AbAKzsNrDA2ithsqDBQFufW54bAb7ygECxvy2AgO6ieXCBAGi3pTrBgGC8/ORDATYo4yoDX0BBQEdP0Duk4WbdYJC1svDpO9SpE+aElxKU7FNBuH2LAIAAAAAAMCWsQI=' +}; diff --git a/src/types/signer-builders.ts b/src/types/signer-builders.ts new file mode 100644 index 00000000..ee829ca7 --- /dev/null +++ b/src/types/signer-builders.ts @@ -0,0 +1,11 @@ +export interface SignerBuildersResultSuccess { + success: true; + message: string; +} + +export interface SignerBuildersResultError { + success: false; + err: unknown; +} + +export type SignerBuildersResult = SignerBuildersResultSuccess | SignerBuildersResultError; diff --git a/src/utils/call.utils.ts b/src/utils/call.utils.ts index 0fc84c71..d68db02b 100644 --- a/src/utils/call.utils.ts +++ b/src/utils/call.utils.ts @@ -20,7 +20,7 @@ import { assertCallMethod, assertCallSender } from './call.assert.utils'; -import {decodeResult} from './idl.utils'; +import {decodeIdl} from './idl.utils'; export const assertCallResponse = ({ params: {method, arg, canisterId, sender}, @@ -97,8 +97,8 @@ export const decodeResponse = async ({ 'A reply cannot be resolved within the provided certificate. This is unexpected; it should have been known at this point.' ); - return decodeResult({ + return decodeIdl({ recordClass: resultRecordClass, - reply + bytes: reply }); }; diff --git a/src/utils/idl.utils.spec.ts b/src/utils/idl.utils.spec.ts index cf93d9f2..d08d4f95 100644 --- a/src/utils/idl.utils.spec.ts +++ b/src/utils/idl.utils.spec.ts @@ -2,7 +2,7 @@ import {IDL} from '@dfinity/candid'; import {Principal} from '@dfinity/principal'; import {TransferArgs} from '../constants/icrc.idl.constants'; import {TransferArgs as TransferArgsType} from '../declarations/icrc-1'; -import {decodeResult, encodeArg} from './idl.utils'; +import {decodeIdl, encodeIdl} from './idl.utils'; describe('idl.utils', () => { beforeEach(() => { @@ -25,7 +25,7 @@ describe('idl.utils', () => { } }; - const arg = encodeArg({ + const arg = encodeIdl({ recordClass: TransferArgs, rawArgs }); @@ -39,13 +39,13 @@ describe('idl.utils', () => { describe('decodeResult', () => { const mockRecordClass = IDL.Record({someField: IDL.Text}); - it('should decode the reply and return the result', () => { + it('should decode the bytes and return the result', () => { const mockExpectedObject = {someField: 'test value'}; const mockReply = IDL.encode([mockRecordClass], [mockExpectedObject]); - const result = decodeResult<{someField: string}>({ + const result = decodeIdl<{someField: string}>({ recordClass: mockRecordClass, - reply: mockReply + bytes: mockReply }); expect(result).toEqual(mockExpectedObject); @@ -55,9 +55,9 @@ describe('idl.utils', () => { const invalidReply = new ArrayBuffer(10); expect(() => - decodeResult({ + decodeIdl({ recordClass: mockRecordClass, - reply: invalidReply + bytes: invalidReply }) ).toThrowError(/Wrong magic number/); }); @@ -71,9 +71,9 @@ describe('idl.utils', () => { vi.spyOn(IDL, 'decode').mockReturnValue([{someField: 'value1'}, {someField: 'value2'}]); expect(() => - decodeResult({ + decodeIdl({ recordClass: mockRecordClass, - reply: mockReply + bytes: mockReply }) ).toThrow('More than one object returned. This is unexpected.'); }); diff --git a/src/utils/idl.utils.ts b/src/utils/idl.utils.ts index fa467e48..8531151c 100644 --- a/src/utils/idl.utils.ts +++ b/src/utils/idl.utils.ts @@ -3,7 +3,7 @@ import {RecordClass, VariantClass} from '@dfinity/candid/lib/cjs/idl'; import {IcrcBlob} from '../types/blob'; import {uint8ArrayToBase64} from './base64.utils'; -export const encodeArg = ({ +export const encodeIdl = ({ recordClass, rawArgs }: { @@ -11,14 +11,14 @@ export const encodeArg = ({ rawArgs: T; }): IcrcBlob => uint8ArrayToBase64(new Uint8Array(IDL.encode([recordClass], [rawArgs]))); -export const decodeResult = ({ +export const decodeIdl = ({ recordClass, - reply + bytes }: { recordClass: RecordClass | VariantClass; - reply: ArrayBuffer; + bytes: ArrayBuffer; }): T => { - const result = IDL.decode([recordClass], reply); + const result = IDL.decode([recordClass], bytes); if (result.length !== 1) { throw new Error('More than one object returned. This is unexpected.'); From 1bfce8cabaecdbbad9de67e176d28e8904b58873 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Sat, 21 Dec 2024 13:18:43 +0100 Subject: [PATCH 02/31] feat: i18n --- package.json | 3 ++- scripts/i18n.mjs | 54 +++++++++++++++++++++++++++++++++++++++++++++ src/i18n/en.json | 31 ++++++++++++++++++++++++++ src/i18n/en.spec.ts | 9 ++++++++ src/types/i18n.ts | 45 +++++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 scripts/i18n.mjs create mode 100644 src/i18n/en.json create mode 100644 src/i18n/en.spec.ts create mode 100644 src/types/i18n.ts diff --git a/package.json b/package.json index 85ba962e..e3f39815 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,8 @@ "e2e:ci": "playwright test --reporter=html", "e2e:ci:snapshots": "playwright test --update-snapshots --reporter=html", "did": "./scripts/download-did && ./scripts/compile-idl-js", - "prepublishOnly": "if [ $(basename $PWD) != 'dist' ]; then echo 'Publishing is only allowed from the dist directory.' && exit 1; fi" + "prepublishOnly": "if [ $(basename $PWD) != 'dist' ]; then echo 'Publishing is only allowed from the dist directory.' && exit 1; fi", + "i18n": "node scripts/i18n.mjs && prettier --write ./src/types/i18n.ts" }, "devDependencies": { "@dfinity/eslint-config-oisy-wallet": "^0.0.6", diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs new file mode 100644 index 00000000..c278a3b3 --- /dev/null +++ b/scripts/i18n.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import {writeFileSync} from 'node:fs'; +import {join} from 'node:path'; + +const PATH_FROM_ROOT = join(process.cwd(), 'src'); +const PATH_TO_EN_JSON = join(PATH_FROM_ROOT, 'i18n', 'en.json'); +const PATH_TO_OUTPUT = join(PATH_FROM_ROOT, 'types', 'i18n.ts'); + +/** + * Generates TypeScript interfaces from the English translation file. + */ +const generateTypes = async () => { + const {default: en} = await import(PATH_TO_EN_JSON, {with: {type: 'json'}}); + + const mapValues = (values) => + Object.entries(values).reduce( + (acc, [key, value]) => [ + ...acc, + `${key}: ${typeof value === 'object' ? `z.object({${mapValues(value).join('')}})` : `z.string()`},` + ], + [] + ); + + const data = Object.entries(en).map(([key, values]) => ({ + key, + schema: `i18n${key.charAt(0).toUpperCase()}${key.slice(1)}Schema`, + values: mapValues(values) + })); + + const comment = `// Auto-generated definitions file ("npm run i18n")\n`; + + const schemas = data + .map( + ({schema, values}) => `export const ${schema} = z.object({ + ${values.join('\n')} +}).strict();` + ) + .join('\n\n'); + + const schema = `import { z } from 'zod'; + +${schemas} + +export const i18Schema = z.object({ + ${data.map(({key, schema}) => `${key}: ${schema},`).join('\n')} +}).strict(); + +export type I18n = z.infer;`; + + writeFileSync(PATH_TO_OUTPUT, `${comment}${schema}`); +}; + +await generateTypes(); diff --git a/src/i18n/en.json b/src/i18n/en.json new file mode 100644 index 00000000..3958228d --- /dev/null +++ b/src/i18n/en.json @@ -0,0 +1,31 @@ +{ + "core": { + "amount": "Amount", + "from": "From", + "to": "To", + "fee": "Fee" + }, + "icrc1_transfer": { + "title": "Approve the transfer of funds" + }, + "icrc2_approve": { + "title": "Authorize another address to withdraw from your account", + "address_is_allowed": "The following address is allowed to withdraw from your account:", + "your_subaccount": "Your subaccount", + "your_account": "Your account", + "requested_withdrawal_allowance": "Requested withdrawal allowance", + "withdrawal_allowance": { + "some": "Current withdrawal allowance", + "none": "The allowance will be set to {} {} independently of any previous allowance. Until this transaction has been executed the spender can still exercise the previous allowance (if any) to it's full amount." + }, + "expiration_date": "Expiration date", + "approval_fee": "Approval fee", + "approver_account_transaction_fees": { + "anonymous": "Transaction fees to be paid by your subaccount", + "owner": "Transaction fees to be paid by:", + "test": { + "yolo": "hello" + } + } + } +} diff --git a/src/i18n/en.spec.ts b/src/i18n/en.spec.ts new file mode 100644 index 00000000..b8452e20 --- /dev/null +++ b/src/i18n/en.spec.ts @@ -0,0 +1,9 @@ +import {i18Schema} from '../types/i18n'; +import en from './en.json'; + +describe('English translations', () => { + it('should validate all keys against schema', () => { + const result = i18Schema.safeParse(en); + expect(result.success).toBe(true); + }); +}); diff --git a/src/types/i18n.ts b/src/types/i18n.ts new file mode 100644 index 00000000..35add204 --- /dev/null +++ b/src/types/i18n.ts @@ -0,0 +1,45 @@ +// Auto-generated definitions file ("npm run i18n") +import {z} from 'zod'; + +export const i18nCoreSchema = z + .object({ + amount: z.string(), + from: z.string(), + to: z.string(), + fee: z.string() + }) + .strict(); + +export const i18nIcrc1_transferSchema = z + .object({ + title: z.string() + }) + .strict(); + +export const i18nIcrc2_approveSchema = z + .object({ + title: z.string(), + address_is_allowed: z.string(), + your_subaccount: z.string(), + your_account: z.string(), + requested_withdrawal_allowance: z.string(), + withdrawal_allowance: z.object({some: z.string(), none: z.string()}), + expiration_date: z.string(), + approval_fee: z.string(), + approver_account_transaction_fees: z.object({ + anonymous: z.string(), + owner: z.string(), + test: z.object({yolo: z.string()}) + }) + }) + .strict(); + +export const i18Schema = z + .object({ + core: i18nCoreSchema, + icrc1_transfer: i18nIcrc1_transferSchema, + icrc2_approve: i18nIcrc2_approveSchema + }) + .strict(); + +export type I18n = z.infer; From 7e3a88c48965a4bf33850cc4f5f81f6717e5b82f Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Sat, 21 Dec 2024 13:54:42 +0100 Subject: [PATCH 03/31] feat: title and amount --- src/builders/signer.builders.spec.ts | 7 +++++-- src/builders/signer.builders.ts | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index 50bf5685..cc99d5e1 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -6,7 +6,7 @@ import {buildContentMessageIcrc1Transfer} from './signer.builders'; describe('Signer builders', () => { describe('icrc1_transfer', () => { - it('should build a consent message for valid arg', () => { + it('should build a consent message for a defined arg', () => { const result = buildContentMessageIcrc1Transfer( base64ToUint8Array(mockIcrcLocalCallParams.arg) ); @@ -16,7 +16,10 @@ describe('Signer builders', () => { const {message} = result as SignerBuildersResultSuccess; expect(message).not.toBeUndefined(); - // TODO: test formatted message + expect(message).toEqual(`# Approve the transfer of funds + +**Amount:** +5000000`); }); it('should not build a consent message for invalid arg', () => { diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 74711010..efad5777 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -1,16 +1,28 @@ import {IcrcTransferArg} from '@dfinity/ledger-icrc'; import {TransferArgs} from '../constants/icrc.idl.constants'; +import en from '../i18n/en.json'; import {SignerBuildersResult} from '../types/signer-builders'; import {decodeIdl} from '../utils/idl.utils'; export const buildContentMessageIcrc1Transfer = (arg: ArrayBuffer): SignerBuildersResult => { try { - const result = decodeIdl({ + const {amount} = decodeIdl({ recordClass: TransferArgs, bytes: arg }); - return {success: true, message: 'TODO'}; + const { + core: {amount: amountLabel}, + icrc1_transfer: {title} + } = en; + + const message = [`# ${title}`]; + + const section = (text: string): string => `**${text}:**`; + + message.push(`${section(amountLabel)}\n${amount}`); + + return {success: true, message: message.join('\n\n')}; } catch (err: unknown) { return {success: false, err}; } From 509a1bfedb1f350b5bbe8518f145d5a5b187f260 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Sat, 21 Dec 2024 14:13:41 +0100 Subject: [PATCH 04/31] feat: with subaccount --- src/builders/signer.builders.spec.ts | 70 +++++++++++++++++++++++++--- src/builders/signer.builders.ts | 27 +++++++++-- src/i18n/en.json | 3 +- src/types/i18n.ts | 3 +- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index cc99d5e1..a3a635ba 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -1,15 +1,37 @@ +import {Ed25519KeyIdentity} from '@dfinity/identity'; +import {encodeIcrcAccount} from '@dfinity/ledger-icrc'; +import {Principal} from '@dfinity/principal'; +import {TransferArgs} from '../constants/icrc.idl.constants'; +import {TransferArgs as TransferArgsType} from '../declarations/icrc-1'; import {mockCallCanisterParams} from '../mocks/call-canister.mocks'; +import {mockPrincipalText} from '../mocks/icrc-accounts.mocks'; import {mockIcrcLocalCallParams} from '../mocks/icrc-call-utils.mocks'; import {SignerBuildersResultError, SignerBuildersResultSuccess} from '../types/signer-builders'; import {base64ToUint8Array} from '../utils/base64.utils'; +import {encodeIdl} from '../utils/idl.utils'; import {buildContentMessageIcrc1Transfer} from './signer.builders'; describe('Signer builders', () => { + const owner = Ed25519KeyIdentity.generate(); + + const rawArgs: TransferArgsType = { + amount: 6660000n, + created_at_time: [1727696940356000000n], + fee: [10000n], + from_subaccount: [], + memo: [], + to: { + owner: Principal.fromText(mockPrincipalText), + subaccount: [] + } + }; + describe('icrc1_transfer', () => { it('should build a consent message for a defined arg', () => { - const result = buildContentMessageIcrc1Transfer( - base64ToUint8Array(mockIcrcLocalCallParams.arg) - ); + const result = buildContentMessageIcrc1Transfer({ + arg: base64ToUint8Array(mockIcrcLocalCallParams.arg), + owner: Principal.fromText(mockPrincipalText) + }); expect(result.success).toBeTruthy(); @@ -19,13 +41,47 @@ describe('Signer builders', () => { expect(message).toEqual(`# Approve the transfer of funds **Amount:** -5000000`); +5000000 + +**From:** +${mockPrincipalText}`); + }); + + it('should build a consent message with a from subaccount', () => { + const subaccount = [1, 2, 3]; + + const arg = encodeIdl({ + recordClass: TransferArgs, + rawArgs: { + ...rawArgs, + from_subaccount: [subaccount] + } + }); + + const result = buildContentMessageIcrc1Transfer({ + arg: base64ToUint8Array(arg), + owner: owner.getPrincipal() + }); + + expect(result.success).toBeTruthy(); + + const {message} = result as SignerBuildersResultSuccess; + + expect(message).not.toBeUndefined(); + expect(message).toEqual(`# Approve the transfer of funds + +**Amount:** +${rawArgs.amount} + +**From subaccount:** +${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})}`); }); it('should not build a consent message for invalid arg', () => { - const result = buildContentMessageIcrc1Transfer( - base64ToUint8Array(mockCallCanisterParams.arg) - ); + const result = buildContentMessageIcrc1Transfer({ + arg: base64ToUint8Array(mockCallCanisterParams.arg), + owner: owner.getPrincipal() + }); expect(result.success).toBeFalsy(); diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index efad5777..9c53ede1 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -1,19 +1,27 @@ -import {IcrcTransferArg} from '@dfinity/ledger-icrc'; +import {encodeIcrcAccount, IcrcTransferArg} from '@dfinity/ledger-icrc'; +import {Principal} from '@dfinity/principal'; +import {fromNullable, isNullish} from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; import en from '../i18n/en.json'; import {SignerBuildersResult} from '../types/signer-builders'; import {decodeIdl} from '../utils/idl.utils'; -export const buildContentMessageIcrc1Transfer = (arg: ArrayBuffer): SignerBuildersResult => { +export const buildContentMessageIcrc1Transfer = ({ + arg, + owner +}: { + arg: ArrayBuffer; + owner: Principal; +}): SignerBuildersResult => { try { - const {amount} = decodeIdl({ + const {amount, from_subaccount} = decodeIdl({ recordClass: TransferArgs, bytes: arg }); const { - core: {amount: amountLabel}, - icrc1_transfer: {title} + core: {amount: amountLabel, from}, + icrc1_transfer: {title, from_subaccount: fromSubaccountLabel} } = en; const message = [`# ${title}`]; @@ -22,6 +30,15 @@ export const buildContentMessageIcrc1Transfer = (arg: ArrayBuffer): SignerBuilde message.push(`${section(amountLabel)}\n${amount}`); + const fromSubaccount = fromNullable(from_subaccount); + const fromAccount = encodeIcrcAccount({ + owner, + subaccount: fromSubaccount + }); + message.push( + `${section(isNullish(fromSubaccount) ? from : fromSubaccountLabel)}\n${fromAccount}` + ); + return {success: true, message: message.join('\n\n')}; } catch (err: unknown) { return {success: false, err}; diff --git a/src/i18n/en.json b/src/i18n/en.json index 3958228d..536dc27f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -6,7 +6,8 @@ "fee": "Fee" }, "icrc1_transfer": { - "title": "Approve the transfer of funds" + "title": "Approve the transfer of funds", + "from_subaccount": "From subaccount" }, "icrc2_approve": { "title": "Authorize another address to withdraw from your account", diff --git a/src/types/i18n.ts b/src/types/i18n.ts index 35add204..3071a3f7 100644 --- a/src/types/i18n.ts +++ b/src/types/i18n.ts @@ -12,7 +12,8 @@ export const i18nCoreSchema = z export const i18nIcrc1_transferSchema = z .object({ - title: z.string() + title: z.string(), + from_subaccount: z.string() }) .strict(); From b83570db42b22b33a2bceb5bf7ea6b59791c985d Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Sat, 21 Dec 2024 14:16:46 +0100 Subject: [PATCH 05/31] feat: with subaccount --- src/builders/signer.builders.spec.ts | 12 ++++++------ src/builders/signer.builders.ts | 8 +++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index a3a635ba..55fa76ef 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -27,8 +27,8 @@ describe('Signer builders', () => { }; describe('icrc1_transfer', () => { - it('should build a consent message for a defined arg', () => { - const result = buildContentMessageIcrc1Transfer({ + it('should build a consent message for a defined arg', async () => { + const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(mockIcrcLocalCallParams.arg), owner: Principal.fromText(mockPrincipalText) }); @@ -47,7 +47,7 @@ describe('Signer builders', () => { ${mockPrincipalText}`); }); - it('should build a consent message with a from subaccount', () => { + it('should build a consent message with a from subaccount', async () => { const subaccount = [1, 2, 3]; const arg = encodeIdl({ @@ -58,7 +58,7 @@ ${mockPrincipalText}`); } }); - const result = buildContentMessageIcrc1Transfer({ + const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(arg), owner: owner.getPrincipal() }); @@ -77,8 +77,8 @@ ${rawArgs.amount} ${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})}`); }); - it('should not build a consent message for invalid arg', () => { - const result = buildContentMessageIcrc1Transfer({ + it('should not build a consent message for invalid arg', async () => { + const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(mockCallCanisterParams.arg), owner: owner.getPrincipal() }); diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 9c53ede1..cb5ae0a3 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -2,23 +2,25 @@ import {encodeIcrcAccount, IcrcTransferArg} from '@dfinity/ledger-icrc'; import {Principal} from '@dfinity/principal'; import {fromNullable, isNullish} from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; -import en from '../i18n/en.json'; import {SignerBuildersResult} from '../types/signer-builders'; import {decodeIdl} from '../utils/idl.utils'; -export const buildContentMessageIcrc1Transfer = ({ +export const buildContentMessageIcrc1Transfer = async ({ arg, owner }: { arg: ArrayBuffer; owner: Principal; -}): SignerBuildersResult => { +}): Promise => { try { const {amount, from_subaccount} = decodeIdl({ recordClass: TransferArgs, bytes: arg }); + // eslint-disable-next-line import/no-relative-parent-imports + const {default: en} = await import('../i18n/en.json'); + const { core: {amount: amountLabel, from}, icrc1_transfer: {title, from_subaccount: fromSubaccountLabel} From 3866a64c53b4b3d57f6bbae8e6e6c331d762c230 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Sat, 21 Dec 2024 14:22:33 +0100 Subject: [PATCH 06/31] feat: to account --- src/builders/signer.builders.spec.ts | 47 ++++++++++++++++++++++++++-- src/builders/signer.builders.ts | 20 +++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index 55fa76ef..53c4429c 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -1,6 +1,7 @@ import {Ed25519KeyIdentity} from '@dfinity/identity'; import {encodeIcrcAccount} from '@dfinity/ledger-icrc'; import {Principal} from '@dfinity/principal'; +import {fromNullable} from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; import {TransferArgs as TransferArgsType} from '../declarations/icrc-1'; import {mockCallCanisterParams} from '../mocks/call-canister.mocks'; @@ -44,7 +45,10 @@ describe('Signer builders', () => { 5000000 **From:** -${mockPrincipalText}`); +${mockPrincipalText} + +**To:** +s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae`); }); it('should build a consent message with a from subaccount', async () => { @@ -74,7 +78,46 @@ ${mockPrincipalText}`); ${rawArgs.amount} **From subaccount:** -${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})}`); +${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})} + +**To:** +${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})}`); + }); + + it('should build a consent message with a to subaccount', async () => { + const subaccount = [1, 2, 3]; + + const arg = encodeIdl({ + recordClass: TransferArgs, + rawArgs: { + ...rawArgs, + to: { + ...rawArgs.to, + subaccount: [subaccount] + } + } + }); + + const result = await buildContentMessageIcrc1Transfer({ + arg: base64ToUint8Array(arg), + owner: owner.getPrincipal() + }); + + expect(result.success).toBeTruthy(); + + const {message} = result as SignerBuildersResultSuccess; + + expect(message).not.toBeUndefined(); + expect(message).toEqual(`# Approve the transfer of funds + +**Amount:** +${rawArgs.amount} + +**From:** +${encodeIcrcAccount({owner: owner.getPrincipal()})} + +**To:** +${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})}`); }); it('should not build a consent message for invalid arg', async () => { diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index cb5ae0a3..252e9424 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -13,7 +13,11 @@ export const buildContentMessageIcrc1Transfer = async ({ owner: Principal; }): Promise => { try { - const {amount, from_subaccount} = decodeIdl({ + const { + amount, + from_subaccount: fromSubaccount, + to: {owner: toOwner, subaccount: toSubaccount} + } = decodeIdl({ recordClass: TransferArgs, bytes: arg }); @@ -22,7 +26,7 @@ export const buildContentMessageIcrc1Transfer = async ({ const {default: en} = await import('../i18n/en.json'); const { - core: {amount: amountLabel, from}, + core: {amount: amountLabel, from, to}, icrc1_transfer: {title, from_subaccount: fromSubaccountLabel} } = en; @@ -32,15 +36,21 @@ export const buildContentMessageIcrc1Transfer = async ({ message.push(`${section(amountLabel)}\n${amount}`); - const fromSubaccount = fromNullable(from_subaccount); + const fromNullishSubaccount = fromNullable(fromSubaccount); const fromAccount = encodeIcrcAccount({ owner, - subaccount: fromSubaccount + subaccount: fromNullishSubaccount }); message.push( - `${section(isNullish(fromSubaccount) ? from : fromSubaccountLabel)}\n${fromAccount}` + `${section(isNullish(fromNullishSubaccount) ? from : fromSubaccountLabel)}\n${fromAccount}` ); + const toAccount = encodeIcrcAccount({ + owner: toOwner, + subaccount: fromNullable(toSubaccount) + }); + message.push(`${section(to)}\n${toAccount}`); + return {success: true, message: message.join('\n\n')}; } catch (err: unknown) { return {success: false, err}; From 335733ccf188104dc9abd19ad78d3ea564ced100 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Sat, 21 Dec 2024 14:29:42 +0100 Subject: [PATCH 07/31] feat: fee --- src/builders/signer.builders.spec.ts | 19 ++++++++++++++----- src/builders/signer.builders.ts | 12 ++++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index 53c4429c..81a58d7d 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -18,7 +18,7 @@ describe('Signer builders', () => { const rawArgs: TransferArgsType = { amount: 6660000n, created_at_time: [1727696940356000000n], - fee: [10000n], + fee: [10330n], from_subaccount: [], memo: [], to: { @@ -28,7 +28,7 @@ describe('Signer builders', () => { }; describe('icrc1_transfer', () => { - it('should build a consent message for a defined arg', async () => { + it('should build a consent message for a defined arg (without fee)', async () => { const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(mockIcrcLocalCallParams.arg), owner: Principal.fromText(mockPrincipalText) @@ -48,7 +48,10 @@ describe('Signer builders', () => { ${mockPrincipalText} **To:** -s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae`); +s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae + +**Fee:** +`); }); it('should build a consent message with a from subaccount', async () => { @@ -81,7 +84,10 @@ ${rawArgs.amount} ${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})} **To:** -${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})}`); +${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} + +**Fee:** +10330`); }); it('should build a consent message with a to subaccount', async () => { @@ -117,7 +123,10 @@ ${rawArgs.amount} ${encodeIcrcAccount({owner: owner.getPrincipal()})} **To:** -${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})}`); +${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} + +**Fee:** +10330`); }); it('should not build a consent message for invalid arg', async () => { diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 252e9424..168e4ebf 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -16,7 +16,8 @@ export const buildContentMessageIcrc1Transfer = async ({ const { amount, from_subaccount: fromSubaccount, - to: {owner: toOwner, subaccount: toSubaccount} + to: {owner: toOwner, subaccount: toSubaccount}, + fee } = decodeIdl({ recordClass: TransferArgs, bytes: arg @@ -26,16 +27,19 @@ export const buildContentMessageIcrc1Transfer = async ({ const {default: en} = await import('../i18n/en.json'); const { - core: {amount: amountLabel, from, to}, + core: {amount: amountLabel, from, to, fee: feeLabel}, icrc1_transfer: {title, from_subaccount: fromSubaccountLabel} } = en; + // Title const message = [`# ${title}`]; const section = (text: string): string => `**${text}:**`; + // - Amount message.push(`${section(amountLabel)}\n${amount}`); + // - From const fromNullishSubaccount = fromNullable(fromSubaccount); const fromAccount = encodeIcrcAccount({ owner, @@ -45,12 +49,16 @@ export const buildContentMessageIcrc1Transfer = async ({ `${section(isNullish(fromNullishSubaccount) ? from : fromSubaccountLabel)}\n${fromAccount}` ); + // - To const toAccount = encodeIcrcAccount({ owner: toOwner, subaccount: fromNullable(toSubaccount) }); message.push(`${section(to)}\n${toAccount}`); + // - Fee + message.push(`${section(feeLabel)}\n${fee}`); + return {success: true, message: message.join('\n\n')}; } catch (err: unknown) { return {success: false, err}; From 772a5b80bd90abd8173a7fe5881f18d780403b3e Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Sat, 21 Dec 2024 15:09:44 +0100 Subject: [PATCH 08/31] feat: memo --- src/builders/signer.builders.spec.ts | 43 +++++++++++++++++++++++++++- src/builders/signer.builders.ts | 21 ++++++++++++-- src/i18n/en.json | 3 +- src/types/i18n.ts | 3 +- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index 81a58d7d..2fba7396 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -1,7 +1,7 @@ import {Ed25519KeyIdentity} from '@dfinity/identity'; import {encodeIcrcAccount} from '@dfinity/ledger-icrc'; import {Principal} from '@dfinity/principal'; -import {fromNullable} from '@dfinity/utils'; +import {asciiStringToByteArray, fromNullable, hexStringToUint8Array} from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; import {TransferArgs as TransferArgsType} from '../declarations/icrc-1'; import {mockCallCanisterParams} from '../mocks/call-canister.mocks'; @@ -129,6 +129,47 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} 10330`); }); + it('should build a consent message with a memo', async () => { + const memo = asciiStringToByteArray("PUPT"); // Reverse top-up memo + + console.log(memo) + + const arg = encodeIdl({ + recordClass: TransferArgs, + rawArgs: { + ...rawArgs, + memo: [memo] + } + }); + + const result = await buildContentMessageIcrc1Transfer({ + arg: base64ToUint8Array(arg), + owner: owner.getPrincipal() + }); + + expect(result.success).toBeTruthy(); + + const {message} = result as SignerBuildersResultSuccess; + + expect(message).not.toBeUndefined(); + expect(message).toEqual(`# Approve the transfer of funds + +**Amount:** +${rawArgs.amount} + +**From:** +${encodeIcrcAccount({owner: owner.getPrincipal()})} + +**To:** +${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} + +**Fee:** +10330 + +**Memo:** +0x50555054`); + }); + it('should not build a consent message for invalid arg', async () => { const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(mockCallCanisterParams.arg), diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 168e4ebf..92227570 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -1,6 +1,12 @@ import {encodeIcrcAccount, IcrcTransferArg} from '@dfinity/ledger-icrc'; import {Principal} from '@dfinity/principal'; -import {fromNullable, isNullish} from '@dfinity/utils'; +import { + arrayOfNumberToUint8Array, + fromNullable, + isNullish, + nonNullish, + uint8ArrayToHexString +} from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; import {SignerBuildersResult} from '../types/signer-builders'; import {decodeIdl} from '../utils/idl.utils'; @@ -17,7 +23,8 @@ export const buildContentMessageIcrc1Transfer = async ({ amount, from_subaccount: fromSubaccount, to: {owner: toOwner, subaccount: toSubaccount}, - fee + fee, + memo } = decodeIdl({ recordClass: TransferArgs, bytes: arg @@ -27,7 +34,7 @@ export const buildContentMessageIcrc1Transfer = async ({ const {default: en} = await import('../i18n/en.json'); const { - core: {amount: amountLabel, from, to, fee: feeLabel}, + core: {amount: amountLabel, from, to, fee: feeLabel, memo: memoLabel}, icrc1_transfer: {title, from_subaccount: fromSubaccountLabel} } = en; @@ -59,6 +66,14 @@ export const buildContentMessageIcrc1Transfer = async ({ // - Fee message.push(`${section(feeLabel)}\n${fee}`); + // - Memo + const nullishMemo = fromNullable(memo); + if (nonNullish(nullishMemo)) { + message.push( + `${section(memoLabel)}\n0x${uint8ArrayToHexString(nullishMemo instanceof Uint8Array ? nullishMemo : arrayOfNumberToUint8Array(nullishMemo))}` + ); + } + return {success: true, message: message.join('\n\n')}; } catch (err: unknown) { return {success: false, err}; diff --git a/src/i18n/en.json b/src/i18n/en.json index 536dc27f..c9abeaac 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -3,7 +3,8 @@ "amount": "Amount", "from": "From", "to": "To", - "fee": "Fee" + "fee": "Fee", + "memo": "Memo" }, "icrc1_transfer": { "title": "Approve the transfer of funds", diff --git a/src/types/i18n.ts b/src/types/i18n.ts index 3071a3f7..dd293358 100644 --- a/src/types/i18n.ts +++ b/src/types/i18n.ts @@ -6,7 +6,8 @@ export const i18nCoreSchema = z amount: z.string(), from: z.string(), to: z.string(), - fee: z.string() + fee: z.string(), + memo: z.string() }) .strict(); From 083c414d6e06ce567c9de63eee9e4d44082ccad8 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Sat, 21 Dec 2024 15:22:03 +0100 Subject: [PATCH 09/31] chore: lint --- src/builders/signer.builders.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index 2fba7396..1e9dcc31 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -1,7 +1,7 @@ import {Ed25519KeyIdentity} from '@dfinity/identity'; import {encodeIcrcAccount} from '@dfinity/ledger-icrc'; import {Principal} from '@dfinity/principal'; -import {asciiStringToByteArray, fromNullable, hexStringToUint8Array} from '@dfinity/utils'; +import {asciiStringToByteArray, fromNullable} from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; import {TransferArgs as TransferArgsType} from '../declarations/icrc-1'; import {mockCallCanisterParams} from '../mocks/call-canister.mocks'; @@ -130,9 +130,7 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} }); it('should build a consent message with a memo', async () => { - const memo = asciiStringToByteArray("PUPT"); // Reverse top-up memo - - console.log(memo) + const memo = asciiStringToByteArray('PUPT'); // Reverse top-up memo const arg = encodeIdl({ recordClass: TransferArgs, From 8a8c97235ec5f2beec6a822041aa98bf03e004f4 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 07:57:28 +0100 Subject: [PATCH 10/31] feat: format utils --- src/utils/format.utils.spec.ts | 35 ++++++++++++++++++++++++++++++++++ src/utils/format.utils.ts | 8 ++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/utils/format.utils.spec.ts create mode 100644 src/utils/format.utils.ts diff --git a/src/utils/format.utils.spec.ts b/src/utils/format.utils.spec.ts new file mode 100644 index 00000000..aa740c20 --- /dev/null +++ b/src/utils/format.utils.spec.ts @@ -0,0 +1,35 @@ +import {formatAmount} from './format.utils'; + +describe('formatAmount', () => { + it('formats amounts with the specified decimals', () => { + expect(formatAmount({amount: 123456n, decimals: 2})).toBe('1,234.56'); + expect(formatAmount({amount: 1000000n, decimals: 6})).toBe('1.000000'); + }); + + it('formats zero amount with decimals', () => { + expect(formatAmount({amount: 0n, decimals: 2})).toBe('0.00'); + expect(formatAmount({amount: 0n, decimals: 6})).toBe('0.000000'); + }); + + it('handles large amounts properly', () => { + expect(formatAmount({amount: 123456789012345n, decimals: 8})).toBe('1,234,567.89012345'); + }); + + it('handles small decimals without rounding errors', () => { + expect(formatAmount({amount: 1n, decimals: 8})).toBe('0.00000001'); + expect(formatAmount({amount: 10n, decimals: 8})).toBe('0.00000010'); + expect(formatAmount({amount: 100n, decimals: 8})).toBe('0.00000100'); + expect(formatAmount({amount: 100_000_000n, decimals: 8})).toBe('1.00000000'); + expect(formatAmount({amount: 1_000_000_000n, decimals: 8})).toBe('10.00000000'); + expect(formatAmount({amount: 1_010_000_000n, decimals: 8})).toBe('10.10000000'); + expect(formatAmount({amount: 1_012_300_000n, decimals: 8})).toBe('10.12300000'); + expect(formatAmount({amount: 20_000_000_000n, decimals: 8})).toBe('200.00000000'); + expect(formatAmount({amount: 20_000_000_001n, decimals: 8})).toBe('200.00000001'); + expect(formatAmount({amount: 200_000_000_000n, decimals: 8})).toBe(`2,000.00000000`); + expect(formatAmount({amount: 200_000_000_000_000n, decimals: 8})).toBe(`2,000,000.00000000`); + }); + + it('throws an error for invalid decimals', () => { + expect(() => formatAmount({amount: 100n, decimals: -1})).toThrow(); + }); +}); diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts new file mode 100644 index 00000000..76861faf --- /dev/null +++ b/src/utils/format.utils.ts @@ -0,0 +1,8 @@ +export const formatAmount = ({amount, decimals}: {amount: bigint; decimals: number}): string => { + const converted = Number(amount) / 10 ** decimals; + + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }).format(converted); +}; From 6c1543f2e360b73f323027e7700ff193bb9ce8f2 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 07:57:38 +0100 Subject: [PATCH 11/31] feat: mapper --- package-lock.json | 19 ++++++++++--------- package.json | 4 +++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index ba2f8543..d3e5dc0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@dfinity/oisy-wallet-signer", "version": "0.0.3", "license": "Apache-2.0", + "dependencies": { + "@dfinity/ledger-icrc": "^2.6.4-next-2024-12-23" + }, "devDependencies": { "@dfinity/eslint-config-oisy-wallet": "^0.0.6", "@dfinity/identity": "^2.1.3", @@ -29,7 +32,6 @@ "@dfinity/agent": "^2.1.3", "@dfinity/candid": "^2.1.3", "@dfinity/ledger-icp": "^2.6.4", - "@dfinity/ledger-icrc": "^2.6.4", "@dfinity/principal": "^2.1.3", "@dfinity/utils": "^2.7.1", "borc": "^2.1.1", @@ -250,16 +252,15 @@ } }, "node_modules/@dfinity/ledger-icrc": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@dfinity/ledger-icrc/-/ledger-icrc-2.6.4.tgz", - "integrity": "sha512-Q7B/D6NhmWQW4qFWZVaV8B6efuRto7hDa0GmT1hxeeUPcyXt42AtfNgyqByDu82Wsank90AW4hgBS0UhKGEB9Q==", + "version": "2.6.4-next-2024-12-23", + "resolved": "https://registry.npmjs.org/@dfinity/ledger-icrc/-/ledger-icrc-2.6.4-next-2024-12-23.tgz", + "integrity": "sha512-48IUVt7UquOJFe4F4jIzmvCPBiu6iobbI/UG6AeZPSxulL6IfxLDD7dxrs3ZRrGN4/4j7Gd1Gqi2SJOLv97zOw==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { - "@dfinity/agent": "^2.0.0", - "@dfinity/candid": "^2.0.0", - "@dfinity/principal": "^2.0.0", - "@dfinity/utils": "^2.7.1" + "@dfinity/agent": "*", + "@dfinity/candid": "*", + "@dfinity/principal": "*", + "@dfinity/utils": "*" } }, "node_modules/@dfinity/principal": { diff --git a/package.json b/package.json index e3f39815..9af805d4 100644 --- a/package.json +++ b/package.json @@ -95,11 +95,13 @@ "@dfinity/agent": "^2.1.3", "@dfinity/candid": "^2.1.3", "@dfinity/ledger-icp": "^2.6.4", - "@dfinity/ledger-icrc": "^2.6.4", "@dfinity/principal": "^2.1.3", "@dfinity/utils": "^2.7.1", "borc": "^2.1.1", "simple-cbor": "^0.4.1", "zod": "^3.23.8" + }, + "dependencies": { + "@dfinity/ledger-icrc": "^2.6.4-next-2024-12-23" } } From a5877488eb1e9e42e7e94556bfebf841310d1030 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 08:01:19 +0100 Subject: [PATCH 12/31] feat: min two decimals for readability --- src/utils/format.utils.spec.ts | 22 +++++++++++----------- src/utils/format.utils.ts | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/utils/format.utils.spec.ts b/src/utils/format.utils.spec.ts index aa740c20..5bbe192a 100644 --- a/src/utils/format.utils.spec.ts +++ b/src/utils/format.utils.spec.ts @@ -3,12 +3,12 @@ import {formatAmount} from './format.utils'; describe('formatAmount', () => { it('formats amounts with the specified decimals', () => { expect(formatAmount({amount: 123456n, decimals: 2})).toBe('1,234.56'); - expect(formatAmount({amount: 1000000n, decimals: 6})).toBe('1.000000'); + expect(formatAmount({amount: 1000000n, decimals: 6})).toBe('1.00'); }); it('formats zero amount with decimals', () => { expect(formatAmount({amount: 0n, decimals: 2})).toBe('0.00'); - expect(formatAmount({amount: 0n, decimals: 6})).toBe('0.000000'); + expect(formatAmount({amount: 0n, decimals: 6})).toBe('0.00'); }); it('handles large amounts properly', () => { @@ -17,16 +17,16 @@ describe('formatAmount', () => { it('handles small decimals without rounding errors', () => { expect(formatAmount({amount: 1n, decimals: 8})).toBe('0.00000001'); - expect(formatAmount({amount: 10n, decimals: 8})).toBe('0.00000010'); - expect(formatAmount({amount: 100n, decimals: 8})).toBe('0.00000100'); - expect(formatAmount({amount: 100_000_000n, decimals: 8})).toBe('1.00000000'); - expect(formatAmount({amount: 1_000_000_000n, decimals: 8})).toBe('10.00000000'); - expect(formatAmount({amount: 1_010_000_000n, decimals: 8})).toBe('10.10000000'); - expect(formatAmount({amount: 1_012_300_000n, decimals: 8})).toBe('10.12300000'); - expect(formatAmount({amount: 20_000_000_000n, decimals: 8})).toBe('200.00000000'); + expect(formatAmount({amount: 10n, decimals: 8})).toBe('0.0000001'); + expect(formatAmount({amount: 100n, decimals: 8})).toBe('0.000001'); + expect(formatAmount({amount: 100_000_000n, decimals: 8})).toBe('1.00'); + expect(formatAmount({amount: 1_000_000_000n, decimals: 8})).toBe('10.00'); + expect(formatAmount({amount: 1_010_000_000n, decimals: 8})).toBe('10.10'); + expect(formatAmount({amount: 1_012_300_000n, decimals: 8})).toBe('10.123'); + expect(formatAmount({amount: 20_000_000_000n, decimals: 8})).toBe('200.00'); expect(formatAmount({amount: 20_000_000_001n, decimals: 8})).toBe('200.00000001'); - expect(formatAmount({amount: 200_000_000_000n, decimals: 8})).toBe(`2,000.00000000`); - expect(formatAmount({amount: 200_000_000_000_000n, decimals: 8})).toBe(`2,000,000.00000000`); + expect(formatAmount({amount: 200_000_000_000n, decimals: 8})).toBe(`2,000.00`); + expect(formatAmount({amount: 200_000_000_000_000n, decimals: 8})).toBe(`2,000,000.00`); }); it('throws an error for invalid decimals', () => { diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts index 76861faf..53b3508a 100644 --- a/src/utils/format.utils.ts +++ b/src/utils/format.utils.ts @@ -2,7 +2,7 @@ export const formatAmount = ({amount, decimals}: {amount: bigint; decimals: numb const converted = Number(amount) / 10 ** decimals; return new Intl.NumberFormat('en-US', { - minimumFractionDigits: decimals, + minimumFractionDigits: 2, maximumFractionDigits: decimals }).format(converted); }; From e338fa3cc40d04000c6f5d40d9fac910a7a7ac85 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 08:08:56 +0100 Subject: [PATCH 13/31] feat: format amount --- src/builders/signer.builders.spec.ts | 33 +++++++++++++++++++--------- src/builders/signer.builders.ts | 9 +++++--- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index 1e9dcc31..bcf047ad 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -15,8 +15,16 @@ import {buildContentMessageIcrc1Transfer} from './signer.builders'; describe('Signer builders', () => { const owner = Ed25519KeyIdentity.generate(); + const token = { + name: 'Token', + symbol: 'TKN', + fee: 11_100n, + decimals: 8, + icon: 'a-logo' + }; + const rawArgs: TransferArgsType = { - amount: 6660000n, + amount: 320_000_000_001n, created_at_time: [1727696940356000000n], fee: [10330n], from_subaccount: [], @@ -31,7 +39,8 @@ describe('Signer builders', () => { it('should build a consent message for a defined arg (without fee)', async () => { const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(mockIcrcLocalCallParams.arg), - owner: Principal.fromText(mockPrincipalText) + owner: Principal.fromText(mockPrincipalText), + token }); expect(result.success).toBeTruthy(); @@ -42,7 +51,7 @@ describe('Signer builders', () => { expect(message).toEqual(`# Approve the transfer of funds **Amount:** -5000000 +0.05 **From:** ${mockPrincipalText} @@ -67,7 +76,8 @@ s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(arg), - owner: owner.getPrincipal() + owner: owner.getPrincipal(), + token }); expect(result.success).toBeTruthy(); @@ -78,7 +88,7 @@ s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae expect(message).toEqual(`# Approve the transfer of funds **Amount:** -${rawArgs.amount} +3,200.00000001 **From subaccount:** ${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})} @@ -106,7 +116,8 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(arg), - owner: owner.getPrincipal() + owner: owner.getPrincipal(), + token }); expect(result.success).toBeTruthy(); @@ -117,7 +128,7 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t expect(message).toEqual(`# Approve the transfer of funds **Amount:** -${rawArgs.amount} +3,200.00000001 **From:** ${encodeIcrcAccount({owner: owner.getPrincipal()})} @@ -142,7 +153,8 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(arg), - owner: owner.getPrincipal() + owner: owner.getPrincipal(), + token }); expect(result.success).toBeTruthy(); @@ -153,7 +165,7 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} expect(message).toEqual(`# Approve the transfer of funds **Amount:** -${rawArgs.amount} +3,200.00000001 **From:** ${encodeIcrcAccount({owner: owner.getPrincipal()})} @@ -171,7 +183,8 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t it('should not build a consent message for invalid arg', async () => { const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(mockCallCanisterParams.arg), - owner: owner.getPrincipal() + owner: owner.getPrincipal(), + token }); expect(result.success).toBeFalsy(); diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 92227570..787ea182 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -1,4 +1,4 @@ -import {encodeIcrcAccount, IcrcTransferArg} from '@dfinity/ledger-icrc'; +import {encodeIcrcAccount, IcrcTokenMetadata, IcrcTransferArg} from '@dfinity/ledger-icrc'; import {Principal} from '@dfinity/principal'; import { arrayOfNumberToUint8Array, @@ -9,14 +9,17 @@ import { } from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; import {SignerBuildersResult} from '../types/signer-builders'; +import {formatAmount} from '../utils/format.utils'; import {decodeIdl} from '../utils/idl.utils'; export const buildContentMessageIcrc1Transfer = async ({ arg, - owner + owner, + token: {name: tokenName, decimals: tokenDecimals} }: { arg: ArrayBuffer; owner: Principal; + token: IcrcTokenMetadata; }): Promise => { try { const { @@ -44,7 +47,7 @@ export const buildContentMessageIcrc1Transfer = async ({ const section = (text: string): string => `**${text}:**`; // - Amount - message.push(`${section(amountLabel)}\n${amount}`); + message.push(`${section(amountLabel)}\n${formatAmount({amount, decimals: tokenDecimals})}`); // - From const fromNullishSubaccount = fromNullable(fromSubaccount); From ff5e86dc096ac48003a16a836c2a269e9d666b17 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 08:13:52 +0100 Subject: [PATCH 14/31] feat: fee format and fallback --- src/builders/signer.builders.spec.ts | 47 ++++++++++++++++++++++++---- src/builders/signer.builders.ts | 6 ++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index bcf047ad..0b91b63c 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -18,7 +18,7 @@ describe('Signer builders', () => { const token = { name: 'Token', symbol: 'TKN', - fee: 11_100n, + fee: 10_000n, decimals: 8, icon: 'a-logo' }; @@ -26,7 +26,7 @@ describe('Signer builders', () => { const rawArgs: TransferArgsType = { amount: 320_000_000_001n, created_at_time: [1727696940356000000n], - fee: [10330n], + fee: [100_330n], from_subaccount: [], memo: [], to: { @@ -60,7 +60,7 @@ ${mockPrincipalText} s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae **Fee:** -`); +0.0001`); }); it('should build a consent message with a from subaccount', async () => { @@ -97,7 +97,7 @@ ${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} **Fee:** -10330`); +0.0010033`); }); it('should build a consent message with a to subaccount', async () => { @@ -137,7 +137,7 @@ ${encodeIcrcAccount({owner: owner.getPrincipal()})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} **Fee:** -10330`); +0.0010033`); }); it('should build a consent message with a memo', async () => { @@ -174,7 +174,7 @@ ${encodeIcrcAccount({owner: owner.getPrincipal()})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} **Fee:** -10330 +0.0010033 **Memo:** 0x50555054`); @@ -194,5 +194,40 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t expect(err).not.toBeUndefined(); expect((err as Error).message).toContain('Wrong magic number'); }); + + it('should build a consent message with token fee if no fee as arg', async () => { + const arg = encodeIdl({ + recordClass: TransferArgs, + rawArgs: { + ...rawArgs, + fee: [] + } + }); + + const result = await buildContentMessageIcrc1Transfer({ + arg: base64ToUint8Array(arg), + owner: owner.getPrincipal(), + token + }); + + expect(result.success).toBeTruthy(); + + const {message} = result as SignerBuildersResultSuccess; + + expect(message).not.toBeUndefined(); + expect(message).toEqual(`# Approve the transfer of funds + +**Amount:** +3,200.00000001 + +**From:** +${encodeIcrcAccount({owner: owner.getPrincipal()})} + +**To:** +${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} + +**Fee:** +0.0001`); + }); }); }); diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 787ea182..1abadb13 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -15,7 +15,7 @@ import {decodeIdl} from '../utils/idl.utils'; export const buildContentMessageIcrc1Transfer = async ({ arg, owner, - token: {name: tokenName, decimals: tokenDecimals} + token: {name: tokenName, decimals: tokenDecimals, fee: tokenFee} }: { arg: ArrayBuffer; owner: Principal; @@ -67,7 +67,9 @@ export const buildContentMessageIcrc1Transfer = async ({ message.push(`${section(to)}\n${toAccount}`); // - Fee - message.push(`${section(feeLabel)}\n${fee}`); + message.push( + `${section(feeLabel)}\n${formatAmount({amount: fromNullable(fee) ?? tokenFee, decimals: tokenDecimals})}` + ); // - Memo const nullishMemo = fromNullable(memo); From c81046eb1433e2e0f245c90b0c5d175cdd5aaa7f Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 08:16:43 +0100 Subject: [PATCH 15/31] feat: token symbol --- src/builders/signer.builders.spec.ts | 20 ++++++++++---------- src/builders/signer.builders.ts | 8 +++++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index 0b91b63c..422364a9 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -51,7 +51,7 @@ describe('Signer builders', () => { expect(message).toEqual(`# Approve the transfer of funds **Amount:** -0.05 +0.05 TKN **From:** ${mockPrincipalText} @@ -60,7 +60,7 @@ ${mockPrincipalText} s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae **Fee:** -0.0001`); +0.0001 TKN`); }); it('should build a consent message with a from subaccount', async () => { @@ -88,7 +88,7 @@ s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae expect(message).toEqual(`# Approve the transfer of funds **Amount:** -3,200.00000001 +3,200.00000001 TKN **From subaccount:** ${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})} @@ -97,7 +97,7 @@ ${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} **Fee:** -0.0010033`); +0.0010033 TKN`); }); it('should build a consent message with a to subaccount', async () => { @@ -128,7 +128,7 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t expect(message).toEqual(`# Approve the transfer of funds **Amount:** -3,200.00000001 +3,200.00000001 TKN **From:** ${encodeIcrcAccount({owner: owner.getPrincipal()})} @@ -137,7 +137,7 @@ ${encodeIcrcAccount({owner: owner.getPrincipal()})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} **Fee:** -0.0010033`); +0.0010033 TKN`); }); it('should build a consent message with a memo', async () => { @@ -165,7 +165,7 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} expect(message).toEqual(`# Approve the transfer of funds **Amount:** -3,200.00000001 +3,200.00000001 TKN **From:** ${encodeIcrcAccount({owner: owner.getPrincipal()})} @@ -174,7 +174,7 @@ ${encodeIcrcAccount({owner: owner.getPrincipal()})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} **Fee:** -0.0010033 +0.0010033 TKN **Memo:** 0x50555054`); @@ -218,7 +218,7 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t expect(message).toEqual(`# Approve the transfer of funds **Amount:** -3,200.00000001 +3,200.00000001 TKN **From:** ${encodeIcrcAccount({owner: owner.getPrincipal()})} @@ -227,7 +227,7 @@ ${encodeIcrcAccount({owner: owner.getPrincipal()})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} **Fee:** -0.0001`); +0.0001 TKN`); }); }); }); diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 1abadb13..6d108c54 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -15,7 +15,7 @@ import {decodeIdl} from '../utils/idl.utils'; export const buildContentMessageIcrc1Transfer = async ({ arg, owner, - token: {name: tokenName, decimals: tokenDecimals, fee: tokenFee} + token: {symbol: tokenSymbol, decimals: tokenDecimals, fee: tokenFee} }: { arg: ArrayBuffer; owner: Principal; @@ -47,7 +47,9 @@ export const buildContentMessageIcrc1Transfer = async ({ const section = (text: string): string => `**${text}:**`; // - Amount - message.push(`${section(amountLabel)}\n${formatAmount({amount, decimals: tokenDecimals})}`); + message.push( + `${section(amountLabel)}\n${formatAmount({amount, decimals: tokenDecimals})} ${tokenSymbol}` + ); // - From const fromNullishSubaccount = fromNullable(fromSubaccount); @@ -68,7 +70,7 @@ export const buildContentMessageIcrc1Transfer = async ({ // - Fee message.push( - `${section(feeLabel)}\n${formatAmount({amount: fromNullable(fee) ?? tokenFee, decimals: tokenDecimals})}` + `${section(feeLabel)}\n${formatAmount({amount: fromNullable(fee) ?? tokenFee, decimals: tokenDecimals})} ${tokenSymbol}` ); // - Memo From afd590c8899fda73c26d59eff5c4eb28dbf3bd4d Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 08:43:02 +0100 Subject: [PATCH 16/31] refactor: extract consent message --- src/services/signer.service.ts | 48 ++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/services/signer.service.ts b/src/services/signer.service.ts index 7c271b44..ac5d1f17 100644 --- a/src/services/signer.service.ts +++ b/src/services/signer.service.ts @@ -1,6 +1,7 @@ import {Principal} from '@dfinity/principal'; import {isNullish, notEmptyString} from '@dfinity/utils'; import {SignerApi} from '../api/signer.api'; +import {icrc21_consent_message_response} from '../declarations/icrc-21'; import { notifyErrorActionAborted, notifyErrorMissingPrompt, @@ -25,7 +26,7 @@ export class SignerService { readonly #signerApi = new SignerApi(); async assertAndPromptConsentMessage({ - params: {canisterId, method, arg, sender}, + params: {sender, ...params}, prompt, notify, options: {owner, host} @@ -52,22 +53,9 @@ export class SignerService { prompt({origin, status: 'loading'}); try { - const response = await this.#signerApi.consentMessage({ - owner, - host, - canisterId, - request: { - method, - arg: base64ToUint8Array(arg), - // TODO: consumer should be able to define user_preferences - user_preferences: { - metadata: { - language: 'en', - utc_offset_minutes: [] - }, - device_spec: [] - } - } + const response = await this.callConsentMessage({ + params, + options: {host, owner} }); if ('Err' in response) { @@ -171,6 +159,32 @@ export class SignerService { return {result: 'invalid'}; } + private async callConsentMessage({ + params: {canisterId, method, arg}, + options: {owner, host} + }: { + params: Omit; + options: SignerOptions; + }): Promise { + return await this.#signerApi.consentMessage({ + owner, + host, + canisterId, + request: { + method, + arg: base64ToUint8Array(arg), + // TODO: consumer should be able to define user_preferences + user_preferences: { + metadata: { + language: 'en', + utc_offset_minutes: [] + }, + device_spec: [] + } + } + }); + } + private async promptConsentMessage({ prompt, ...payload From ceeb02a4211a8293d36d5e1b5782835866e5e87d Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 09:50:17 +0100 Subject: [PATCH 17/31] feat: use builders --- src/api/signer.api.ts | 18 +++ src/builders/signer.builders.spec.ts | 26 ++-- src/builders/signer.builders.ts | 15 +-- src/constants/signer.builders.constants.ts | 6 + src/services/signer.service.ts | 136 ++++++++++++++++++--- src/types/signer-builders.ts | 23 ++-- 6 files changed, 174 insertions(+), 50 deletions(-) create mode 100644 src/constants/signer.builders.constants.ts diff --git a/src/api/signer.api.ts b/src/api/signer.api.ts index b9908b23..61321fe4 100644 --- a/src/api/signer.api.ts +++ b/src/api/signer.api.ts @@ -1,3 +1,6 @@ +import {IcrcLedgerCanister} from '@dfinity/ledger-icrc'; +import type {IcrcTokenMetadataResponse} from '@dfinity/ledger-icrc/dist/types/types/ledger.responses'; +import {Principal} from '@dfinity/principal'; import {arrayBufferToUint8Array} from '@dfinity/utils'; import {encode} from '../agent/agentjs-cbor-copy'; import type {CustomHttpAgentResponse} from '../agent/custom-http-agent'; @@ -26,6 +29,21 @@ export class SignerApi extends Icrc21Canister { return this.encodeResult(result); } + async ledgerMetadata({ + host, + owner, + canisterId + }: {canisterId: string | Principal} & SignerOptions): Promise { + const agent = await this.getAgent({host, owner}); + + const {metadata} = IcrcLedgerCanister.create({ + agent: agent.agent, + canisterId: canisterId instanceof Principal ? canisterId : Principal.fromText(canisterId) + }); + + return await metadata({certified: true}); + } + private encodeResult({ requestDetails: contentMap, certificate diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index 422364a9..ac725231 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -7,7 +7,7 @@ import {TransferArgs as TransferArgsType} from '../declarations/icrc-1'; import {mockCallCanisterParams} from '../mocks/call-canister.mocks'; import {mockPrincipalText} from '../mocks/icrc-accounts.mocks'; import {mockIcrcLocalCallParams} from '../mocks/icrc-call-utils.mocks'; -import {SignerBuildersResultError, SignerBuildersResultSuccess} from '../types/signer-builders'; +import {SignerBuildersResultError, SignerBuildersResultOk} from '../types/signer-builders'; import {base64ToUint8Array} from '../utils/base64.utils'; import {encodeIdl} from '../utils/idl.utils'; import {buildContentMessageIcrc1Transfer} from './signer.builders'; @@ -43,9 +43,9 @@ describe('Signer builders', () => { token }); - expect(result.success).toBeTruthy(); + expect('Ok' in result).toBeTruthy(); - const {message} = result as SignerBuildersResultSuccess; + const {Ok: message} = result as SignerBuildersResultOk; expect(message).not.toBeUndefined(); expect(message).toEqual(`# Approve the transfer of funds @@ -80,9 +80,9 @@ s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae token }); - expect(result.success).toBeTruthy(); + expect('Ok' in result).toBeTruthy(); - const {message} = result as SignerBuildersResultSuccess; + const {Ok: message} = result as SignerBuildersResultOk; expect(message).not.toBeUndefined(); expect(message).toEqual(`# Approve the transfer of funds @@ -120,9 +120,9 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t token }); - expect(result.success).toBeTruthy(); + expect('Ok' in result).toBeTruthy(); - const {message} = result as SignerBuildersResultSuccess; + const {Ok: message} = result as SignerBuildersResultOk; expect(message).not.toBeUndefined(); expect(message).toEqual(`# Approve the transfer of funds @@ -157,9 +157,9 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} token }); - expect(result.success).toBeTruthy(); + expect('Ok' in result).toBeTruthy(); - const {message} = result as SignerBuildersResultSuccess; + const {Ok: message} = result as SignerBuildersResultOk; expect(message).not.toBeUndefined(); expect(message).toEqual(`# Approve the transfer of funds @@ -187,9 +187,9 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t token }); - expect(result.success).toBeFalsy(); + expect('Ok' in result).toBeFalsy(); - const {err} = result as SignerBuildersResultError; + const {Err: err} = result as SignerBuildersResultError; expect(err).not.toBeUndefined(); expect((err as Error).message).toContain('Wrong magic number'); @@ -210,9 +210,9 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t token }); - expect(result.success).toBeTruthy(); + expect('Ok' in result).toBeTruthy(); - const {message} = result as SignerBuildersResultSuccess; + const {Ok: message} = result as SignerBuildersResultOk; expect(message).not.toBeUndefined(); expect(message).toEqual(`# Approve the transfer of funds diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 6d108c54..912cc7fe 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -1,5 +1,4 @@ -import {encodeIcrcAccount, IcrcTokenMetadata, IcrcTransferArg} from '@dfinity/ledger-icrc'; -import {Principal} from '@dfinity/principal'; +import {encodeIcrcAccount, IcrcTransferArg} from '@dfinity/ledger-icrc'; import { arrayOfNumberToUint8Array, fromNullable, @@ -8,18 +7,14 @@ import { uint8ArrayToHexString } from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; -import {SignerBuildersResult} from '../types/signer-builders'; +import {SignerBuilderFn, SignerBuildersResult} from '../types/signer-builders'; import {formatAmount} from '../utils/format.utils'; import {decodeIdl} from '../utils/idl.utils'; -export const buildContentMessageIcrc1Transfer = async ({ +export const buildContentMessageIcrc1Transfer: SignerBuilderFn = async ({ arg, owner, token: {symbol: tokenSymbol, decimals: tokenDecimals, fee: tokenFee} -}: { - arg: ArrayBuffer; - owner: Principal; - token: IcrcTokenMetadata; }): Promise => { try { const { @@ -81,8 +76,8 @@ export const buildContentMessageIcrc1Transfer = async ({ ); } - return {success: true, message: message.join('\n\n')}; + return {Ok: message.join('\n\n')}; } catch (err: unknown) { - return {success: false, err}; + return {Err: err}; } }; diff --git a/src/constants/signer.builders.constants.ts b/src/constants/signer.builders.constants.ts new file mode 100644 index 00000000..cc8b2aa3 --- /dev/null +++ b/src/constants/signer.builders.constants.ts @@ -0,0 +1,6 @@ +import {buildContentMessageIcrc1Transfer} from '../builders/signer.builders'; +import {SignerBuilderFn, SignerBuilderMethods} from '../types/signer-builders'; + +export const SIGNER_BUILDERS: Record = { + icrc1_transfer: buildContentMessageIcrc1Transfer +}; diff --git a/src/services/signer.service.ts b/src/services/signer.service.ts index ac5d1f17..06d5e0e6 100644 --- a/src/services/signer.service.ts +++ b/src/services/signer.service.ts @@ -1,7 +1,9 @@ +import {mapTokenMetadata} from '@dfinity/ledger-icrc'; import {Principal} from '@dfinity/principal'; import {isNullish, notEmptyString} from '@dfinity/utils'; import {SignerApi} from '../api/signer.api'; -import {icrc21_consent_message_response} from '../declarations/icrc-21'; +import {SIGNER_BUILDERS} from '../constants/signer.builders.constants'; +import {icrc21_consent_info, icrc21_consent_message_response} from '../declarations/icrc-21'; import { notifyErrorActionAborted, notifyErrorMissingPrompt, @@ -53,10 +55,38 @@ export class SignerService { prompt({origin, status: 'loading'}); try { - const response = await this.callConsentMessage({ + const loadParams = { params, options: {host, owner} - }); + }; + + /** + * If the ICRC-21 call to fetch the consent message fails, it might be due to the fact + * that the targeted canister does not implement the ICRC-21 specification. + * + * To address the potential lack of support for the most common types of calls for ledgers, + * namely transfer and approve, we use custom builders. Those builders construct + * messages similar to those that would be implemented by the canisters. + * + * @returns {Promise} - The consent message response. + * @throws The potential original error from the ICRC-21 call. The errors related to + * the custom builder is ignored. + **/ + const loadConsentMessage = async (): Promise => { + try { + return await this.callConsentMessage(loadParams); + } catch (err: unknown) { + const fallbackMessage = await this.tryFallbackOnError(loadParams); + + if ('Ok' in fallbackMessage) { + return fallbackMessage; + } + + throw err; + } + }; + + const response = await loadConsentMessage(); if ('Err' in response) { const {Err} = response; @@ -81,22 +111,7 @@ export class SignerService { return {result}; } catch (err: unknown) { - // TODO: 2001 for not supported consent message - i.e. method is not implemented. - // see https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_49_call_canister.md#errors - - // TODO: Likewise for example if canister is out of cycles or stopped etc. it should not throw 4000. - - prompt({origin, status: 'error', details: err}); - - notifyNetworkError({ - ...notify, - message: - err instanceof Error && notEmptyString(err.message) - ? err.message - : 'An unknown error occurred' - }); - - return {result: 'error'}; + return this.notifyError({err, prompt, notify}); } } @@ -185,6 +200,35 @@ export class SignerService { }); } + private notifyError({ + err, + notify, + prompt + }: { + err: unknown; + notify: Notify; + prompt: ConsentMessagePrompt; + }): {result: 'error'} { + // TODO: 2001 for not supported consent message - i.e. method is not implemented. + // see https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_49_call_canister.md#errors + + // TODO: Likewise for example if canister is out of cycles or stopped etc. it should not throw 4000. + + const {origin} = notify; + + prompt({origin, status: 'error', details: err}); + + notifyNetworkError({ + ...notify, + message: + err instanceof Error && notEmptyString(err.message) + ? err.message + : 'An unknown error occurred' + }); + + return {result: 'error'}; + } + private async promptConsentMessage({ prompt, ...payload @@ -207,4 +251,58 @@ export class SignerService { return await promise; } + + private async tryFallbackOnError({ + params: {method, arg, canisterId}, + options: {owner, host} + }: { + params: Omit; + options: SignerOptions; + }): Promise<{NoFallback: null} | {Ok: icrc21_consent_info} | {Err: unknown}> { + const fn = SIGNER_BUILDERS[method]; + + if (isNullish(fn)) { + return {NoFallback: null}; + } + + try { + const tokenResponse = await this.#signerApi.ledgerMetadata({ + canisterId, + host, + owner + }); + + const token = mapTokenMetadata(tokenResponse); + + if (isNullish(token)) { + return {Err: new Error('Incomplete token metadata.')}; + } + + const message = await fn({ + arg: base64ToUint8Array(arg), + token, + owner: owner.getPrincipal() + }); + + if ('Err' in message) { + return message; + } + + const {Ok: GenericDisplayMessage} = message; + + const consentMessage: icrc21_consent_info = { + metadata: { + language: 'en', + utc_offset_minutes: [] + }, + consent_message: { + GenericDisplayMessage + } + }; + + return {Ok: consentMessage}; + } catch (err: unknown) { + return {Err: err}; + } + } } diff --git a/src/types/signer-builders.ts b/src/types/signer-builders.ts index ee829ca7..cc4a0019 100644 --- a/src/types/signer-builders.ts +++ b/src/types/signer-builders.ts @@ -1,11 +1,18 @@ -export interface SignerBuildersResultSuccess { - success: true; - message: string; -} +import {IcrcTokenMetadata} from '@dfinity/ledger-icrc'; +import {Principal} from '@dfinity/principal'; + +export interface SignerBuildersResultOk {Ok: string} + +export interface SignerBuildersResultError {Err: unknown} -export interface SignerBuildersResultError { - success: false; - err: unknown; +export type SignerBuildersResult = SignerBuildersResultOk | SignerBuildersResultError; + +export interface SignerBuilderParams { + arg: ArrayBuffer; + owner: Principal; + token: IcrcTokenMetadata; } -export type SignerBuildersResult = SignerBuildersResultSuccess | SignerBuildersResultError; +export type SignerBuilderFn = (params: SignerBuilderParams) => Promise; + +export type SignerBuilderMethods = 'icrc1_transfer' | string; From 4991d3dad2ae9a66ab65c0f3b5cd21cbfef1ddc0 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 10:15:18 +0100 Subject: [PATCH 18/31] chore: rm test --- src/i18n/en.json | 5 +---- src/types/i18n.ts | 6 +----- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/i18n/en.json b/src/i18n/en.json index c9abeaac..7af2c318 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -24,10 +24,7 @@ "approval_fee": "Approval fee", "approver_account_transaction_fees": { "anonymous": "Transaction fees to be paid by your subaccount", - "owner": "Transaction fees to be paid by:", - "test": { - "yolo": "hello" - } + "owner": "Transaction fees to be paid by:" } } } diff --git a/src/types/i18n.ts b/src/types/i18n.ts index dd293358..a1c72a5e 100644 --- a/src/types/i18n.ts +++ b/src/types/i18n.ts @@ -28,11 +28,7 @@ export const i18nIcrc2_approveSchema = z withdrawal_allowance: z.object({some: z.string(), none: z.string()}), expiration_date: z.string(), approval_fee: z.string(), - approver_account_transaction_fees: z.object({ - anonymous: z.string(), - owner: z.string(), - test: z.object({yolo: z.string()}) - }) + approver_account_transaction_fees: z.object({anonymous: z.string(), owner: z.string()}) }) .strict(); From e8595bae2476ccc0dbd93947ea4674a1af7f2751 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 11:00:45 +0100 Subject: [PATCH 19/31] chore: todo --- src/services/signer.service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/services/signer.service.ts b/src/services/signer.service.ts index 06d5e0e6..5ea7e468 100644 --- a/src/services/signer.service.ts +++ b/src/services/signer.service.ts @@ -103,6 +103,11 @@ export class SignerService { const {Ok: consentInfo} = response; + // TODO: change consent message + // { + // {Ok: consentInfo} | {Warn: {consentInfo?: string, method, arg, canisterId, owner}} + // } + const {result} = await this.promptConsentMessage({consentInfo, prompt, origin}); if (result === 'rejected') { From 77fdc04cf8a9a5af1d6ee57b5c16e96dead96082 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 11:22:06 +0100 Subject: [PATCH 20/31] build: bump ic next --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50dd32c8..77450ab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.3", "license": "Apache-2.0", "dependencies": { - "@dfinity/ledger-icrc": "^2.6.4-next-2024-12-23" + "@dfinity/ledger-icrc": "^2.6.4-next-2024-12-23.1" }, "devDependencies": { "@dfinity/eslint-config-oisy-wallet": "^0.0.6", @@ -252,9 +252,9 @@ } }, "node_modules/@dfinity/ledger-icrc": { - "version": "2.6.4-next-2024-12-23", - "resolved": "https://registry.npmjs.org/@dfinity/ledger-icrc/-/ledger-icrc-2.6.4-next-2024-12-23.tgz", - "integrity": "sha512-48IUVt7UquOJFe4F4jIzmvCPBiu6iobbI/UG6AeZPSxulL6IfxLDD7dxrs3ZRrGN4/4j7Gd1Gqi2SJOLv97zOw==", + "version": "2.6.4-next-2024-12-23.1", + "resolved": "https://registry.npmjs.org/@dfinity/ledger-icrc/-/ledger-icrc-2.6.4-next-2024-12-23.1.tgz", + "integrity": "sha512-dCXBpBvCWagdZGsIFTgHSLK0mmPE/RcVSGSO+YmokTHuxfSB5Pekoe597/CleNzB41sYAwGyDKCtJ5Da6yevKw==", "license": "Apache-2.0", "peerDependencies": { "@dfinity/agent": "*", diff --git a/package.json b/package.json index 040c8a76..7731916e 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,6 @@ "zod": "^3.23.8" }, "dependencies": { - "@dfinity/ledger-icrc": "^2.6.4-next-2024-12-23" + "@dfinity/ledger-icrc": "^2.6.4-next-2024-12-23.1" } } From a5998cffb9ccde9d87f78b734e50127514aca390 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 12:20:38 +0100 Subject: [PATCH 21/31] feat: builder return object for consent message --- src/builders/signer.builders.spec.ts | 125 +++++++++++++++++++-------- src/builders/signer.builders.ts | 13 ++- src/services/signer.service.ts | 20 +---- src/types/signer-builders.ts | 3 +- 4 files changed, 104 insertions(+), 57 deletions(-) diff --git a/src/builders/signer.builders.spec.ts b/src/builders/signer.builders.spec.ts index ac725231..d9368b45 100644 --- a/src/builders/signer.builders.spec.ts +++ b/src/builders/signer.builders.spec.ts @@ -2,12 +2,17 @@ import {Ed25519KeyIdentity} from '@dfinity/identity'; import {encodeIcrcAccount} from '@dfinity/ledger-icrc'; import {Principal} from '@dfinity/principal'; import {asciiStringToByteArray, fromNullable} from '@dfinity/utils'; +import {expect} from 'vitest'; import {TransferArgs} from '../constants/icrc.idl.constants'; import {TransferArgs as TransferArgsType} from '../declarations/icrc-1'; import {mockCallCanisterParams} from '../mocks/call-canister.mocks'; import {mockPrincipalText} from '../mocks/icrc-accounts.mocks'; import {mockIcrcLocalCallParams} from '../mocks/icrc-call-utils.mocks'; -import {SignerBuildersResultError, SignerBuildersResultOk} from '../types/signer-builders'; +import { + SignerBuildersResult, + SignerBuildersResultError, + SignerBuildersResultOk +} from '../types/signer-builders'; import {base64ToUint8Array} from '../utils/base64.utils'; import {encodeIdl} from '../utils/idl.utils'; import {buildContentMessageIcrc1Transfer} from './signer.builders'; @@ -36,6 +41,26 @@ describe('Signer builders', () => { }; describe('icrc1_transfer', () => { + const expectMessage = ({ + result, + expectedMessage + }: { + result: SignerBuildersResult; + expectedMessage: string; + }) => { + expect('Ok' in result).toBeTruthy(); + + const {Ok} = result as SignerBuildersResultOk; + + expect('GenericDisplayMessage' in Ok.consent_message).toBeTruthy(); + + const {GenericDisplayMessage: message} = Ok.consent_message as { + GenericDisplayMessage: string; + }; + + expect(message).toEqual(expectedMessage); + }; + it('should build a consent message for a defined arg (without fee)', async () => { const result = await buildContentMessageIcrc1Transfer({ arg: base64ToUint8Array(mockIcrcLocalCallParams.arg), @@ -43,12 +68,9 @@ describe('Signer builders', () => { token }); - expect('Ok' in result).toBeTruthy(); - - const {Ok: message} = result as SignerBuildersResultOk; - - expect(message).not.toBeUndefined(); - expect(message).toEqual(`# Approve the transfer of funds + expectMessage({ + result, + expectedMessage: `# Approve the transfer of funds **Amount:** 0.05 TKN @@ -60,7 +82,46 @@ ${mockPrincipalText} s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae **Fee:** -0.0001 TKN`); +0.0001 TKN` + }); + }); + + it('should build a consent message in english', async () => { + const arg = encodeIdl({ + recordClass: TransferArgs, + rawArgs + }); + + const result = await buildContentMessageIcrc1Transfer({ + arg: base64ToUint8Array(arg), + owner: owner.getPrincipal(), + token + }); + + expect('Ok' in result); + + const {Ok} = result as SignerBuildersResultOk; + + expect(Ok.metadata.language).toEqual('en'); + }); + + it('should build a consent message with no utc time information', async () => { + const arg = encodeIdl({ + recordClass: TransferArgs, + rawArgs + }); + + const result = await buildContentMessageIcrc1Transfer({ + arg: base64ToUint8Array(arg), + owner: owner.getPrincipal(), + token + }); + + expect('Ok' in result); + + const {Ok} = result as SignerBuildersResultOk; + + expect(fromNullable(Ok.metadata.utc_offset_minutes)).toBeUndefined(); }); it('should build a consent message with a from subaccount', async () => { @@ -80,12 +141,9 @@ s3oqv-3j7id-xjhbm-3owbe-fvwly-oso6u-vej6n-bexck-koyu2-bxb6y-wae token }); - expect('Ok' in result).toBeTruthy(); - - const {Ok: message} = result as SignerBuildersResultOk; - - expect(message).not.toBeUndefined(); - expect(message).toEqual(`# Approve the transfer of funds + expectMessage({ + result, + expectedMessage: `# Approve the transfer of funds **Amount:** 3,200.00000001 TKN @@ -97,7 +155,8 @@ ${encodeIcrcAccount({owner: owner.getPrincipal(), subaccount: subaccount})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} **Fee:** -0.0010033 TKN`); +0.0010033 TKN` + }); }); it('should build a consent message with a to subaccount', async () => { @@ -120,12 +179,9 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t token }); - expect('Ok' in result).toBeTruthy(); - - const {Ok: message} = result as SignerBuildersResultOk; - - expect(message).not.toBeUndefined(); - expect(message).toEqual(`# Approve the transfer of funds + expectMessage({ + result, + expectedMessage: `# Approve the transfer of funds **Amount:** 3,200.00000001 TKN @@ -137,7 +193,8 @@ ${encodeIcrcAccount({owner: owner.getPrincipal()})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} **Fee:** -0.0010033 TKN`); +0.0010033 TKN` + }); }); it('should build a consent message with a memo', async () => { @@ -157,12 +214,9 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount})} token }); - expect('Ok' in result).toBeTruthy(); - - const {Ok: message} = result as SignerBuildersResultOk; - - expect(message).not.toBeUndefined(); - expect(message).toEqual(`# Approve the transfer of funds + expectMessage({ + result, + expectedMessage: `# Approve the transfer of funds **Amount:** 3,200.00000001 TKN @@ -177,7 +231,8 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t 0.0010033 TKN **Memo:** -0x50555054`); +0x50555054` + }); }); it('should not build a consent message for invalid arg', async () => { @@ -210,12 +265,9 @@ ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.t token }); - expect('Ok' in result).toBeTruthy(); - - const {Ok: message} = result as SignerBuildersResultOk; - - expect(message).not.toBeUndefined(); - expect(message).toEqual(`# Approve the transfer of funds + expectMessage({ + result, + expectedMessage: `# Approve the transfer of funds **Amount:** 3,200.00000001 TKN @@ -227,7 +279,8 @@ ${encodeIcrcAccount({owner: owner.getPrincipal()})} ${encodeIcrcAccount({owner: rawArgs.to.owner, subaccount: fromNullable(rawArgs.to.subaccount)})} **Fee:** -0.0001 TKN`); +0.0001 TKN` + }); }); }); }); diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 912cc7fe..fcbcc0f6 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -7,6 +7,7 @@ import { uint8ArrayToHexString } from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; +import {icrc21_consent_info} from '../declarations/icrc-21'; import {SignerBuilderFn, SignerBuildersResult} from '../types/signer-builders'; import {formatAmount} from '../utils/format.utils'; import {decodeIdl} from '../utils/idl.utils'; @@ -76,7 +77,17 @@ export const buildContentMessageIcrc1Transfer: SignerBuilderFn = async ({ ); } - return {Ok: message.join('\n\n')}; + const consentMessage: icrc21_consent_info = { + metadata: { + language: 'en', + utc_offset_minutes: [] + }, + consent_message: { + GenericDisplayMessage: message.join('\n\n') + } + }; + + return {Ok: consentMessage}; } catch (err: unknown) { return {Err: err}; } diff --git a/src/services/signer.service.ts b/src/services/signer.service.ts index 5ea7e468..bb5e3feb 100644 --- a/src/services/signer.service.ts +++ b/src/services/signer.service.ts @@ -283,29 +283,11 @@ export class SignerService { return {Err: new Error('Incomplete token metadata.')}; } - const message = await fn({ + return await fn({ arg: base64ToUint8Array(arg), token, owner: owner.getPrincipal() }); - - if ('Err' in message) { - return message; - } - - const {Ok: GenericDisplayMessage} = message; - - const consentMessage: icrc21_consent_info = { - metadata: { - language: 'en', - utc_offset_minutes: [] - }, - consent_message: { - GenericDisplayMessage - } - }; - - return {Ok: consentMessage}; } catch (err: unknown) { return {Err: err}; } diff --git a/src/types/signer-builders.ts b/src/types/signer-builders.ts index 620420ed..5f4bcb7f 100644 --- a/src/types/signer-builders.ts +++ b/src/types/signer-builders.ts @@ -1,8 +1,9 @@ import {IcrcTokenMetadata} from '@dfinity/ledger-icrc'; import {Principal} from '@dfinity/principal'; +import {icrc21_consent_info} from '../declarations/icrc-21'; export interface SignerBuildersResultOk { - Ok: string; + Ok: icrc21_consent_info; } export interface SignerBuildersResultError { From 5fbd709fdca26a371d2007bd39328d7b1af8fa1e Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 12:29:27 +0100 Subject: [PATCH 22/31] feat: types --- src/builders/signer.builders.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index fcbcc0f6..9197fdb2 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -1,4 +1,4 @@ -import {encodeIcrcAccount, IcrcTransferArg} from '@dfinity/ledger-icrc'; +import {encodeIcrcAccount, type IcrcTransferArg} from '@dfinity/ledger-icrc'; import { arrayOfNumberToUint8Array, fromNullable, @@ -7,8 +7,8 @@ import { uint8ArrayToHexString } from '@dfinity/utils'; import {TransferArgs} from '../constants/icrc.idl.constants'; -import {icrc21_consent_info} from '../declarations/icrc-21'; -import {SignerBuilderFn, SignerBuildersResult} from '../types/signer-builders'; +import type {icrc21_consent_info} from '../declarations/icrc-21'; +import type {SignerBuilderFn, SignerBuildersResult} from '../types/signer-builders'; import {formatAmount} from '../utils/format.utils'; import {decodeIdl} from '../utils/idl.utils'; From 4eceb12a919d26defec0de165c6aa46cb8a19f52 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 14:05:17 +0100 Subject: [PATCH 23/31] docs: builder --- src/builders/signer.builders.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/builders/signer.builders.ts b/src/builders/signer.builders.ts index 9197fdb2..278a86e1 100644 --- a/src/builders/signer.builders.ts +++ b/src/builders/signer.builders.ts @@ -12,6 +12,20 @@ import type {SignerBuilderFn, SignerBuildersResult} from '../types/signer-builde import {formatAmount} from '../utils/format.utils'; import {decodeIdl} from '../utils/idl.utils'; +/** + * Builds a content message for an ICRC-1 transfer by decoding the arguments for a potential call. + * This is used as a workaround when the targeted canister does not comply with the ICRC-21 standard — i.e. it has not implemented the related endpoints. + * + * @param {Object} params - Parameters for building the consent message. + * @param {Uint8Array} params.arg - Encoded arguments for the ICRC-1 transfer. + * @param {Principal} params.owner - Principal ID of the sender (owner) account. + * @param {Object} params.token - Token metadata including symbol, decimals, and fee. + * @param {string} params.token.symbol - The symbol of the token. + * @param {number} params.token.decimals - The number of decimals for the token. + * @param {bigint} params.token.fee - Default fee for the token transfer. + * @returns {Promise} - A result containing either the consent message or an error. + * + **/ export const buildContentMessageIcrc1Transfer: SignerBuilderFn = async ({ arg, owner, From c666d206ffd09aa7ac2e615c5cda1c7bcd6490a3 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 14:41:48 +0100 Subject: [PATCH 24/31] test: ledger in signer api --- src/api/signer.api.spec.ts | 81 ++++++++++++++++++++++++++++++---- src/api/signer.api.ts | 8 ++-- src/services/signer.service.ts | 2 +- 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/src/api/signer.api.spec.ts b/src/api/signer.api.spec.ts index d10363f4..4417676d 100644 --- a/src/api/signer.api.spec.ts +++ b/src/api/signer.api.spec.ts @@ -1,5 +1,8 @@ import * as httpAgent from '@dfinity/agent'; import {Ed25519KeyIdentity} from '@dfinity/identity'; +import {IcrcLedgerCanister} from '@dfinity/ledger-icrc'; +import {Principal} from '@dfinity/principal'; +import {beforeEach, expect} from 'vitest'; import {mockCallCanisterSuccess} from '../mocks/call-canister.mocks'; import {mockRepliedLocalCertificate} from '../mocks/custom-http-agent-responses.mocks'; import {mockRequestDetails, mockRequestPayload} from '../mocks/custom-http-agent.mocks'; @@ -45,15 +48,77 @@ describe('Signer-api', () => { vi.clearAllMocks(); }); - it('should call request and return the properly encoded result', async () => { - const result = await signerApi.call({ - params: { - ...mockRequestPayload, - sender: identity.getPrincipal().toText() - }, - ...signerOptions + describe('call', () => { + it('should call request and return the properly encoded result', async () => { + const result = await signerApi.call({ + params: { + ...mockRequestPayload, + sender: identity.getPrincipal().toText() + }, + ...signerOptions + }); + + expect(result).toEqual(mockCallCanisterSuccess); + }); + }); + + describe('ledgerMetadata', () => { + const mockMetadata = [ + ['icrc1:name', {Text: 'Token'}], + ['icrc1:symbol', {Text: 'TKN'}], + ['icrc1:decimals', {Nat: 11n}], + ['icrc1:fee', {Nat: 12_987n}] + ]; + + const ledgerCanisterMock = { + metadata: () => Promise.resolve(mockMetadata) + } as unknown as IcrcLedgerCanister; + + beforeEach(() => { + vi.spyOn(IcrcLedgerCanister, 'create').mockImplementation(() => ledgerCanisterMock); + }); + + it('should call ledger metadata with a certified call', async () => { + const spy = vi.spyOn(ledgerCanisterMock, 'metadata'); + + await signerApi.ledgerMetadata({ + params: { + canisterId: mockRequestPayload.canisterId + }, + ...signerOptions + }); + + expect(spy).toHaveBeenCalledWith({ + certified: true + }); + }); + + it('should init ledger with canister ID', async () => { + const spy = vi.spyOn(IcrcLedgerCanister, 'create'); + + await signerApi.ledgerMetadata({ + params: { + canisterId: mockRequestPayload.canisterId + }, + ...signerOptions + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + canisterId: Principal.fromText(mockRequestPayload.canisterId) + }) + ); }); - expect(result).toEqual(mockCallCanisterSuccess); + it('should respond with metadata', async () => { + const result = await signerApi.ledgerMetadata({ + params: { + canisterId: mockRequestPayload.canisterId + }, + ...signerOptions + }); + + expect(result).toEqual(mockMetadata); + }); }); }); diff --git a/src/api/signer.api.ts b/src/api/signer.api.ts index 61321fe4..9a5bc29d 100644 --- a/src/api/signer.api.ts +++ b/src/api/signer.api.ts @@ -32,13 +32,15 @@ export class SignerApi extends Icrc21Canister { async ledgerMetadata({ host, owner, - canisterId - }: {canisterId: string | Principal} & SignerOptions): Promise { + params: {canisterId} + }: { + params: Pick; + } & SignerOptions): Promise { const agent = await this.getAgent({host, owner}); const {metadata} = IcrcLedgerCanister.create({ agent: agent.agent, - canisterId: canisterId instanceof Principal ? canisterId : Principal.fromText(canisterId) + canisterId: Principal.fromText(canisterId) }); return await metadata({certified: true}); diff --git a/src/services/signer.service.ts b/src/services/signer.service.ts index bb5e3feb..ba043ed3 100644 --- a/src/services/signer.service.ts +++ b/src/services/signer.service.ts @@ -272,7 +272,7 @@ export class SignerService { try { const tokenResponse = await this.#signerApi.ledgerMetadata({ - canisterId, + params: {canisterId}, host, owner }); From 0eaa7667438a570bfa46f71b83d095520d36bc7f Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 14:43:46 +0100 Subject: [PATCH 25/31] test: bubble error --- src/api/signer.api.spec.ts | 113 ++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/src/api/signer.api.spec.ts b/src/api/signer.api.spec.ts index 4417676d..5333c4c8 100644 --- a/src/api/signer.api.spec.ts +++ b/src/api/signer.api.spec.ts @@ -63,62 +63,87 @@ describe('Signer-api', () => { }); describe('ledgerMetadata', () => { - const mockMetadata = [ - ['icrc1:name', {Text: 'Token'}], - ['icrc1:symbol', {Text: 'TKN'}], - ['icrc1:decimals', {Nat: 11n}], - ['icrc1:fee', {Nat: 12_987n}] - ]; - - const ledgerCanisterMock = { - metadata: () => Promise.resolve(mockMetadata) - } as unknown as IcrcLedgerCanister; - - beforeEach(() => { - vi.spyOn(IcrcLedgerCanister, 'create').mockImplementation(() => ledgerCanisterMock); - }); + describe('success', () => { + const mockMetadata = [ + ['icrc1:name', {Text: 'Token'}], + ['icrc1:symbol', {Text: 'TKN'}], + ['icrc1:decimals', {Nat: 11n}], + ['icrc1:fee', {Nat: 12_987n}] + ]; + + const ledgerCanisterMock = { + metadata: () => Promise.resolve(mockMetadata) + } as unknown as IcrcLedgerCanister; + + beforeEach(() => { + vi.spyOn(IcrcLedgerCanister, 'create').mockImplementation(() => ledgerCanisterMock); + }); - it('should call ledger metadata with a certified call', async () => { - const spy = vi.spyOn(ledgerCanisterMock, 'metadata'); + it('should call ledger metadata with a certified call', async () => { + const spy = vi.spyOn(ledgerCanisterMock, 'metadata'); - await signerApi.ledgerMetadata({ - params: { - canisterId: mockRequestPayload.canisterId - }, - ...signerOptions + await signerApi.ledgerMetadata({ + params: { + canisterId: mockRequestPayload.canisterId + }, + ...signerOptions + }); + + expect(spy).toHaveBeenCalledWith({ + certified: true + }); }); - expect(spy).toHaveBeenCalledWith({ - certified: true + it('should init ledger with canister ID', async () => { + const spy = vi.spyOn(IcrcLedgerCanister, 'create'); + + await signerApi.ledgerMetadata({ + params: { + canisterId: mockRequestPayload.canisterId + }, + ...signerOptions + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + canisterId: Principal.fromText(mockRequestPayload.canisterId) + }) + ); }); - }); - it('should init ledger with canister ID', async () => { - const spy = vi.spyOn(IcrcLedgerCanister, 'create'); + it('should respond with metadata', async () => { + const result = await signerApi.ledgerMetadata({ + params: { + canisterId: mockRequestPayload.canisterId + }, + ...signerOptions + }); - await signerApi.ledgerMetadata({ - params: { - canisterId: mockRequestPayload.canisterId - }, - ...signerOptions + expect(result).toEqual(mockMetadata); }); - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - canisterId: Principal.fromText(mockRequestPayload.canisterId) - }) - ); }); - it('should respond with metadata', async () => { - const result = await signerApi.ledgerMetadata({ - params: { - canisterId: mockRequestPayload.canisterId - }, - ...signerOptions + describe('error', () => { + const mockError = new Error('Test'); + + const ledgerCanisterMock = { + metadata: () => Promise.reject(mockError) + } as unknown as IcrcLedgerCanister; + + beforeEach(() => { + vi.spyOn(IcrcLedgerCanister, 'create').mockImplementation(() => ledgerCanisterMock); }); - expect(result).toEqual(mockMetadata); + it('should bubble error with metadata', () => { + expect( + signerApi.ledgerMetadata({ + params: { + canisterId: mockRequestPayload.canisterId + }, + ...signerOptions + }) + ).rejects.toThrowError(mockError); + }); }); }); }); From 95b714d23d15e6596c5853f235e7649d98841538 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 24 Dec 2024 06:24:02 +0100 Subject: [PATCH 26/31] refactor: method instead of function and rename fallback --- src/services/signer.service.ts | 66 ++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/src/services/signer.service.ts b/src/services/signer.service.ts index d1403719..62e4328a 100644 --- a/src/services/signer.service.ts +++ b/src/services/signer.service.ts @@ -55,38 +55,10 @@ export class SignerService { prompt({origin, status: 'loading'}); try { - const loadParams = { + const response = await this.loadConsentMessage({ params, options: {host, owner} - }; - - /** - * If the ICRC-21 call to fetch the consent message fails, it might be due to the fact - * that the targeted canister does not implement the ICRC-21 specification. - * - * To address the potential lack of support for the most common types of calls for ledgers, - * namely transfer and approve, we use custom builders. Those builders construct - * messages similar to those that would be implemented by the canisters. - * - * @returns {Promise} - The consent message response. - * @throws The potential original error from the ICRC-21 call. The errors related to - * the custom builder is ignored. - **/ - const loadConsentMessage = async (): Promise => { - try { - return await this.callConsentMessage(loadParams); - } catch (err: unknown) { - const fallbackMessage = await this.tryFallbackOnError(loadParams); - - if ('Ok' in fallbackMessage) { - return fallbackMessage; - } - - throw err; - } - }; - - const response = await loadConsentMessage(); + }); if ('Err' in response) { const {Err} = response; @@ -258,7 +230,39 @@ export class SignerService { return await promise; } - private async tryFallbackOnError({ + /** + * If the ICRC-21 call to fetch the consent message fails, it might be due to the fact + * that the targeted canister does not implement the ICRC-21 specification. + * + * To address the potential lack of support for the most common types of calls for ledgers, + * namely transfer and approve, we use custom builders. Those builders construct + * messages similar to those that would be implemented by the canisters. + * + * @param {Object} params - The parameters for loading the consent message. + * @param {Omit} params.params - The ICRC call canister parameters minus the sender. + * @param {SignerOptions} params.options - The signer options - host and owner. + * @returns {Promise} - The consent message response. + * @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; + options: SignerOptions; + }): Promise { + try { + return await this.callConsentMessage(params); + } catch (err: unknown) { + const fallbackMessage = await this.tryBuildConsentMessageOnError(params); + + if ('Ok' in fallbackMessage) { + return fallbackMessage; + } + + throw err; + } + } + + private async tryBuildConsentMessageOnError({ params: {method, arg, canisterId}, options: {owner, host} }: { From 0b50366640cef8e4f0a744a0eb3973c6d6d93408 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 24 Dec 2024 07:31:31 +0100 Subject: [PATCH 27/31] test: error with builder --- src/api/signer.api.spec.ts | 12 +-- src/mocks/icrc-ledger.mocks.ts | 6 ++ src/services/signer.services.spec.ts | 105 +++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 src/mocks/icrc-ledger.mocks.ts diff --git a/src/api/signer.api.spec.ts b/src/api/signer.api.spec.ts index e443bead..fbbe3a4f 100644 --- a/src/api/signer.api.spec.ts +++ b/src/api/signer.api.spec.ts @@ -5,6 +5,7 @@ import {Principal} from '@dfinity/principal'; import {mockCallCanisterSuccess} from '../mocks/call-canister.mocks'; import {mockRepliedLocalCertificate} from '../mocks/custom-http-agent-responses.mocks'; import {mockRequestDetails, mockRequestPayload} from '../mocks/custom-http-agent.mocks'; +import {mockIcrcLedgerMetadata} from '../mocks/icrc-ledger.mocks'; import type {SignerOptions} from '../types/signer-options'; import {SignerApi} from './signer.api'; @@ -63,15 +64,8 @@ describe('Signer-api', () => { describe('ledgerMetadata', () => { describe('success', () => { - const mockMetadata = [ - ['icrc1:name', {Text: 'Token'}], - ['icrc1:symbol', {Text: 'TKN'}], - ['icrc1:decimals', {Nat: 11n}], - ['icrc1:fee', {Nat: 12_987n}] - ]; - const ledgerCanisterMock = { - metadata: () => Promise.resolve(mockMetadata) + metadata: () => Promise.resolve(mockIcrcLedgerMetadata) } as unknown as IcrcLedgerCanister; beforeEach(() => { @@ -118,7 +112,7 @@ describe('Signer-api', () => { ...signerOptions }); - expect(result).toEqual(mockMetadata); + expect(result).toEqual(mockIcrcLedgerMetadata); }); }); diff --git a/src/mocks/icrc-ledger.mocks.ts b/src/mocks/icrc-ledger.mocks.ts new file mode 100644 index 00000000..9ac5d1f1 --- /dev/null +++ b/src/mocks/icrc-ledger.mocks.ts @@ -0,0 +1,6 @@ +export const mockIcrcLedgerMetadata = [ + ['icrc1:name', {Text: 'Token'}], + ['icrc1:symbol', {Text: 'TKN'}], + ['icrc1:decimals', {Nat: 11n}], + ['icrc1:fee', {Nat: 12_987n}] +]; \ No newline at end of file diff --git a/src/services/signer.services.spec.ts b/src/services/signer.services.spec.ts index 10ffac55..2de261c5 100644 --- a/src/services/signer.services.spec.ts +++ b/src/services/signer.services.spec.ts @@ -8,6 +8,7 @@ import * as signerHandlers from '../handlers/signer.handlers'; import {mockCallCanisterParams} from '../mocks/call-canister.mocks'; import {mockCanisterCallSuccess, mockConsentInfo} from '../mocks/consent-message.mocks'; import {mockPrincipalText} from '../mocks/icrc-accounts.mocks'; +import {mockIcrcLedgerMetadata} from '../mocks/icrc-ledger.mocks'; import {mockErrorNotify} from '../mocks/signer-error.mocks'; import type {IcrcCallCanisterRequestParams} from '../types/icrc-requests'; import {JSON_RPC_VERSION_2, type RpcId, type RpcResponseWithError} from '../types/rpc'; @@ -368,6 +369,110 @@ describe('Signer services', () => { }); }); + describe('Without consent message fallback', () => { + it('should trigger prompt "error" if consentMessage throws', async () => { + const error = new Error('Test Error'); + + spyIcrc21CanisterConsentMessage.mockRejectedValue(error); + + const prompt = vi.fn(); + + await signerService.assertAndPromptConsentMessage({ + notify, + params, + prompt, + options: signerOptions + }); + + expect(prompt).toHaveBeenCalledWith({ + origin: testOrigin, + status: 'error', + details: error + }); + }); + }); + + describe('With consent message fallback', () => { + let spySignerApiLedgerMedatada: MockInstance; + + beforeEach(() => { + spySignerApiLedgerMedatada = vi.spyOn(SignerApi.prototype, 'ledgerMetadata'); + }); + + it('should trigger prompt "error" if consentMessage throws and no matching fallback', async () => { + const error = new Error('Test Error'); + + spyIcrc21CanisterConsentMessage.mockRejectedValue(error); + + const prompt = vi.fn(); + + await signerService.assertAndPromptConsentMessage({ + notify, + params, + prompt, + options: signerOptions + }); + + expect(prompt).toHaveBeenCalledWith({ + origin: testOrigin, + status: 'error', + details: error + }); + }); + + it('should trigger prompt "error" if consentMessage throws and ledger metadata throws', async () => { + const error = new Error('Test Error'); + const ledgerError = new Error('Test Error'); + + spyIcrc21CanisterConsentMessage.mockRejectedValue(error); + spySignerApiLedgerMedatada.mockRejectedValue(ledgerError); + + const prompt = vi.fn(); + + await signerService.assertAndPromptConsentMessage({ + notify, + params: { + ...params, + method: 'icrc1_transfer' + }, + prompt, + options: signerOptions + }); + + expect(prompt).toHaveBeenCalledWith({ + origin: testOrigin, + status: 'error', + details: error + }); + }); + + it('should trigger prompt "error" if consentMessage throws and builder throws', async () => { + const error = new Error('Test Error'); + + spyIcrc21CanisterConsentMessage.mockRejectedValue(error); + spySignerApiLedgerMedatada.mockResolvedValue(mockIcrcLedgerMetadata); + // Signer builder error with arg lead to "Wrong magic number". Similar test in signer.builder.spec.ts + + const prompt = vi.fn(); + + await signerService.assertAndPromptConsentMessage({ + notify, + params: { + ...params, + method: 'icrc1_transfer' + }, + prompt, + options: signerOptions + }); + + expect(prompt).toHaveBeenCalledWith({ + origin: testOrigin, + status: 'error', + details: error + }); + }); + }); + describe('Assert sender', () => { const prompt = vi.fn(); From 9ce617c4d08a1fa5e304d74016027ec40f4a7c74 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 24 Dec 2024 07:50:42 +0100 Subject: [PATCH 28/31] chore: fmt --- src/mocks/icrc-ledger.mocks.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/mocks/icrc-ledger.mocks.ts b/src/mocks/icrc-ledger.mocks.ts index 9ac5d1f1..7dba0e24 100644 --- a/src/mocks/icrc-ledger.mocks.ts +++ b/src/mocks/icrc-ledger.mocks.ts @@ -1,6 +1,6 @@ export const mockIcrcLedgerMetadata = [ - ['icrc1:name', {Text: 'Token'}], - ['icrc1:symbol', {Text: 'TKN'}], - ['icrc1:decimals', {Nat: 11n}], - ['icrc1:fee', {Nat: 12_987n}] -]; \ No newline at end of file + ['icrc1:name', {Text: 'Token'}], + ['icrc1:symbol', {Text: 'TKN'}], + ['icrc1:decimals', {Nat: 11n}], + ['icrc1:fee', {Nat: 12_987n}] +]; From 149220e387b53b9f9532d7a59c032ef89380654d Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 24 Dec 2024 07:50:56 +0100 Subject: [PATCH 29/31] test: throws return --- src/services/signer.services.spec.ts | 102 ++++++++++++++++++--------- 1 file changed, 70 insertions(+), 32 deletions(-) diff --git a/src/services/signer.services.spec.ts b/src/services/signer.services.spec.ts index 2de261c5..023d8209 100644 --- a/src/services/signer.services.spec.ts +++ b/src/services/signer.services.spec.ts @@ -333,43 +333,22 @@ describe('Signer services', () => { expect(postMessageMock).toHaveBeenCalledWith(expectedMessage, testOrigin); }); - it('should return error if consentMessage throws', async () => { - spyIcrc21CanisterConsentMessage.mockRejectedValue(new Error('Test Error')); - - const prompt = vi.fn(); - - const result = await signerService.assertAndPromptConsentMessage({ - notify, - params, - prompt, - options: signerOptions - }); - - expect(result).toEqual({result: 'error'}); - }); - - it('should trigger prompt "error" if consentMessage throws', async () => { - const error = new Error('Test Error'); - - spyIcrc21CanisterConsentMessage.mockRejectedValue(error); + describe('Without consent message fallback', () => { + it('should return error if consentMessage throws', async () => { + spyIcrc21CanisterConsentMessage.mockRejectedValue(new Error('Test Error')); - const prompt = vi.fn(); + const prompt = vi.fn(); - await signerService.assertAndPromptConsentMessage({ - notify, - params, - prompt, - options: signerOptions - }); + const result = await signerService.assertAndPromptConsentMessage({ + notify, + params, + prompt, + options: signerOptions + }); - expect(prompt).toHaveBeenCalledWith({ - origin: testOrigin, - status: 'error', - details: error + expect(result).toEqual({result: 'error'}); }); - }); - describe('Without consent message fallback', () => { it('should trigger prompt "error" if consentMessage throws', async () => { const error = new Error('Test Error'); @@ -399,6 +378,65 @@ describe('Signer services', () => { spySignerApiLedgerMedatada = vi.spyOn(SignerApi.prototype, 'ledgerMetadata'); }); + it('should return error if consentMessage throws and no matching fallback', async () => { + spyIcrc21CanisterConsentMessage.mockRejectedValue(new Error('Test Error')); + + const prompt = vi.fn(); + + const result = await signerService.assertAndPromptConsentMessage({ + notify, + params, + prompt, + options: signerOptions + }); + + expect(result).toEqual({result: 'error'}); + }); + + it('should return error if consentMessage throws and ledger metadata throws', async () => { + const error = new Error('Test Error'); + const ledgerError = new Error('Test Error'); + + spyIcrc21CanisterConsentMessage.mockRejectedValue(error); + spySignerApiLedgerMedatada.mockRejectedValue(ledgerError); + + const prompt = vi.fn(); + + const result = await signerService.assertAndPromptConsentMessage({ + notify, + params: { + ...params, + method: 'icrc1_transfer' + }, + prompt, + options: signerOptions + }); + + expect(result).toEqual({result: 'error'}); + }); + + it('should return error if consentMessage throws and build throws', async () => { + const error = new Error('Test Error'); + + spyIcrc21CanisterConsentMessage.mockRejectedValue(error); + spySignerApiLedgerMedatada.mockResolvedValue(mockIcrcLedgerMetadata); + // Signer builder error with arg lead to "Wrong magic number". Similar test in signer.builder.spec.ts + + const prompt = vi.fn(); + + const result = await signerService.assertAndPromptConsentMessage({ + notify, + params: { + ...params, + method: 'icrc1_transfer' + }, + prompt, + options: signerOptions + }); + + expect(result).toEqual({result: 'error'}); + }); + it('should trigger prompt "error" if consentMessage throws and no matching fallback', async () => { const error = new Error('Test Error'); From 5a92b3f660f974b95a4f12704389238cbccfc4b6 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 24 Dec 2024 07:58:21 +0100 Subject: [PATCH 30/31] test: approves --- src/services/signer.services.spec.ts | 108 ++++++++++++++++++--------- 1 file changed, 74 insertions(+), 34 deletions(-) diff --git a/src/services/signer.services.spec.ts b/src/services/signer.services.spec.ts index 023d8209..2c767b3c 100644 --- a/src/services/signer.services.spec.ts +++ b/src/services/signer.services.spec.ts @@ -8,6 +8,7 @@ import * as signerHandlers from '../handlers/signer.handlers'; import {mockCallCanisterParams} from '../mocks/call-canister.mocks'; import {mockCanisterCallSuccess, mockConsentInfo} from '../mocks/consent-message.mocks'; import {mockPrincipalText} from '../mocks/icrc-accounts.mocks'; +import {mockIcrcLocalCallParams} from '../mocks/icrc-call-utils.mocks'; import {mockIcrcLedgerMetadata} from '../mocks/icrc-ledger.mocks'; import {mockErrorNotify} from '../mocks/signer-error.mocks'; import type {IcrcCallCanisterRequestParams} from '../types/icrc-requests'; @@ -94,40 +95,6 @@ describe('Signer services', () => { }); }); - it('should return approved when user approves the consent message', async () => { - spyIcrc21CanisterConsentMessage.mockResolvedValue({ - Ok: mockConsentInfo - }); - - const prompt = ({status, ...rest}: ConsentMessagePromptPayload): void => { - if (status === 'result' && 'approve' in rest) { - rest.approve(); - } - }; - - const result = await signerService.assertAndPromptConsentMessage({ - notify, - params, - prompt, - options: signerOptions - }); - - expect(result).toEqual({result: 'approved'}); - - expect(spyIcrc21CanisterConsentMessage).toHaveBeenCalledWith({ - ...signerOptions, - canisterId: params.canisterId, - request: { - method: params.method, - arg: base64ToUint8Array(params.arg), - user_preferences: { - metadata: {language: 'en', utc_offset_minutes: []}, - device_spec: [] - } - } - }); - }); - describe('User reject consent', () => { beforeEach(() => { spyIcrc21CanisterConsentMessage.mockResolvedValue({ @@ -334,6 +301,40 @@ describe('Signer services', () => { }); describe('Without consent message fallback', () => { + it('should return approved when user approves the consent message', async () => { + spyIcrc21CanisterConsentMessage.mockResolvedValue({ + Ok: mockConsentInfo + }); + + const prompt = ({status, ...rest}: ConsentMessagePromptPayload): void => { + if (status === 'result' && 'approve' in rest) { + rest.approve(); + } + }; + + const result = await signerService.assertAndPromptConsentMessage({ + notify, + params, + prompt, + options: signerOptions + }); + + expect(result).toEqual({result: 'approved'}); + + expect(spyIcrc21CanisterConsentMessage).toHaveBeenCalledWith({ + ...signerOptions, + canisterId: params.canisterId, + request: { + method: params.method, + arg: base64ToUint8Array(params.arg), + user_preferences: { + metadata: {language: 'en', utc_offset_minutes: []}, + device_spec: [] + } + } + }); + }); + it('should return error if consentMessage throws', async () => { spyIcrc21CanisterConsentMessage.mockRejectedValue(new Error('Test Error')); @@ -378,6 +379,45 @@ describe('Signer services', () => { spySignerApiLedgerMedatada = vi.spyOn(SignerApi.prototype, 'ledgerMetadata'); }); + it('should return approved when user approves the consent message that was built', async () => { + spyIcrc21CanisterConsentMessage.mockRejectedValue(new Error('Test Error')); + spySignerApiLedgerMedatada.mockResolvedValue(mockIcrcLedgerMetadata); + + const prompt = ({status, ...rest}: ConsentMessagePromptPayload): void => { + if (status === 'result' && 'approve' in rest) { + rest.approve(); + } + }; + + const method = 'icrc1_transfer'; + + const result = await signerService.assertAndPromptConsentMessage({ + notify, + params: { + ...params, + method, + arg: mockIcrcLocalCallParams.arg + }, + prompt, + options: signerOptions + }); + + expect(result).toEqual({result: 'approved'}); + + expect(spyIcrc21CanisterConsentMessage).toHaveBeenCalledWith({ + ...signerOptions, + canisterId: params.canisterId, + request: { + method, + arg: base64ToUint8Array(mockIcrcLocalCallParams.arg), + user_preferences: { + metadata: {language: 'en', utc_offset_minutes: []}, + device_spec: [] + } + } + }); + }); + it('should return error if consentMessage throws and no matching fallback', async () => { spyIcrc21CanisterConsentMessage.mockRejectedValue(new Error('Test Error')); From ff334eceb6c4210607c32bc1eef11d547a6b97cc Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 25 Dec 2024 07:28:04 +0100 Subject: [PATCH 31/31] chore: update todo --- src/services/signer.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/signer.service.ts b/src/services/signer.service.ts index 62e4328a..5af7cf90 100644 --- a/src/services/signer.service.ts +++ b/src/services/signer.service.ts @@ -75,7 +75,7 @@ export class SignerService { const {Ok: consentInfo} = response; - // TODO: change consent message + // TODO: change consent message prompt payload // { // {Ok: consentInfo} | {Warn: {consentInfo?: string, method, arg, canisterId, owner}} // }