From 73a6c8af543c3ce7625261220a660a92961a6281 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 23 Dec 2024 11:14:44 +0100 Subject: [PATCH] feat: icrc map token metadata to record (#798) # Motivation I've been implementing the same mapper repeatedly, and today I needed it in another library (the oisy-wallet-signer). Instead of reimplementing it again, I added a utility to the ledger-icrc library to map the token metadata information from a ledger response provided in form of Candid arrays into a structured record. # Changes - Add utility `mapTokenMetadata`. --------- Signed-off-by: David Dal Busco Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + packages/ledger-icrc/README.md | 24 +++- .../ledger-icrc/src/types/ledger.responses.ts | 8 ++ .../src/utils/ledger.utils.spec.ts | 129 +++++++++++++++++- .../ledger-icrc/src/utils/ledger.utils.ts | 65 ++++++++- 5 files changed, 223 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e06383342..74b4d9e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Add support for `get_subnet_types_to_subnets` to `@dfinity/cmc`. - Support `VotingPowerEconomics`, `potential_voting_power` and `deciding_voting_power` in `@dfinity/nns`. - Add utility `isEmptyString` (the opposite of existing `notEmptyString`). +- Add utility `mapTokenMetadata` in `@dfinity/ledger-icrc` to map the token metadata information from a ledger response into a structured record. # 2024.11.27-1230Z diff --git a/packages/ledger-icrc/README.md b/packages/ledger-icrc/README.md index 3a26fbc77..5f2fb4f1e 100644 --- a/packages/ledger-icrc/README.md +++ b/packages/ledger-icrc/README.md @@ -58,6 +58,7 @@ const data = await metadata({}); - [encodeIcrcAccount](#gear-encodeicrcaccount) - [decodeIcrcAccount](#gear-decodeicrcaccount) +- [mapTokenMetadata](#gear-maptokenmetadata) - [decodePayment](#gear-decodepayment) #### :gear: encodeIcrcAccount @@ -73,7 +74,7 @@ Parameters: - `account`: : Principal, subaccount?: Uint8Array } -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/utils/ledger.utils.ts#L21) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/utils/ledger.utils.ts#L27) #### :gear: decodeIcrcAccount @@ -88,7 +89,26 @@ Parameters: - `accountString`: string -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/utils/ledger.utils.ts#L61) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/utils/ledger.utils.ts#L67) + +#### :gear: mapTokenMetadata + +Maps the token metadata information from a ledger response into a structured record. + +This utility processes an array of metadata key-value pairs provided by the ledger +and extracts specific fields, such as symbol, name, fee, decimals, and logo. It then +constructs a `IcrcTokenMetadata` record. If any required fields are missing, +the function returns `undefined`. + +| Function | Type | +| ------------------ | ------------------------------------------------------------------------- | +| `mapTokenMetadata` | `(response: IcrcTokenMetadataResponse) => IcrcTokenMetadata or undefined` | + +Parameters: + +- `response`: - An array of key-value pairs representing token metadata. + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc/src/utils/ledger.utils.ts#L111) #### :gear: decodePayment diff --git a/packages/ledger-icrc/src/types/ledger.responses.ts b/packages/ledger-icrc/src/types/ledger.responses.ts index 8198b43db..ed8cc6e81 100644 --- a/packages/ledger-icrc/src/types/ledger.responses.ts +++ b/packages/ledger-icrc/src/types/ledger.responses.ts @@ -19,3 +19,11 @@ export interface IcrcAccount { owner: Principal; subaccount?: Subaccount; } + +export interface IcrcTokenMetadata { + name: string; + symbol: string; + fee: bigint; + decimals: number; + icon?: string; +} diff --git a/packages/ledger-icrc/src/utils/ledger.utils.spec.ts b/packages/ledger-icrc/src/utils/ledger.utils.spec.ts index 85dfd6b1c..dbe6788d4 100644 --- a/packages/ledger-icrc/src/utils/ledger.utils.spec.ts +++ b/packages/ledger-icrc/src/utils/ledger.utils.spec.ts @@ -1,6 +1,14 @@ import { Principal } from "@dfinity/principal"; import { mockPrincipal } from "../mocks/ledger.mock"; -import { decodeIcrcAccount, encodeIcrcAccount } from "./ledger.utils"; +import { + IcrcMetadataResponseEntries, + type IcrcTokenMetadataResponse, +} from "../types/ledger.responses"; +import { + decodeIcrcAccount, + encodeIcrcAccount, + mapTokenMetadata, +} from "./ledger.utils"; describe("ledger-utils", () => { const ownerText = @@ -114,4 +122,123 @@ describe("ledger-utils", () => { expect(decodeIcrcAccount(encodeIcrcAccount(account4))).toEqual(account4); }); }); + + describe("mapTokenMetadata", () => { + const validResponse: IcrcTokenMetadataResponse = [ + [IcrcMetadataResponseEntries.SYMBOL, { Text: "TKN" }], + [IcrcMetadataResponseEntries.NAME, { Text: "Token" }], + [IcrcMetadataResponseEntries.FEE, { Nat: 10_000n }], + [IcrcMetadataResponseEntries.DECIMALS, { Nat: 8n }], + [IcrcMetadataResponseEntries.LOGO, { Text: "a-logo" }], + ]; + + it("should map token metadata", () => { + const result = mapTokenMetadata(validResponse); + + expect(result).toEqual({ + name: "Token", + symbol: "TKN", + fee: 10_000n, + decimals: 8, + icon: "a-logo", + }); + }); + + const missingFieldCases: [string, IcrcTokenMetadataResponse][] = [ + [ + "missing field symbol", + validResponse.filter( + ([key]) => key !== IcrcMetadataResponseEntries.SYMBOL, + ), + ], + [ + "missing field name", + validResponse.filter( + ([key]) => key !== IcrcMetadataResponseEntries.NAME, + ), + ], + [ + "missing field fee", + validResponse.filter( + ([key]) => key !== IcrcMetadataResponseEntries.FEE, + ), + ], + [ + "missing field decimals", + validResponse.filter( + ([key]) => key !== IcrcMetadataResponseEntries.DECIMALS, + ), + ], + ]; + + it.each(missingFieldCases)( + "should return undefined for %s", + (_, response) => { + const result = mapTokenMetadata(response); + expect(result).toBeUndefined(); + }, + ); + + const invalidFieldCases: [string, IcrcTokenMetadataResponse][] = [ + [ + "invalid symbol value", + validResponse.map(([key, value]) => + key === IcrcMetadataResponseEntries.SYMBOL + ? [key, { Nat: BigInt(1) }] + : [key, value], + ), + ], + [ + "invalid name value", + validResponse.map(([key, value]) => + key === IcrcMetadataResponseEntries.NAME + ? [key, { Nat: BigInt(1) }] + : [key, value], + ), + ], + [ + "invalid fee value", + validResponse.map(([key, value]) => + key === IcrcMetadataResponseEntries.FEE + ? [key, { Text: "100" }] + : [key, value], + ), + ], + [ + "invalid decimals value", + validResponse.map(([key, value]) => + key === IcrcMetadataResponseEntries.DECIMALS + ? [key, { Text: "8" }] + : [key, value], + ), + ], + ]; + + it.each(invalidFieldCases)( + "should return undefined for %s", + (_, response) => { + const result = mapTokenMetadata(response); + expect(result).toBeUndefined(); + }, + ); + + test("should return empty if response metadata is empty", () => { + const result = mapTokenMetadata([]); + expect(result).toBeUndefined(); + }); + + test("should map a metadata without logo", () => { + const responseWithoutLogo = validResponse.filter( + ([key]) => key !== IcrcMetadataResponseEntries.LOGO, + ); + + const result = mapTokenMetadata(responseWithoutLogo); + expect(result).toEqual({ + name: "Token", + symbol: "TKN", + fee: 10_000n, + decimals: 8, + }); + }); + }); }); diff --git a/packages/ledger-icrc/src/utils/ledger.utils.ts b/packages/ledger-icrc/src/utils/ledger.utils.ts index b7968d93a..131442648 100644 --- a/packages/ledger-icrc/src/utils/ledger.utils.ts +++ b/packages/ledger-icrc/src/utils/ledger.utils.ts @@ -4,10 +4,16 @@ import { encodeBase32, hexStringToUint8Array, isNullish, + nonNullish, notEmptyString, uint8ArrayToHexString, } from "@dfinity/utils"; -import type { IcrcAccount } from "../types/ledger.responses"; +import type { + IcrcAccount, + IcrcTokenMetadata, + IcrcTokenMetadataResponse, +} from "../types/ledger.responses"; +import { IcrcMetadataResponseEntries } from "../types/ledger.responses"; const MAX_SUBACCOUNT_HEX_LENGTH = 64; @@ -89,3 +95,60 @@ export const decodeIcrcAccount = (accountString: string): IcrcAccount => { return account; }; + +/** + * Maps the token metadata information from a ledger response into a structured record. + * + * This utility processes an array of metadata key-value pairs provided by the ledger + * and extracts specific fields, such as symbol, name, fee, decimals, and logo. It then + * constructs a `IcrcTokenMetadata` record. If any required fields are missing, + * the function returns `undefined`. + * + * @param {IcrcTokenMetadataResponse} response - An array of key-value pairs representing token metadata. + * + * @returns {IcrcTokenMetadata | undefined} - A structured metadata record or `undefined` if required fields are missing. + */ +export const mapTokenMetadata = ( + response: IcrcTokenMetadataResponse, +): IcrcTokenMetadata | undefined => { + const nullishToken = response.reduce>( + (acc, [key, value]) => { + switch (key) { + case IcrcMetadataResponseEntries.SYMBOL: + acc = { ...acc, ...("Text" in value && { symbol: value.Text }) }; + break; + case IcrcMetadataResponseEntries.NAME: + acc = { ...acc, ...("Text" in value && { name: value.Text }) }; + break; + case IcrcMetadataResponseEntries.FEE: + acc = { ...acc, ...("Nat" in value && { fee: value.Nat }) }; + break; + case IcrcMetadataResponseEntries.DECIMALS: + acc = { + ...acc, + ...("Nat" in value && { decimals: Number(value.Nat) }), + }; + break; + case IcrcMetadataResponseEntries.LOGO: + acc = { ...acc, ...("Text" in value && { icon: value.Text }) }; + } + + return acc; + }, + {}, + ); + + const isIcrcTokenMetadata = ( + arg: Partial, + ): arg is IcrcTokenMetadata => + nonNullish(arg.symbol) && + nonNullish(arg.name) && + nonNullish(arg.fee) && + nonNullish(arg.decimals); + + if (!isIcrcTokenMetadata(nullishToken)) { + return undefined; + } + + return nullishToken; +};