Skip to content

Commit

Permalink
feat: expose ledger-icp converters (#716)
Browse files Browse the repository at this point in the history
# Motivation

The ICRC signer standards require developers to pass the payload as a
Candid `Uint8Array`. Such an array can be generated using
`@dfinity/candid` which I find it somewhat cumbersome to use when you
are not used to it. Moreover, the IDL declarations required for this
conversion are not currently exposed by the types generated by Candid
and didc, which means that each developer has to manually copy the data.
Long story short, it's not the best DX.

That's why, in our implementation of the Oisy standard, we discussed
providing an opinionated client library that can be used to interact
with Oisy, or wallet.

Given that most JavaScript developers are likely more familiar with
using the types exposed by ic-js to interact with well-known canisters
of the Internet Computer (IC), such as ledgers, rather than those
generated by Candid, this PR aims to expose the "converters" of
`ledger-icp` to allow the client library to obfuscate this complexity.

In short, this Oisy client library needs to be able to transform IC-js
types into Candid types the same way ic-js do. Therefore, to avoid code
duplication, this PR exposes the converters.

# Notes

Oisy signer lib is currently not open source as in development and not
yet reviewed by security but, happy to jump in a call to share more
details if anything is unclear.

# Changes

- Expose `@dfinity/ledger-icp` converters
- Generate test. For "consent message" I extracted a mock from existing
data as I knew this one existed since it was recently added.

Signed-off-by: David Dal Busco <david.dalbusco@dfinity.org>
  • Loading branch information
peterpeterparker committed Sep 11, 2024
1 parent 1b43ed0 commit 1b6baa3
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Principal } from "@dfinity/principal";
import { toNullable } from "@dfinity/utils";
import { mockAccountIdentifier } from "../../mocks/ledger.mock";
import { mockConsentMessageRequest } from "../../mocks/ledger.request.mock";
import type {
Icrc1TransferRequest,
Icrc2ApproveRequest,
TransferRequest,
} from "../../types/ledger_converters";
import {
toIcrc1TransferRawRequest,
toIcrc21ConsentMessageRawRequest,
toIcrc2ApproveRawRequest,
toTransferRawRequest,
} from "./ledger.request.converts";

