Skip to content

Commit

Permalink
feat: icrc map token metadata to record (#798)
Browse files Browse the repository at this point in the history
# 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 <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
peterpeterparker and github-actions[bot] authored Dec 23, 2024
1 parent d810d76 commit 73a6c8a
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 22 additions & 2 deletions packages/ledger-icrc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const data = await metadata({});

- [encodeIcrcAccount](#gear-encodeicrcaccount)
- [decodeIcrcAccount](#gear-decodeicrcaccount)
- [mapTokenMetadata](#gear-maptokenmetadata)
- [decodePayment](#gear-decodepayment)

#### :gear: encodeIcrcAccount
Expand All @@ -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

Expand All @@ -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

Expand Down
8 changes: 8 additions & 0 deletions packages/ledger-icrc/src/types/ledger.responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,11 @@ export interface IcrcAccount {
owner: Principal;
subaccount?: Subaccount;
}

export interface IcrcTokenMetadata {
name: string;
symbol: string;
fee: bigint;
decimals: number;
icon?: string;
}
129 changes: 128 additions & 1 deletion packages/ledger-icrc/src/utils/ledger.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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,
});
});
});
});
65 changes: 64 additions & 1 deletion packages/ledger-icrc/src/utils/ledger.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Partial<IcrcTokenMetadata>>(
(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<IcrcTokenMetadata>,
): arg is IcrcTokenMetadata =>
nonNullish(arg.symbol) &&
nonNullish(arg.name) &&
nonNullish(arg.fee) &&
nonNullish(arg.decimals);

if (!isIcrcTokenMetadata(nullishToken)) {
return undefined;
}

return nullishToken;
};

0 comments on commit 73a6c8a

Please sign in to comment.