Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/functions/src/canisters/ledger/icrc/ledger.canister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ApproveResult>} The result of the approval.
*/
icrc2Approve = async ({args}: {args: ApproveArgs}): Promise<ApproveResult> => {
const parsed = ApproveArgsSchema.parse(args);
const idlArgs = schemaToIdl({schema: ApproveArgsSchema, value: parsed});

const idlResult = await call<ApproveResult>({
canisterId: this.canisterId,
method: 'icrc2_approve',
args: [[IcrcLedgerIdl.ApproveArgs, idlArgs]],
result: IcrcLedgerIdl.ApproveResult
});

return schemaFromIdl({schema: ApproveResultSchema, value: idlResult}) as ApproveResult;
};
}
71 changes: 71 additions & 0 deletions packages/functions/src/canisters/ledger/icrc/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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});
Expand Down Expand Up @@ -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');
});
});
});
Loading