describe("ledger.request.converts", () => {
const now = BigInt(Date.now()) * BigInt(1e6);

const mockTransferRequest: TransferRequest = {
to: mockAccountIdentifier,
amount: 123n,
memo: 456n,
fee: 10_000n,
fromSubAccount: [0, 1, 2],
createdAt: now,
};

const to = {
owner: Principal.fromHex("abcd"),
subaccount: [] as [],
};

const mockIcrc1TransferRequest: Icrc1TransferRequest = {
to,
fromSubAccount: [0, 1, 2],
amount: 1_000_000n,
fee: 11_000n,
icrc1Memo: new Uint8Array([1, 2, 3]),
createdAt: now,
};

const mockIcrc2ApproveRequest: Icrc2ApproveRequest = {
spender: to,
fromSubAccount: [0, 1, 2],
expected_allowance: 5_000n,
expires_at: now + 200n,
amount: 1_200_000n,
fee: 12_000n,
icrc1Memo: new Uint8Array([1, 2, 3]),
createdAt: now,
};

it("toTransferRawRequest should return a valid raw request", () => {
const result = toTransferRawRequest(mockTransferRequest);

expect(result.to).toEqual(mockTransferRequest.to.toUint8Array());
expect(result.fee).toEqual({ e8s: mockTransferRequest.fee });
expect(result.amount).toEqual({ e8s: mockTransferRequest.amount });
expect(result.memo).toEqual(mockTransferRequest.memo);
expect(result.created_at_time).toEqual([
{ timestamp_nanos: mockTransferRequest.createdAt },
]);
expect(result.from_subaccount).toEqual([
new Uint8Array(mockTransferRequest.fromSubAccount ?? []),
]);
});

it("toIcrc1TransferRawRequest should return a valid ICRC-1 raw request", () => {
const result = toIcrc1TransferRawRequest(mockIcrc1TransferRequest);

expect(result.to).toEqual(mockIcrc1TransferRequest.to);
expect(result.fee).toEqual(toNullable(mockIcrc1TransferRequest.fee));
expect(result.amount).toEqual(mockIcrc1TransferRequest.amount);
expect(result.memo).toEqual(toNullable(mockIcrc1TransferRequest.icrc1Memo));
expect(result.created_at_time).toEqual(
toNullable(mockIcrc1TransferRequest.createdAt),
);
expect(result.from_subaccount).toEqual(
toNullable(mockIcrc1TransferRequest.fromSubAccount),
);
});

it("toIcrc2ApproveRawRequest should return a valid ICRC-2 raw request", () => {
const result = toIcrc2ApproveRawRequest(mockIcrc2ApproveRequest);

expect(result.spender).toEqual(mockIcrc2ApproveRequest.spender);
expect(result.fee).toEqual(toNullable(mockIcrc2ApproveRequest.fee));
expect(result.memo).toEqual(toNullable(mockIcrc2ApproveRequest.icrc1Memo));
expect(result.created_at_time).toEqual(
toNullable(mockIcrc2ApproveRequest.createdAt),
);
expect(result.amount).toEqual(mockIcrc2ApproveRequest.amount);
expect(result.expected_allowance).toEqual(
toNullable(mockIcrc2ApproveRequest.expected_allowance),
);
expect(result.expires_at).toEqual(
toNullable(mockIcrc2ApproveRequest.expires_at),
);
expect(result.from_subaccount).toEqual(
toNullable(mockIcrc2ApproveRequest.fromSubAccount),
);
});

it("toIcrc21ConsentMessageRawRequest should return a valid ICRC-21 generic consent message request", () => {
const result = toIcrc21ConsentMessageRawRequest(mockConsentMessageRequest);

expect(result.method).toEqual(mockConsentMessageRequest.method);
expect(result.arg).toEqual(mockConsentMessageRequest.arg);
expect(result.user_preferences.metadata.language).toEqual(
mockConsentMessageRequest.userPreferences.metadata.language,
);
expect(result.user_preferences.metadata.utc_offset_minutes).toEqual(
toNullable(
mockConsentMessageRequest.userPreferences.metadata.utcOffsetMinutes,
),
);
});

it("toIcrc21ConsentMessageRawRequest should return a valid ICRC-21 line display consent message request", () => {
const lineDisplay = {
charactersPerLine: 2,
linesPerPage: 10,
};

const consentMessageRequest = {
...mockConsentMessageRequest,
userPreferences: {
...mockConsentMessageRequest.userPreferences,
deriveSpec: {
LineDisplay: lineDisplay,
},
},
};

const result = toIcrc21ConsentMessageRawRequest(consentMessageRequest);

expect(result.user_preferences.device_spec).toEqual(
toNullable({
LineDisplay: {
characters_per_line: lineDisplay.charactersPerLine,
lines_per_page: lineDisplay.linesPerPage,
},
}),
);
});
});
1 change: 1 addition & 0 deletions packages/ledger-icp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
Value,
} from "../candid/ledger";
export { AccountIdentifier, SubAccount } from "./account_identifier";
export * from "./canisters/ledger/ledger.request.converts";
export * from "./errors/ledger.errors";
export { IndexCanister } from "./index.canister";
export { LedgerCanister } from "./ledger.canister";
Expand Down
36 changes: 13 additions & 23 deletions packages/ledger-icp/src/ledger.canister.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "./errors/ledger.errors";
import { LedgerCanister } from "./ledger.canister";
import { mockAccountIdentifier, mockPrincipal } from "./mocks/ledger.mock";
import { mockConsentMessageRequest } from "./mocks/ledger.request.mock";
import type {
Icrc21ConsentMessageRequest,
Icrc2ApproveRequest,
Expand Down Expand Up @@ -1059,19 +1060,6 @@ describe("LedgerCanister", () => {
});

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: {
Expand Down Expand Up @@ -1111,12 +1099,14 @@ describe("LedgerCanister", () => {
certifiedServiceOverride: service,
});

const response = await ledger.icrc21ConsentMessage(consentMessageRequest);
const response = await ledger.icrc21ConsentMessage(
mockConsentMessageRequest,
);

expect(response).toEqual(consentMessageResponse.Ok);
expect(service.icrc21_canister_call_consent_message).toBeCalledWith({
method: consentMessageRequest.method,
arg: consentMessageRequest.arg,
method: mockConsentMessageRequest.method,
arg: mockConsentMessageRequest.arg,
user_preferences: {
metadata: {
language: "en-US",
Expand All @@ -1142,7 +1132,7 @@ describe("LedgerCanister", () => {
});

const requestWithLineDisplay: Icrc21ConsentMessageRequest = {
...consentMessageRequest,
...mockConsentMessageRequest,
userPreferences: {
metadata: {
language: "en-US",
Expand Down Expand Up @@ -1192,7 +1182,7 @@ describe("LedgerCanister", () => {
});

const requestWithUtcOffset: Icrc21ConsentMessageRequest = {
...consentMessageRequest,
...mockConsentMessageRequest,
userPreferences: {
metadata: {
language: "en-US",
Expand Down Expand Up @@ -1246,7 +1236,7 @@ describe("LedgerCanister", () => {
});

await expect(
ledger.icrc21ConsentMessage(consentMessageRequest),
ledger.icrc21ConsentMessage(mockConsentMessageRequest),
).rejects.toThrowError(new GenericError(errorDescription, BigInt(500)));
});

Expand All @@ -1272,7 +1262,7 @@ describe("LedgerCanister", () => {
});

await expect(
ledger.icrc21ConsentMessage(consentMessageRequest),
ledger.icrc21ConsentMessage(mockConsentMessageRequest),
).rejects.toThrowError(
new InsufficientPaymentError(insufficientPaymentDescription),
);
Expand Down Expand Up @@ -1301,7 +1291,7 @@ describe("LedgerCanister", () => {
});

await expect(
ledger.icrc21ConsentMessage(consentMessageRequest),
ledger.icrc21ConsentMessage(mockConsentMessageRequest),
).rejects.toThrowError(
new UnsupportedCanisterCallError(unsupportedCanisterCallDescription),
);
Expand Down Expand Up @@ -1330,7 +1320,7 @@ describe("LedgerCanister", () => {
});

await expect(
ledger.icrc21ConsentMessage(consentMessageRequest),
ledger.icrc21ConsentMessage(mockConsentMessageRequest),
).rejects.toThrowError(
new ConsentMessageUnavailableError(
consentMessageUnavailableDescription,
Expand Down Expand Up @@ -1361,7 +1351,7 @@ describe("LedgerCanister", () => {
});

await expect(
ledger.icrc21ConsentMessage(consentMessageRequest),
ledger.icrc21ConsentMessage(mockConsentMessageRequest),
).rejects.toThrowError(
new ConsentMessageError(`Unknown error type ${JSON.stringify(Err)}`),
);
Expand Down
14 changes: 14 additions & 0 deletions packages/ledger-icp/src/mocks/ledger.request.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Icrc21ConsentMessageRequest } from "../types/ledger_converters";

export const mockConsentMessageRequest: Icrc21ConsentMessageRequest = {
method: "icrc1_transfer",
arg: new Uint8Array([1, 2, 3]),
userPreferences: {
metadata: {
language: "en-US",
},
deriveSpec: {
GenericDisplay: null,
},
},
};

0 comments on commit 1b6baa3

Please sign in to comment.