From 90d11095495810ab39aeba241c76db197cbf5d7c Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 4 Sep 2024 16:53:55 +0200 Subject: [PATCH] feat: icrc21_canister_call_consent_message for ledger-icrc (#710) # Motivation Add support for `icrc21_canister_call_consent_message` to `@dfinity/ledger-icrc`. # Notes It annoys me a bit that the close basically duplicates the code of ICP (provided in PR #709) but, not sure it is worth the effort at this point to create a util or even lib for that particular topic. We might do so if more canisters starts implementing consent message but, happy to hear any thoughts about it. # Changes - Expose and implement new function `consentMessage`. - Add a new parameter for the request. - Map the potential error. --------- Signed-off-by: David Dal Busco Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- packages/ledger-icrc/README.md | 35 +- .../src/converters/ledger.converters.ts | 32 +- .../ledger-icrc/src/errors/ledger.errors.ts | 50 +++ .../ledger-icrc/src/ledger.canister.spec.ts | 327 +++++++++++++++++- packages/ledger-icrc/src/ledger.canister.ts | 39 ++- .../ledger-icrc/src/types/ledger.params.ts | 53 +++ 7 files changed, 524 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a81f2d2fc..79bb03612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Features -- Add support for `icrc21_canister_call_consent_message` to `@dfinity/ledger-icp`. +- Add support for `icrc21_canister_call_consent_message` to `@dfinity/ledger-icp` and `@dfinity/ledger-icrc`. # 2024.09.02-0830Z diff --git a/packages/ledger-icrc/README.md b/packages/ledger-icrc/README.md index b1a3a2204..3a26fbc77 100644 --- a/packages/ledger-icrc/README.md +++ b/packages/ledger-icrc/README.md @@ -145,7 +145,7 @@ Parameters: ### :factory: IcrcLedgerCanister -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L27) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L33) #### Methods @@ -158,6 +158,7 @@ Parameters: - [transferFrom](#gear-transferfrom) - [approve](#gear-approve) - [allowance](#gear-allowance) +- [consentMessage](#gear-consentmessage) ##### :gear: create @@ -165,7 +166,7 @@ Parameters: | -------- | ---------------------------------------------------------------------- | | `create` | `(options: IcrcLedgerCanisterOptions<_SERVICE>) => IcrcLedgerCanister` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L28) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L34) ##### :gear: metadata @@ -175,7 +176,7 @@ The token metadata (name, symbol, etc.). | ---------- | ------------------------------------------------------------- | | `metadata` | `(params: QueryParams) => Promise` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L42) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L48) ##### :gear: transactionFee @@ -185,7 +186,7 @@ The ledger transaction fees. | ---------------- | ------------------------------------------ | | `transactionFee` | `(params: QueryParams) => Promise` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L50) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L56) ##### :gear: balance @@ -199,7 +200,7 @@ Parameters: - `params`: The parameters to get the balance of an account. -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L59) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L65) ##### :gear: transfer @@ -213,7 +214,7 @@ Parameters: - `params`: The parameters to transfer tokens. -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L72) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L78) ##### :gear: totalTokensSupply @@ -223,7 +224,7 @@ Returns the total supply of tokens. | ------------------- | ------------------------------------------ | | `totalTokensSupply` | `(params: QueryParams) => Promise` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L88) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L94) ##### :gear: transferFrom @@ -239,7 +240,7 @@ Parameters: - `params`: The parameters to transfer tokens from to. -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L101) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L107) ##### :gear: approve @@ -255,7 +256,7 @@ Parameters: - `params`: The parameters to approve. -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L123) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L129) ##### :gear: allowance @@ -271,7 +272,21 @@ Parameters: - `params`: The parameters to call the allowance. -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L145) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L151) + +##### :gear: consentMessage + +Fetches the consent message for a specified canister call, intended to provide a human-readable message that helps users make informed decisions. + +| Method | Type | +| ---------------- | ---------------------------------------------------------------------- | +| `consentMessage` | `(params: Icrc21ConsentMessageParams) => Promise` | + +Parameters: + +- `params`: - The request parameters containing the method name, arguments, and consent preferences (e.g., language). + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/ledger.canister.ts#L169) ### :factory: IcrcIndexCanister diff --git a/packages/ledger-icrc/src/converters/ledger.converters.ts b/packages/ledger-icrc/src/converters/ledger.converters.ts index 3854ebe42..6d3e244f5 100644 --- a/packages/ledger-icrc/src/converters/ledger.converters.ts +++ b/packages/ledger-icrc/src/converters/ledger.converters.ts @@ -1,11 +1,13 @@ -import { toNullable } from "@dfinity/utils"; +import { isNullish, toNullable } from "@dfinity/utils"; import type { ApproveArgs, + icrc21_consent_message_request as ConsentMessageArgs, TransferArg, TransferFromArgs, } from "../../candid/icrc_ledger"; import type { ApproveParams, + Icrc21ConsentMessageParams, TransferFromParams, TransferParams, } from "../types/ledger.params"; @@ -60,3 +62,31 @@ export const toApproveArgs = ({ expected_allowance: toNullable(expected_allowance), expires_at: toNullable(expires_at), }); + +export const toIcrc21ConsentMessageArgs = ({ + userPreferences: { + metadata: { utcOffsetMinutes, language }, + deriveSpec, + }, + ...rest +}: Icrc21ConsentMessageParams): ConsentMessageArgs => ({ + ...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-icrc/src/errors/ledger.errors.ts b/packages/ledger-icrc/src/errors/ledger.errors.ts index 468789160..eb45d26ab 100644 --- a/packages/ledger-icrc/src/errors/ledger.errors.ts +++ b/packages/ledger-icrc/src/errors/ledger.errors.ts @@ -1,3 +1,5 @@ +import type { icrc21_error as Icrc21RawError } from "../../candid/icrc_ledger"; + export class IcrcTransferError extends Error { public errorType: T; constructor({ msg, errorType }: { msg?: string; errorType: T }) { @@ -5,3 +7,51 @@ export class IcrcTransferError extends Error { this.errorType = errorType; } } + +export class GenericError extends Error { + constructor( + public readonly message: string, + public readonly error_code: bigint, + ) { + super(); + } +} + +export class ConsentMessageError extends Error {} + +export class InsufficientPaymentError extends ConsentMessageError {} +export class UnsupportedCanisterCallError extends ConsentMessageError {} +export class ConsentMessageUnavailableError extends ConsentMessageError {} + +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-icrc/src/ledger.canister.spec.ts b/packages/ledger-icrc/src/ledger.canister.spec.ts index 390bb1298..106f759fd 100644 --- a/packages/ledger-icrc/src/ledger.canister.spec.ts +++ b/packages/ledger-icrc/src/ledger.canister.spec.ts @@ -8,8 +8,16 @@ import type { _SERVICE as IcrcLedgerService, TransferArg, TransferFromArgs, + icrc21_consent_message_response, } from "../candid/icrc_ledger"; -import { IcrcTransferError } from "./errors/ledger.errors"; +import { + ConsentMessageError, + ConsentMessageUnavailableError, + GenericError, + IcrcTransferError, + InsufficientPaymentError, + UnsupportedCanisterCallError, +} from "./errors/ledger.errors"; import { IcrcLedgerCanister } from "./ledger.canister"; import { ledgerCanisterIdMock, @@ -19,6 +27,7 @@ import { import { AllowanceParams, ApproveParams, + Icrc21ConsentMessageParams, TransferFromParams, TransferParams, } from "./types/ledger.params"; @@ -315,4 +324,320 @@ describe("Ledger canister", () => { expect(res).toEqual(allowance); }); }); + + describe("consentMessage", () => { + const consentMessageRequest: Icrc21ConsentMessageParams = { + 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 = IcrcLedgerCanister.create({ + canisterId: ledgerCanisterIdMock, + certifiedServiceOverride: service, + }); + + const response = await ledger.consentMessage(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 = IcrcLedgerCanister.create({ + certifiedServiceOverride: service, + canisterId: ledgerCanisterIdMock, + }); + + const requestWithLineDisplay: Icrc21ConsentMessageParams = { + ...consentMessageRequest, + userPreferences: { + metadata: { + language: "en-US", + }, + deriveSpec: { + LineDisplay: { + charactersPerLine: 20, + linesPerPage: 4, + }, + }, + }, + }; + + const response = await ledger.consentMessage(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 = IcrcLedgerCanister.create({ + certifiedServiceOverride: service, + canisterId: ledgerCanisterIdMock, + }); + + const requestWithUtcOffset: Icrc21ConsentMessageParams = { + ...consentMessageRequest, + userPreferences: { + metadata: { + language: "en-US", + utcOffsetMinutes: 120, + }, + deriveSpec: { + GenericDisplay: null, + }, + }, + }; + + const response = await ledger.consentMessage(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 = IcrcLedgerCanister.create({ + certifiedServiceOverride: service, + canisterId: ledgerCanisterIdMock, + }); + + await expect( + ledger.consentMessage(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 = IcrcLedgerCanister.create({ + canisterId: ledgerCanisterIdMock, + certifiedServiceOverride: service, + }); + + await expect( + ledger.consentMessage(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 = IcrcLedgerCanister.create({ + canisterId: ledgerCanisterIdMock, + certifiedServiceOverride: service, + }); + + await expect( + ledger.consentMessage(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 = IcrcLedgerCanister.create({ + canisterId: ledgerCanisterIdMock, + certifiedServiceOverride: service, + }); + + await expect( + ledger.consentMessage(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 = IcrcLedgerCanister.create({ + canisterId: ledgerCanisterIdMock, + certifiedServiceOverride: service, + }); + + await expect( + ledger.consentMessage(consentMessageRequest), + ).rejects.toThrowError( + new ConsentMessageError(`Unknown error type ${JSON.stringify(Err)}`), + ); + }); + }); }); diff --git a/packages/ledger-icrc/src/ledger.canister.ts b/packages/ledger-icrc/src/ledger.canister.ts index c58286836..0993dcbc5 100644 --- a/packages/ledger-icrc/src/ledger.canister.ts +++ b/packages/ledger-icrc/src/ledger.canister.ts @@ -5,20 +5,26 @@ import type { BlockIndex, _SERVICE as IcrcLedgerService, Tokens, + icrc21_consent_info, } from "../candid/icrc_ledger"; import { idlFactory as certifiedIdlFactory } from "../candid/icrc_ledger.certified.idl"; import { idlFactory } from "../candid/icrc_ledger.idl"; import { toApproveArgs, + toIcrc21ConsentMessageArgs, toTransferArg, toTransferFromArgs, } from "./converters/ledger.converters"; -import { IcrcTransferError } from "./errors/ledger.errors"; +import { + IcrcTransferError, + mapIcrc21ConsentMessageError, +} from "./errors/ledger.errors"; import type { IcrcLedgerCanisterOptions } from "./types/canister.options"; import type { AllowanceParams, ApproveParams, BalanceParams, + Icrc21ConsentMessageParams, TransferFromParams, TransferParams, } from "./types/ledger.params"; @@ -146,4 +152,35 @@ export class IcrcLedgerCanister extends Canister { const { certified, ...rest } = params; return this.caller({ certified }).icrc2_allowance({ ...rest }); }; + + /** + * Fetches the consent message for a specified canister call, intended to provide a human-readable message that helps users make informed decisions. + * + * @link: https://github.com/dfinity/wg-identity-authentication/blob/main/topics/ICRC-21/icrc_21_consent_msg.md + * + * @param {Icrc21ConsentMessageParams} 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. + * + * @throws {InsufficientPaymentError} - This error is reserved for future use, in case payment extensions are introduced. For example, if consent messages, which are currently free, begin to require payments. + * @throws {UnsupportedCanisterCallError} - If the specified canister call is not supported. + * @throws {ConsentMessageUnavailableError} - If there is no consent message available. + * @throws {GenericError} - For any other generic errors. + */ + consentMessage = async ( + params: Icrc21ConsentMessageParams, + ): Promise => { + const { icrc21_canister_call_consent_message } = this.caller({ + certified: true, + }); + + const response = await icrc21_canister_call_consent_message( + toIcrc21ConsentMessageArgs(params), + ); + + if ("Err" in response) { + throw mapIcrc21ConsentMessageError(response.Err); + } + + return response.Ok; + }; } diff --git a/packages/ledger-icrc/src/types/ledger.params.ts b/packages/ledger-icrc/src/types/ledger.params.ts index 610f46676..9d596a9d4 100644 --- a/packages/ledger-icrc/src/types/ledger.params.ts +++ b/packages/ledger-icrc/src/types/ledger.params.ts @@ -2,6 +2,7 @@ import type { QueryParams } from "@dfinity/utils"; import type { Account, AllowanceArgs, + icrc21_consent_message_request as ConsentMessageArgs, Subaccount, Timestamp, Tokens, @@ -77,3 +78,55 @@ export type ApproveParams = Omit & { * Params to get the token allowance that the spender account can transfer from the specified account */ export type AllowanceParams = AllowanceArgs & QueryParams; + +/** + * Metadata for the consent message in ICRC-21 specification. + * @param {number} [utcOffsetMinutes] - The user's local timezone offset in minutes from UTC. If absent, the default is UTC. + * @param {string} language - BCP-47 language tag. See https://www.rfc-editor.org/rfc/bcp/bcp47.txt + */ +export type Icrc21ConsentMessageMetadata = { + utcOffsetMinutes?: number; + language: string; +}; + +/** + * Device specification for displaying the consent message. + * + * @param {null} [GenericDisplay] - A generic display able to handle large documents and do line wrapping and pagination / scrolling. Text must be Markdown formatted, no external resources (e.g. images) are allowed. + * @param {Object} [LineDisplay] - Simple display able to handle lines of text with a maximum number of characters per line. + * @param {number} LineDisplay.charactersPerLine - Maximum number of characters that can be displayed per line. + * @param {number} LineDisplay.linesPerPage - Maximum number of lines that can be displayed at once on a single page. + */ +export type Icrc21ConsentMessageDeviceSpec = + | { GenericDisplay: null } + | { + LineDisplay: { + charactersPerLine: number; + linesPerPage: number; + }; + }; + +/** + * Specification for the consent message, including metadata and device preferences. + * + * @param {Icrc21ConsentMessageMetadata} metadata - Metadata of the consent message. + * @param {Icrc21ConsentMessageDeviceSpec} [deviceSpec] - Information about the device responsible for presenting the consent message to the user. + */ +export type Icrc21ConsentMessageSpec = { + metadata: Icrc21ConsentMessageMetadata; + deriveSpec?: Icrc21ConsentMessageDeviceSpec; +}; + +/** + * Parameters for the consent message request. + * + * @param {string} method - Method name of the canister call. + * @param {Uint8Array} arg - Argument of the canister call. + * @param {Icrc21ConsentMessageSpec} userPreferences - User preferences with regards to the consent message presented to the end-user. + */ +export type Icrc21ConsentMessageParams = Omit< + ConsentMessageArgs, + "user_preferences" +> & { + userPreferences: Icrc21ConsentMessageSpec; +};