diff --git a/CHANGELOG.md b/CHANGELOG.md index 2041730e..a81f2d2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# Next + +## Features + +- Add support for `icrc21_canister_call_consent_message` to `@dfinity/ledger-icp`. + # 2024.09.02-0830Z ## Overview diff --git a/packages/ledger-icp/src/canisters/ledger/ledger.request.converts.ts b/packages/ledger-icp/src/canisters/ledger/ledger.request.converts.ts index 7ab49d8b..020eb61d 100644 --- a/packages/ledger-icp/src/canisters/ledger/ledger.request.converts.ts +++ b/packages/ledger-icp/src/canisters/ledger/ledger.request.converts.ts @@ -1,13 +1,19 @@ -import { arrayOfNumberToUint8Array, toNullable } from "@dfinity/utils"; +import { + arrayOfNumberToUint8Array, + isNullish, + toNullable, +} from "@dfinity/utils"; import type { TransferArg as Icrc1TransferRawRequest, + icrc21_consent_message_request as Icrc21ConsentMessageRawRequest, ApproveArgs as Icrc2ApproveRawRequest, Tokens, TransferArgs as TransferRawRequest, } from "../../../candid/ledger"; import { TRANSACTION_FEE } from "../../constants/constants"; -import type { +import { Icrc1TransferRequest, + Icrc21ConsentMessageRequest, Icrc2ApproveRequest, TransferRequest, } from "../../types/ledger_converters"; @@ -75,3 +81,31 @@ export const toIcrc2ApproveRawRequest = ({ expected_allowance: toNullable(expected_allowance), expires_at: toNullable(expires_at), }); + +export const toIcrc21ConsentMessageRawRequest = ({ + userPreferences: { + metadata: { utcOffsetMinutes, language }, + deriveSpec, + }, + ...rest +}: Icrc21ConsentMessageRequest): Icrc21ConsentMessageRawRequest => ({ + ...rest, + user_preferences: { + metadata: { + language, + utc_offset_minutes: toNullable(utcOffsetMinutes), + }, + device_spec: isNullish(deriveSpec) + ? toNullable() + : toNullable( + "GenericDisplay" in deriveSpec + ? { GenericDisplay: null } + : { + LineDisplay: { + characters_per_line: deriveSpec.LineDisplay.charactersPerLine, + lines_per_page: deriveSpec.LineDisplay.linesPerPage, + }, + }, + ), + }, +}); diff --git a/packages/ledger-icp/src/errors/ledger.errors.ts b/packages/ledger-icp/src/errors/ledger.errors.ts index 7ef679e4..3f5c6355 100644 --- a/packages/ledger-icp/src/errors/ledger.errors.ts +++ b/packages/ledger-icp/src/errors/ledger.errors.ts @@ -1,6 +1,7 @@ import type { Icrc1BlockIndex, Icrc1Tokens, + icrc21_error as Icrc21RawError, ApproveError as RawApproveError, Icrc1TransferError as RawIcrc1TransferError, TransferError as RawTransferError, @@ -11,6 +12,7 @@ export class IcrcError extends Error {} export class TransferError extends IcrcError {} export class ApproveError extends IcrcError {} +export class ConsentMessageError extends IcrcError {} export class InvalidSenderError extends TransferError {} @@ -74,6 +76,10 @@ export class ExpiredError extends ApproveError { } } +export class InsufficientPaymentError extends ConsentMessageError {} +export class UnsupportedCanisterCallError extends ConsentMessageError {} +export class ConsentMessageUnavailableError extends ConsentMessageError {} + export const mapTransferError = ( rawTransferError: RawTransferError, ): TransferError => { @@ -184,3 +190,36 @@ export const mapIcrc2ApproveError = ( `Unknown error type ${JSON.stringify(rawApproveError)}`, ); }; + +export const mapIcrc21ConsentMessageError = ( + rawError: Icrc21RawError, +): ConsentMessageError => { + if ("GenericError" in rawError) { + return new GenericError( + rawError.GenericError.description, + rawError.GenericError.error_code, + ); + } + + if ("InsufficientPayment" in rawError) { + return new InsufficientPaymentError( + rawError.InsufficientPayment.description, + ); + } + + if ("UnsupportedCanisterCall" in rawError) { + return new UnsupportedCanisterCallError( + rawError.UnsupportedCanisterCall.description, + ); + } + if ("ConsentMessageUnavailable" in rawError) { + return new ConsentMessageUnavailableError( + rawError.ConsentMessageUnavailable.description, + ); + } + + // Edge case + return new ConsentMessageError( + `Unknown error type ${JSON.stringify(rawError)}`, + ); +}; diff --git a/packages/ledger-icp/src/ledger.canister.spec.ts b/packages/ledger-icp/src/ledger.canister.spec.ts index 82168de4..bc8ce6fe 100644 --- a/packages/ledger-icp/src/ledger.canister.spec.ts +++ b/packages/ledger-icp/src/ledger.canister.spec.ts @@ -5,6 +5,7 @@ import { mock } from "jest-mock-extended"; import { _SERVICE as LedgerService, Value, + icrc21_consent_message_response, type Account, type ApproveArgs as Icrc2ApproveRawRequest, } from "../candid/ledger"; @@ -12,20 +13,27 @@ import { TRANSACTION_FEE } from "./constants/constants"; import { AllowanceChangedError, BadFeeError, + ConsentMessageError, + ConsentMessageUnavailableError, CreatedInFutureError, DuplicateError, ExpiredError, GenericError, InsufficientFundsError, + InsufficientPaymentError, TemporarilyUnavailableError, TooOldError, TxCreatedInFutureError, TxDuplicateError, TxTooOldError, + UnsupportedCanisterCallError, } from "./errors/ledger.errors"; import { LedgerCanister } from "./ledger.canister"; import { mockAccountIdentifier, mockPrincipal } from "./mocks/ledger.mock"; -import type { Icrc2ApproveRequest } from "./types/ledger_converters"; +import type { + Icrc21ConsentMessageRequest, + Icrc2ApproveRequest, +} from "./types/ledger_converters"; describe("LedgerCanister", () => { describe("accountBalance", () => { @@ -1049,4 +1057,314 @@ describe("LedgerCanister", () => { expect(call).rejects.toThrowError(InsufficientFundsError); }); }); + + describe("icrc21ConsentMessage", () => { + const consentMessageRequest: Icrc21ConsentMessageRequest = { + method: "icrc1_transfer", + arg: new Uint8Array([1, 2, 3]), + userPreferences: { + metadata: { + language: "en-US", + }, + deriveSpec: { + GenericDisplay: null, + }, + }, + }; + + const consentMessageResponse: icrc21_consent_message_response = { + Ok: { + consent_message: { + GenericDisplayMessage: "Transfer 1 ICP to account abcd", + }, + metadata: { + language: "en-US", + utc_offset_minutes: [], + }, + }, + }; + + const consentMessageLineDisplayResponse: icrc21_consent_message_response = { + Ok: { + consent_message: { + LineDisplayMessage: { + pages: [ + { lines: ["Transfer 1 ICP", "to account abcd"] }, + { lines: ["Fee: 0.0001 ICP"] }, + ], + }, + }, + metadata: { + language: "en-US", + utc_offset_minutes: [], + }, + }, + }; + + it("should fetch consent message successfully with GenericDisplayMessage", async () => { + const service = mock>(); + service.icrc21_canister_call_consent_message.mockResolvedValue( + consentMessageResponse, + ); + + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + + const response = await ledger.icrc21ConsentMessage(consentMessageRequest); + + expect(response).toEqual(consentMessageResponse.Ok); + expect(service.icrc21_canister_call_consent_message).toBeCalledWith({ + method: consentMessageRequest.method, + arg: consentMessageRequest.arg, + user_preferences: { + metadata: { + language: "en-US", + utc_offset_minutes: [], + }, + device_spec: [ + { + GenericDisplay: null, + }, + ], + }, + }); + }); + + it("should fetch consent message successfully with LineDisplayMessage", async () => { + const service = mock>(); + service.icrc21_canister_call_consent_message.mockResolvedValue( + consentMessageLineDisplayResponse, + ); + + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + + const requestWithLineDisplay: Icrc21ConsentMessageRequest = { + ...consentMessageRequest, + userPreferences: { + metadata: { + language: "en-US", + }, + deriveSpec: { + LineDisplay: { + charactersPerLine: 20, + linesPerPage: 4, + }, + }, + }, + }; + + const response = await ledger.icrc21ConsentMessage( + requestWithLineDisplay, + ); + + expect(response).toEqual(consentMessageLineDisplayResponse.Ok); + expect(service.icrc21_canister_call_consent_message).toBeCalledWith({ + method: requestWithLineDisplay.method, + arg: requestWithLineDisplay.arg, + user_preferences: { + metadata: { + language: "en-US", + utc_offset_minutes: [], + }, + device_spec: [ + { + LineDisplay: { + characters_per_line: 20, + lines_per_page: 4, + }, + }, + ], + }, + }); + }); + + it("should handle UTC offset in the request", async () => { + const service = mock>(); + service.icrc21_canister_call_consent_message.mockResolvedValue( + consentMessageResponse, + ); + + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + + const requestWithUtcOffset: Icrc21ConsentMessageRequest = { + ...consentMessageRequest, + userPreferences: { + metadata: { + language: "en-US", + utcOffsetMinutes: 120, + }, + deriveSpec: { + GenericDisplay: null, + }, + }, + }; + + const response = await ledger.icrc21ConsentMessage(requestWithUtcOffset); + + expect(response).toEqual(consentMessageResponse.Ok); + expect(service.icrc21_canister_call_consent_message).toBeCalledWith({ + method: requestWithUtcOffset.method, + arg: requestWithUtcOffset.arg, + user_preferences: { + metadata: { + language: "en-US", + utc_offset_minutes: [120], + }, + device_spec: [ + { + GenericDisplay: null, + }, + ], + }, + }); + }); + + it("should throw GenericError when the canister returns a GenericError", async () => { + const service = mock>(); + + const errorDescription = "An error occurred"; + const errorResponse: icrc21_consent_message_response = { + Err: { + GenericError: { + description: errorDescription, + error_code: BigInt(500), + }, + }, + }; + + service.icrc21_canister_call_consent_message.mockResolvedValue( + errorResponse, + ); + + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + + await expect( + ledger.icrc21ConsentMessage(consentMessageRequest), + ).rejects.toThrowError(new GenericError(errorDescription, BigInt(500))); + }); + + it("should throw InsufficientPaymentError when the canister returns an InsufficientPayment error", async () => { + const service = mock>(); + + const insufficientPaymentDescription = "Payment is insufficient"; + const insufficientPaymentErrorResponse: icrc21_consent_message_response = + { + Err: { + InsufficientPayment: { + description: insufficientPaymentDescription, + }, + }, + }; + + service.icrc21_canister_call_consent_message.mockResolvedValue( + insufficientPaymentErrorResponse, + ); + + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + + await expect( + ledger.icrc21ConsentMessage(consentMessageRequest), + ).rejects.toThrowError( + new InsufficientPaymentError(insufficientPaymentDescription), + ); + }); + + it("should throw UnsupportedCanisterCallError when the canister returns an UnsupportedCanisterCallError error", async () => { + const service = mock>(); + + const unsupportedCanisterCallDescription = + "This canister call is not supported"; + const unsupportedCanisterCallErrorResponse: icrc21_consent_message_response = + { + Err: { + UnsupportedCanisterCall: { + description: unsupportedCanisterCallDescription, + }, + }, + }; + + service.icrc21_canister_call_consent_message.mockResolvedValue( + unsupportedCanisterCallErrorResponse, + ); + + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + + await expect( + ledger.icrc21ConsentMessage(consentMessageRequest), + ).rejects.toThrowError( + new UnsupportedCanisterCallError(unsupportedCanisterCallDescription), + ); + }); + + it("should throw ConsentMessageUnavailableError when the canister returns an ConsentMessageUnavailableError error", async () => { + const service = mock>(); + + const consentMessageUnavailableDescription = + "Consent message is unavailable"; + const consentMessageUnavailableErrorResponse: icrc21_consent_message_response = + { + Err: { + ConsentMessageUnavailable: { + description: consentMessageUnavailableDescription, + }, + }, + }; + + service.icrc21_canister_call_consent_message.mockResolvedValue( + consentMessageUnavailableErrorResponse, + ); + + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + + await expect( + ledger.icrc21ConsentMessage(consentMessageRequest), + ).rejects.toThrowError( + new ConsentMessageUnavailableError( + consentMessageUnavailableDescription, + ), + ); + }); + + it("should throw ConsentMessageError with correct message for an unknown error type", async () => { + const service = mock>(); + + const Err = { + UnknownErrorType: { + description: "This is an unknown error type", + }, + }; + + const unknownErrorResponse: icrc21_consent_message_response = { + // @ts-expect-error: we are testing this on purpose + Err, + }; + + service.icrc21_canister_call_consent_message.mockResolvedValue( + unknownErrorResponse, + ); + + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + + await expect( + ledger.icrc21ConsentMessage(consentMessageRequest), + ).rejects.toThrowError( + new ConsentMessageError(`Unknown error type ${JSON.stringify(Err)}`), + ); + }); + }); }); diff --git a/packages/ledger-icp/src/ledger.canister.ts b/packages/ledger-icp/src/ledger.canister.ts index bc412677..c06fa96c 100644 --- a/packages/ledger-icp/src/ledger.canister.ts +++ b/packages/ledger-icp/src/ledger.canister.ts @@ -1,28 +1,33 @@ import type { Principal } from "@dfinity/principal"; import { Canister, createServices, type QueryParams } from "@dfinity/utils"; -import type { +import { Icrc1BlockIndex, _SERVICE as LedgerService, Value, + icrc21_consent_info, + icrc21_consent_message_response, } from "../candid/ledger"; import { idlFactory as certifiedIdlFactory } from "../candid/ledger.certified.idl"; import { idlFactory } from "../candid/ledger.idl"; import { toIcrc1TransferRawRequest, + toIcrc21ConsentMessageRawRequest, toIcrc2ApproveRawRequest, toTransferRawRequest, } from "./canisters/ledger/ledger.request.converts"; import { MAINNET_LEDGER_CANISTER_ID } from "./constants/canister_ids"; import { mapIcrc1TransferError, + mapIcrc21ConsentMessageError, mapIcrc2ApproveError, mapTransferError, } from "./errors/ledger.errors"; import type { BlockHeight } from "./types/common"; import type { LedgerCanisterOptions } from "./types/ledger.options"; import type { AccountBalanceParams } from "./types/ledger.params"; -import type { +import { Icrc1TransferRequest, + Icrc21ConsentMessageRequest, Icrc2ApproveRequest, TransferRequest, } from "./types/ledger_converters"; @@ -158,4 +163,30 @@ export class LedgerCanister extends Canister { return response.Ok; }; + + /** + * Fetches the consent message for a specified canister call, intended to provide a human-readable message that helps users make informed decisions. + * + * Reference: https://github.com/dfinity/wg-identity-authentication/blob/main/topics/ICRC-21/icrc_21_consent_msg.md + * + * @param {Icrc21ConsentMessageRequest} params - The request parameters containing the method name, arguments, and consent preferences (e.g., language). + * @returns {Promise} - A promise that resolves to the consent message response, which includes the consent message in the specified language and other related information. + * */ + icrc21ConsentMessage = async ( + params: Icrc21ConsentMessageRequest, + ): Promise => { + const { icrc21_canister_call_consent_message } = this.caller({ + certified: true, + }); + + const response = await icrc21_canister_call_consent_message( + toIcrc21ConsentMessageRawRequest(params), + ); + + if ("Err" in response) { + throw mapIcrc21ConsentMessageError(response.Err); + } + + return response.Ok; + }; } diff --git a/packages/ledger-icp/src/types/ledger_converters.ts b/packages/ledger-icp/src/types/ledger_converters.ts index 2f87c317..c8f11db2 100644 --- a/packages/ledger-icp/src/types/ledger_converters.ts +++ b/packages/ledger-icp/src/types/ledger_converters.ts @@ -2,6 +2,7 @@ import type { Account, Icrc1Timestamp, Icrc1Tokens, + icrc21_consent_message_request, SubAccount, } from "../../candid/ledger"; import type { AccountIdentifier } from "../account_identifier"; @@ -54,3 +55,29 @@ export type Icrc2ApproveRequest = Omit & { expires_at?: Icrc1Timestamp; spender: Account; }; + +export type Icrc21ConsentMessageMetadata = { + utcOffsetMinutes?: number; + language: string; +}; + +export type Icrc21ConsentMessageDeviceSpec = + | { GenericDisplay: null } + | { + LineDisplay: { + charactersPerLine: number; + linesPerPage: number; + }; + }; + +export type Icrc21ConsentMessageSpec = { + metadata: Icrc21ConsentMessageMetadata; + deriveSpec?: Icrc21ConsentMessageDeviceSpec; +}; + +export type Icrc21ConsentMessageRequest = Omit< + icrc21_consent_message_request, + "user_preferences" +> & { + userPreferences: Icrc21ConsentMessageSpec; +};