diff --git a/packages/functions/src/canisters/ledger/icrc/ledger.canister.ts b/packages/functions/src/canisters/ledger/icrc/ledger.canister.ts index 231ac012..595570ce 100644 --- a/packages/functions/src/canisters/ledger/icrc/ledger.canister.ts +++ b/packages/functions/src/canisters/ledger/icrc/ledger.canister.ts @@ -4,12 +4,16 @@ import {Canister} from '../../_canister'; import {IcrcLedgerIdl} from '../../declarations'; import { AccountSchema, + ApproveArgsSchema, + ApproveResultSchema, IcrcCanisterOptionsSchema, TransferArgsSchema, TransferFromArgsSchema, TransferFromResultSchema, TransferResultSchema, type Account, + type ApproveArgs, + type ApproveResult, type IcrcCanisterOptions, type TransferArgs, type TransferFromArgs, @@ -96,4 +100,24 @@ export class IcrcLedgerCanister extends Canister { value: idlResult }) as TransferFromResult; }; + + /** + * Approves a spender to transfer tokens on behalf of the caller using the ICRC-2 `icrc2_approve` method. + * + * @param {ApproveArgs} args - Approve arguments (amount, spender, fee, expires_at, etc.). + * @returns {Promise} The result of the approval. + */ + icrc2Approve = async ({args}: {args: ApproveArgs}): Promise => { + const parsed = ApproveArgsSchema.parse(args); + const idlArgs = schemaToIdl({schema: ApproveArgsSchema, value: parsed}); + + const idlResult = await call({ + canisterId: this.canisterId, + method: 'icrc2_approve', + args: [[IcrcLedgerIdl.ApproveArgs, idlArgs]], + result: IcrcLedgerIdl.ApproveResult + }); + + return schemaFromIdl({schema: ApproveResultSchema, value: idlResult}) as ApproveResult; + }; } diff --git a/packages/functions/src/canisters/ledger/icrc/schema.ts b/packages/functions/src/canisters/ledger/icrc/schema.ts index 2c11fe7d..a8ea47c2 100644 --- a/packages/functions/src/canisters/ledger/icrc/schema.ts +++ b/packages/functions/src/canisters/ledger/icrc/schema.ts @@ -76,6 +76,35 @@ export const TransferFromResultSchema = z.union([ z.strictObject({Err: TransferFromErrorSchema}) ]); +// icrc2_approve +export const ApproveArgsSchema = j.strictObject({ + fee: TokensSchema.optional(), + memo: j.uint8Array().optional(), + from_subaccount: SubaccountSchema.optional(), + created_at_time: j.bigint().optional(), + amount: TokensSchema, + expected_allowance: TokensSchema.optional(), + expires_at: j.bigint().optional(), + spender: AccountSchema +}); + +export const ApproveErrorSchema = z.union([ + z.strictObject({GenericError: z.strictObject({message: z.string(), error_code: z.bigint()})}), + z.strictObject({TemporarilyUnavailable: z.null()}), + z.strictObject({Duplicate: z.strictObject({duplicate_of: z.bigint()})}), + z.strictObject({BadFee: z.strictObject({expected_fee: z.bigint()})}), + z.strictObject({AllowanceChanged: z.strictObject({current_allowance: z.bigint()})}), + z.strictObject({CreatedInFuture: z.strictObject({ledger_time: z.bigint()})}), + z.strictObject({TooOld: z.null()}), + z.strictObject({Expired: z.strictObject({ledger_time: z.bigint()})}), + z.strictObject({InsufficientFunds: z.strictObject({balance: z.bigint()})}) +]); + +export const ApproveResultSchema = z.union([ + z.strictObject({Ok: z.bigint()}), + z.strictObject({Err: ApproveErrorSchema}) +]); + /** * Subaccount is an arbitrary 32-byte array used to compute the source address. */ @@ -189,3 +218,45 @@ export interface TransferFromArgs { * Amount of ICRC tokens, represented as a natural number. */ export type Tokens = bigint; + +/** + * Arguments for the ICRC-2 `icrc2_approve` call. + */ +export interface ApproveArgs { + /** An optional fee. Uses the default ledger fee if not provided. */ + fee?: bigint; + /** An optional memo for the transaction. */ + memo?: Uint8Array; + /** An optional subaccount of the approver. */ + from_subaccount?: Subaccount; + /** An optional timestamp. Uses current IC time if not provided. */ + created_at_time?: bigint; + /** The amount to approve. */ + amount: bigint; + /** An optional expected current allowance for optimistic concurrency control. */ + expected_allowance?: bigint; + /** An optional expiry timestamp for the approval. */ + expires_at?: bigint; + /** The spender account being approved. */ + spender: Account; +} + +/** + * Errors that can occur during an ICRC-2 approve. + */ +export type ApproveError = + | {GenericError: {message: string; error_code: bigint}} + | {TemporarilyUnavailable: null} + | {Duplicate: {duplicate_of: bigint}} + | {BadFee: {expected_fee: bigint}} + | {AllowanceChanged: {current_allowance: bigint}} + | {CreatedInFuture: {ledger_time: bigint}} + | {TooOld: null} + | {Expired: {ledger_time: bigint}} + | {InsufficientFunds: {balance: bigint}}; + +/** + * The result of an ICRC-2 `icrc2_approve` call. + * Returns the block index of the approval on success. + */ +export type ApproveResult = {Ok: bigint} | {Err: ApproveError}; diff --git a/packages/functions/src/tests/canisters/ledger/icrc/ledger.canister.spec.ts b/packages/functions/src/tests/canisters/ledger/icrc/ledger.canister.spec.ts index 1c293dae..a7b435c1 100644 --- a/packages/functions/src/tests/canisters/ledger/icrc/ledger.canister.spec.ts +++ b/packages/functions/src/tests/canisters/ledger/icrc/ledger.canister.spec.ts @@ -4,6 +4,8 @@ import {IcrcLedgerIdl} from '../../../../canisters/declarations'; import {IcrcLedgerCanister} from '../../../../canisters/ledger/icrc'; import { type Account, + type ApproveArgs, + type ApproveResult, type TransferArgs, type TransferFromArgs, type TransferFromResult, @@ -29,6 +31,9 @@ describe('IcrcLedgerCanister', () => { const mockIdlTransferFromResult = (result: TransferFromResult) => new Uint8Array(IDL.encode([IcrcLedgerIdl.TransferFromResult], [result])); + const mockIdlApproveResult = (result: ApproveResult) => + new Uint8Array(IDL.encode([IcrcLedgerIdl.ApproveResult], [result])); + describe('constructor', () => { it('should create instance with provided canister ID', () => { const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); @@ -519,4 +524,204 @@ describe('IcrcLedgerCanister', () => { expect(ledger2.canisterId).toEqual(canisterId2); }); }); + + describe('icrc2Approve', () => { + const mockApproveArgs: ApproveArgs = { + spender: {owner: mockSpender, subaccount: undefined}, + amount: 1000000000n, + fee: undefined, + memo: undefined, + from_subaccount: undefined, + created_at_time: undefined, + expected_allowance: undefined, + expires_at: undefined + }; + + it('should successfully approve and return block index', async () => { + const mockResponse: ApproveResult = {Ok: 12345n}; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + if ('Ok' in result) { + expect(result.Ok).toBe(12345n); + return; + } + + expect(true).toBeFalsy(); + }); + + it('should handle ApproveError.GenericError', async () => { + const mockResponse: ApproveResult = { + Err: {GenericError: {message: 'Generic error', error_code: 500n}} + }; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + expect(result).toHaveProperty('Err'); + }); + + it('should handle ApproveError.TemporarilyUnavailable', async () => { + const mockResponse: ApproveResult = {Err: {TemporarilyUnavailable: null}}; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + }); + + it('should handle ApproveError.Duplicate', async () => { + const mockResponse: ApproveResult = {Err: {Duplicate: {duplicate_of: 5000n}}}; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + }); + + it('should handle ApproveError.BadFee', async () => { + const mockResponse: ApproveResult = {Err: {BadFee: {expected_fee: 10000n}}}; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + }); + + it('should handle ApproveError.AllowanceChanged', async () => { + const mockResponse: ApproveResult = {Err: {AllowanceChanged: {current_allowance: 500000n}}}; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + }); + + it('should handle ApproveError.CreatedInFuture', async () => { + const mockResponse: ApproveResult = { + Err: {CreatedInFuture: {ledger_time: 1700000000000000000n}} + }; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + }); + + it('should handle ApproveError.TooOld', async () => { + const mockResponse: ApproveResult = {Err: {TooOld: null}}; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + }); + + it('should handle ApproveError.Expired', async () => { + const mockResponse: ApproveResult = {Err: {Expired: {ledger_time: 1600000000000000000n}}}; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + }); + + it('should handle ApproveError.InsufficientFunds', async () => { + const mockResponse: ApproveResult = {Err: {InsufficientFunds: {balance: 50000000n}}}; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult(mockResponse)) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: mockApproveArgs}); + + expect(result).toEqual(mockResponse); + }); + + it('should handle approve with all optional fields', async () => { + const fullArgs: ApproveArgs = { + spender: {owner: mockSpender, subaccount: mockSubaccount}, + amount: 1000000000n, + fee: 10000n, + memo: new Uint8Array([1, 2, 3, 4]), + from_subaccount: mockSubaccount, + created_at_time: 1700000000000000000n, + expected_allowance: 500000000n, + expires_at: 1800000000000000000n + }; + + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => mockIdlApproveResult({Ok: 99999n})) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + const result = await ledger.icrc2Approve({args: fullArgs}); + + expect(result).toEqual({Ok: 99999n}); + }); + + it('should throw error if canister call fails', async () => { + vi.stubGlobal( + '__ic_cdk_call_raw', + vi.fn(async () => { + throw new Error('Network error'); + }) + ); + + const ledger = new IcrcLedgerCanister({canisterId: mockCanisterId}); + + await expect(ledger.icrc2Approve({args: mockApproveArgs})).rejects.toThrow('Network error'); + }); + }); });