Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: icrc map token metadata to record #798

Merged
merged 4 commits into from
Dec 23, 2024
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
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 }) };
peterpeterparker marked this conversation as resolved.
Show resolved Hide resolved
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;
};
Loading