From b7d45f29e89b634ec327647dea9c11150b06d8bc Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 26 Sep 2023 14:02:25 +0200 Subject: [PATCH 01/25] feat: move ICP ledger features from NNS lib to ledger-icp --- package-lock.json | 13 +- packages/ledger-icp/package.json | 1 + .../src/account_identifier.spec.ts | 0 .../src/account_identifier.ts | 3 +- .../ledger/ledger.request.converts.ts | 0 .../ledger-icp/src/constants/canister_ids.ts | 5 + .../src/constants/constants.ts | 0 .../src/errors/ledger.errors.ts | 0 packages/ledger-icp/src/index.ts | 5 +- .../ledger-icp/src/ledger.canister.spec.ts | 982 +++++++++++++++++- packages/ledger-icp/src/ledger.canister.ts | 237 ++++- packages/ledger-icp/src/types/common.ts | 3 + .../src/types/ledger.options.ts | 0 .../src/types/ledger_converters.ts | 0 packages/ledger-icp/src/utils/proto.utils.ts | 87 ++ packages/nns/package.json | 1 + .../governance/request.converters.ts | 2 +- .../governance/response.converters.ts | 2 +- packages/nns/src/constants/canister_ids.ts | 4 - packages/nns/src/governance.canister.spec.ts | 2 +- packages/nns/src/governance.canister.ts | 6 +- packages/nns/src/index.ts | 4 - packages/nns/src/ledger.canister.spec.ts | 980 ----------------- packages/nns/src/ledger.canister.ts | 236 ----- 24 files changed, 1335 insertions(+), 1238 deletions(-) rename packages/{nns => ledger-icp}/src/account_identifier.spec.ts (100%) rename packages/{nns => ledger-icp}/src/account_identifier.ts (95%) rename packages/{nns => ledger-icp}/src/canisters/ledger/ledger.request.converts.ts (100%) create mode 100644 packages/ledger-icp/src/constants/canister_ids.ts rename packages/{nns => ledger-icp}/src/constants/constants.ts (100%) rename packages/{nns => ledger-icp}/src/errors/ledger.errors.ts (100%) create mode 100644 packages/ledger-icp/src/types/common.ts rename packages/{nns => ledger-icp}/src/types/ledger.options.ts (100%) rename packages/{nns => ledger-icp}/src/types/ledger_converters.ts (100%) create mode 100644 packages/ledger-icp/src/utils/proto.utils.ts delete mode 100644 packages/nns/src/ledger.canister.spec.ts delete mode 100644 packages/nns/src/ledger.canister.ts diff --git a/package-lock.json b/package-lock.json index 38664020..1fb6bcdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7071,9 +7071,16 @@ } }, "packages/ledger-icp": { + "name": "@dfinity/ledger-icp", "version": "0.0.1", "license": "Apache-2.0", - "devDependencies": {} + "peerDependencies": { + "@dfinity/agent": "^0.19.2", + "@dfinity/candid": "^0.19.2", + "@dfinity/nns-proto": "^0.0.9", + "@dfinity/principal": "^0.19.2", + "@dfinity/utils": "^0.0.23" + } }, "packages/nns": { "name": "@dfinity/nns", @@ -7089,6 +7096,7 @@ "peerDependencies": { "@dfinity/agent": "^0.19.2", "@dfinity/candid": "^0.19.2", + "@dfinity/ledger-icp": "^0.0.1", "@dfinity/nns-proto": "^0.0.9", "@dfinity/principal": "^0.19.2", "@dfinity/utils": "^0.0.23" @@ -7620,7 +7628,8 @@ "requires": {} }, "@dfinity/ledger-icp": { - "version": "file:packages/ledger-icp" + "version": "file:packages/ledger-icp", + "requires": {} }, "@dfinity/nns": { "version": "file:packages/nns", diff --git a/packages/ledger-icp/package.json b/packages/ledger-icp/package.json index 7b863b19..96334224 100644 --- a/packages/ledger-icp/package.json +++ b/packages/ledger-icp/package.json @@ -40,6 +40,7 @@ "peerDependencies": { "@dfinity/agent": "^0.19.2", "@dfinity/candid": "^0.19.2", + "@dfinity/nns-proto": "^0.0.9", "@dfinity/principal": "^0.19.2", "@dfinity/utils": "^0.0.23" } diff --git a/packages/nns/src/account_identifier.spec.ts b/packages/ledger-icp/src/account_identifier.spec.ts similarity index 100% rename from packages/nns/src/account_identifier.spec.ts rename to packages/ledger-icp/src/account_identifier.spec.ts diff --git a/packages/nns/src/account_identifier.ts b/packages/ledger-icp/src/account_identifier.ts similarity index 95% rename from packages/nns/src/account_identifier.ts rename to packages/ledger-icp/src/account_identifier.ts index 3212ce1a..c8478739 100644 --- a/packages/nns/src/account_identifier.ts +++ b/packages/ledger-icp/src/account_identifier.ts @@ -7,7 +7,6 @@ import { uint8ArrayToHexString, } from "@dfinity/utils"; import { sha224 } from "@noble/hashes/sha256"; -import type { AccountIdentifier as AccountIdentifierCandid } from "../candid/governance"; import { importNnsProto } from "./utils/proto.utils"; export class AccountIdentifier { @@ -66,7 +65,7 @@ export class AccountIdentifier { return Array.from(this.bytes); } - public toAccountIdentifierHash(): AccountIdentifierCandid { + public toAccountIdentifierHash(): { hash: Uint8Array } { return { hash: this.toUint8Array(), }; diff --git a/packages/nns/src/canisters/ledger/ledger.request.converts.ts b/packages/ledger-icp/src/canisters/ledger/ledger.request.converts.ts similarity index 100% rename from packages/nns/src/canisters/ledger/ledger.request.converts.ts rename to packages/ledger-icp/src/canisters/ledger/ledger.request.converts.ts diff --git a/packages/ledger-icp/src/constants/canister_ids.ts b/packages/ledger-icp/src/constants/canister_ids.ts new file mode 100644 index 00000000..b3713767 --- /dev/null +++ b/packages/ledger-icp/src/constants/canister_ids.ts @@ -0,0 +1,5 @@ +import { Principal } from "@dfinity/principal"; + +export const MAINNET_LEDGER_CANISTER_ID = Principal.fromText( + "ryjl3-tyaaa-aaaaa-aaaba-cai", +); diff --git a/packages/nns/src/constants/constants.ts b/packages/ledger-icp/src/constants/constants.ts similarity index 100% rename from packages/nns/src/constants/constants.ts rename to packages/ledger-icp/src/constants/constants.ts diff --git a/packages/nns/src/errors/ledger.errors.ts b/packages/ledger-icp/src/errors/ledger.errors.ts similarity index 100% rename from packages/nns/src/errors/ledger.errors.ts rename to packages/ledger-icp/src/errors/ledger.errors.ts diff --git a/packages/ledger-icp/src/index.ts b/packages/ledger-icp/src/index.ts index f59d2fbb..c03a1be0 100644 --- a/packages/ledger-icp/src/index.ts +++ b/packages/ledger-icp/src/index.ts @@ -1 +1,4 @@ -export * from "./ledger.canister"; +export { AccountIdentifier, SubAccount } from "./account_identifier"; +export * from "./errors/ledger.errors"; +export { LedgerCanister } from "./ledger.canister"; +export * from "./types/ledger.options"; diff --git a/packages/ledger-icp/src/ledger.canister.spec.ts b/packages/ledger-icp/src/ledger.canister.spec.ts index 2ff65568..65bb98fd 100644 --- a/packages/ledger-icp/src/ledger.canister.spec.ts +++ b/packages/ledger-icp/src/ledger.canister.spec.ts @@ -1,2 +1,980 @@ -it("should be true to path CI until effective implementation", () => - expect(true).toBeTruthy()); +import { ActorSubclass } from "@dfinity/agent"; +import { Memo, Payment, SendRequest } from "@dfinity/nns-proto"; +import { Principal } from "@dfinity/principal"; +import { arrayOfNumberToUint8Array } from "@dfinity/utils"; +import { mock } from "jest-mock-extended"; +import type { _SERVICE as LedgerService } from "../candid/ledger"; +import { AccountIdentifier } from "./account_identifier"; +import { toICPTs } from "./canisters/ledger/ledger.request.converts"; +import { TRANSACTION_FEE } from "./constants/constants"; +import { + BadFeeError, + InsufficientFundsError, + InvalidSenderError, + TxCreatedInFutureError, + TxDuplicateError, + TxTooOldError, +} from "./errors/ledger.errors"; +import { LedgerCanister } from "./ledger.canister"; +import { E8s } from "./types/common"; + +describe("LedgerCanister", () => { + const accountIdentifier = AccountIdentifier.fromHex( + "3e8bbceef8b9338e56a1b561a127326e6614894ab9b0739df4cc3664d40a5958", + ); + describe("accountBalance", () => { + describe("no hardware wallet", () => { + const tokens = { + e8s: BigInt(30_000_000), + }; + it("returns account balance with query call", async () => { + const service = mock>(); + service.account_balance.mockResolvedValue(tokens); + const ledger = LedgerCanister.create({ + serviceOverride: service, + }); + + const balance = await ledger.accountBalance({ + accountIdentifier, + certified: false, + }); + expect(balance).toEqual(tokens.e8s); + expect(service.account_balance).toBeCalled(); + }); + + it("returns account balance with update call", async () => { + const service = mock>(); + service.account_balance.mockResolvedValue(tokens); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + + const balance = await ledger.accountBalance({ + accountIdentifier, + certified: true, + }); + expect(balance).toEqual(tokens.e8s); + expect(service.account_balance).toBeCalled(); + }); + }); + + describe("transactionFee", () => { + it("returns the transaction fee in e8s", async () => { + const fee = BigInt(10_000); + const service = mock>(); + service.transfer_fee.mockResolvedValue({ + transfer_fee: { + e8s: fee, + }, + }); + const ledger = LedgerCanister.create({ + serviceOverride: service, + }); + + const expectedFee = await ledger.transactionFee(); + expect(service.transfer_fee).toBeCalled(); + expect(expectedFee).toBe(fee); + }); + }); + + describe("for hardware wallet", () => { + it("returns account balance with query call", async () => { + const queryFetcher = jest + .fn() + .mockResolvedValue(new Uint8Array(32).fill(0)); + const ledger = LedgerCanister.create({ + queryCallOverride: queryFetcher, + hardwareWallet: true, + }); + const balance = await ledger.accountBalance({ + accountIdentifier, + certified: false, + }); + expect(typeof balance).toEqual("bigint"); + expect(queryFetcher).toBeCalled(); + }); + + it("returns account balance with update call", async () => { + const updateFetcher = jest + .fn() + .mockResolvedValue(new Uint8Array(32).fill(0)); + const ledger = LedgerCanister.create({ + updateCallOverride: updateFetcher, + hardwareWallet: true, + }); + const balance = await ledger.accountBalance({ + accountIdentifier, + certified: true, + }); + expect(typeof balance).toEqual("bigint"); + expect(updateFetcher).toBeCalled(); + }); + }); + }); + + describe("transfer", () => { + describe("no hardware wallet", () => { + const to = accountIdentifier; + const amount = BigInt(100000); + + it("fetches transaction fee if not present", async () => { + const service = mock>(); + service.transfer_fee.mockResolvedValue({ + transfer_fee: { e8s: BigInt(10_000) }, + }); + service.transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + await ledger.transfer({ + to, + amount, + }); + + expect(service.transfer_fee).toBeCalled(); + }); + + it("calls transfer certified service with data", async () => { + const service = mock>(); + service.transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const fee = BigInt(10_000); + const memo = BigInt(3456); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + await ledger.transfer({ + to, + amount, + fee, + memo, + }); + + expect(service.transfer).toBeCalledWith({ + to: to.toUint8Array(), + fee: { + e8s: fee, + }, + amount: { + e8s: amount, + }, + memo, + created_at_time: [], + from_subaccount: [], + }); + }); + + it("sets a default memo if not passed", async () => { + const service = mock>(); + service.transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const fee = BigInt(10_000); + const defaultMemo = BigInt(0); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + await ledger.transfer({ + to, + amount, + fee, + }); + + expect(service.transfer).toBeCalledWith({ + to: to.toUint8Array(), + fee: { + e8s: fee, + }, + amount: { + e8s: amount, + }, + memo: defaultMemo, + created_at_time: [], + from_subaccount: [], + }); + }); + + it("handles createdAt parameter", async () => { + const service = mock>(); + service.transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const fee = BigInt(10_000); + const memo = BigInt(3456); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + const createdAt = BigInt(123132223); + await ledger.transfer({ + to, + amount, + fee, + memo, + createdAt, + }); + + expect(service.transfer).toBeCalledWith({ + to: to.toUint8Array(), + fee: { + e8s: fee, + }, + amount: { + e8s: amount, + }, + memo, + created_at_time: [{ timestamp_nanos: createdAt }], + from_subaccount: [], + }); + }); + + it("handles subaccount", async () => { + const service = mock>(); + service.transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const fee = BigInt(10_000); + const memo = BigInt(0); + const fromSubAccount = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, + ]; + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + await ledger.transfer({ + to, + amount, + fee, + memo, + fromSubAccount, + }); + + expect(service.transfer).toBeCalledWith({ + to: to.toUint8Array(), + fee: { + e8s: fee, + }, + amount: { + e8s: amount, + }, + memo, + created_at_time: [], + from_subaccount: [arrayOfNumberToUint8Array(fromSubAccount)], + }); + }); + + it("handles duplicate transaction", async () => { + const service = mock>(); + service.transfer.mockResolvedValue({ + Err: { + TxDuplicate: { + duplicate_of: BigInt(10), + }, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(TxDuplicateError); + }); + + it("handles insufficient balance", async () => { + const service = mock>(); + service.transfer.mockResolvedValue({ + Err: { + InsufficientFunds: { + balance: { + e8s: BigInt(12312414), + }, + }, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(InsufficientFundsError); + }); + + it("handles old tx", async () => { + const service = mock>(); + service.transfer.mockResolvedValue({ + Err: { + TxTooOld: { + allowed_window_nanos: BigInt(1234), + }, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(TxTooOldError); + }); + + it("handles bad fee", async () => { + const service = mock>(); + service.transfer.mockResolvedValue({ + Err: { + BadFee: { + expected_fee: { + e8s: BigInt(1234), + }, + }, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(BadFeeError); + }); + + it("handles transaction created in the future", async () => { + const service = mock>(); + service.transfer.mockResolvedValue({ + Err: { + TxCreatedInFuture: null, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(TxCreatedInFutureError); + }); + }); + + describe("for hardware wallet", () => { + const to = accountIdentifier; + const amount = BigInt(100000); + + it("handles invalid sender", async () => { + const ledger = LedgerCanister.create({ + updateCallOverride: () => { + throw new Error(`Reject code: 5 + Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'Sending from 2vxsx-fae is not allowed', rosetta-api/ledger_canister/src/main.rs:135:9`); + }, + hardwareWallet: true, + }); + + const call = async () => + await ledger.transfer({ + to, + amount, + }); + + await expect(call).rejects.toThrow(new InvalidSenderError()); + }); + + it("handles duplicate transaction", async () => { + const ledger = LedgerCanister.create({ + updateCallOverride: () => { + throw new Error(`Reject code: 5 + Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'transaction is a duplicate of another transaction in block 1235123', rosetta-api/ledger_canister/src/main.rs:135:9`); + }, + hardwareWallet: true, + }); + + const call = async () => + await ledger.transfer({ + to, + amount, + }); + + await expect(call).rejects.toThrow( + new TxDuplicateError(BigInt(1235123)), + ); + }); + + it("handles insufficient balance", async () => { + const ledger = LedgerCanister.create({ + updateCallOverride: () => { + throw new Error(`Reject code: 5 + Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'the debit account doesn't have enough funds to complete the transaction, current balance: 123.46789123', rosetta-api/ledger_canister/src/main.rs:135:9`); + }, + hardwareWallet: true, + }); + + const call = async () => + await ledger.transfer({ + to, + amount, + }); + + await expect(call).rejects.toThrow( + new InsufficientFundsError(BigInt(12346789123)), + ); + }); + + it("handles future tx", async () => { + const ledger = LedgerCanister.create({ + updateCallOverride: () => { + throw new Error(`Reject code: 5 + Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'transaction's created_at_time is in future', rosetta-api/ledger_canister/src/main.rs:135:9`); + }, + hardwareWallet: true, + }); + + const call = async () => + await ledger.transfer({ + to, + amount, + }); + + await expect(call).rejects.toThrow(new TxCreatedInFutureError()); + }); + + it("handles old tx", async () => { + const ledger = LedgerCanister.create({ + updateCallOverride: () => { + throw new Error(`Reject code: 5 + Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'transaction is older than 123 seconds', rosetta-api/ledger_canister/src/main.rs:135:9`); + }, + hardwareWallet: true, + }); + + const call = async () => + await ledger.transfer({ + to, + amount, + }); + + await expect(call).rejects.toThrow(new TxTooOldError(123)); + }); + + it("handles subaccount for hw", async () => { + const ledger = LedgerCanister.create({ + updateCallOverride: jest + .fn() + .mockResolvedValue(new Uint8Array(32).fill(0)), + hardwareWallet: true, + }); + const fromSubAccount = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, + ]; + + const res = await ledger.transfer({ + to, + amount, + fromSubAccount, + }); + + expect(typeof res).toEqual("bigint"); + }); + + const initExpectedRequest = async ({ + to, + amount, + memo, + fee, + }: { + to: AccountIdentifier; + amount: bigint; + memo?: bigint; + fee?: E8s; + }): Promise => { + const expectedRequest = new SendRequest(); + expectedRequest.setTo(await to.toProto()); + + const payment = new Payment(); + payment.setReceiverGets(await toICPTs(amount)); + expectedRequest.setPayment(payment); + + const requestMemo = new Memo(); + requestMemo.setMemo((memo ?? BigInt(0)).toString()); + expectedRequest.setMemo(requestMemo); + + expectedRequest.setMaxFee(await toICPTs(fee ?? TRANSACTION_FEE)); + + return expectedRequest; + }; + + it("should set a default fee for a transfer", async () => { + const ledger = LedgerCanister.create({ + updateCallOverride: () => Promise.resolve(new Uint8Array()), + hardwareWallet: true, + }); + + // @ts-ignore - private function + const spy = jest.spyOn(ledger, "updateFetcher"); + + const expectedRequest = await initExpectedRequest({ to, amount }); + + await ledger.transfer({ + to, + amount, + }); + + expect(spy).toHaveBeenCalledWith({ + // @ts-ignore - private variable + agent: ledger.agent, + // @ts-ignore - private variable + canisterId: ledger.canisterId, + methodName: "send_pb", + arg: expectedRequest.serializeBinary(), + }); + }); + + it("should use custom fee for a transfer", async () => { + const ledger = LedgerCanister.create({ + updateCallOverride: () => Promise.resolve(new Uint8Array()), + hardwareWallet: true, + }); + + // @ts-ignore - private function + const spy = jest.spyOn(ledger, "updateFetcher"); + + const fee = BigInt(990_000); + + const expectedRequest = await initExpectedRequest({ to, amount, fee }); + + await ledger.transfer({ + to, + amount, + fee, + }); + + expect(spy).toHaveBeenCalledWith({ + // @ts-ignore - private variable + agent: ledger.agent, + // @ts-ignore - private variable + canisterId: ledger.canisterId, + methodName: "send_pb", + arg: expectedRequest.serializeBinary(), + }); + }); + + it("should use custom memo for a transfer", async () => { + const ledger = LedgerCanister.create({ + updateCallOverride: () => Promise.resolve(new Uint8Array()), + hardwareWallet: true, + }); + + // @ts-ignore - private function + const spy = jest.spyOn(ledger, "updateFetcher"); + + const memo = BigInt(990_000); + + const expectedRequest = await initExpectedRequest({ to, amount, memo }); + + await ledger.transfer({ + to, + amount, + memo, + }); + + expect(spy).toHaveBeenCalledWith({ + // @ts-ignore - private variable + agent: ledger.agent, + // @ts-ignore - private variable + canisterId: ledger.canisterId, + methodName: "send_pb", + arg: expectedRequest.serializeBinary(), + }); + }); + }); + }); + + describe("icrc1Transfer", () => { + describe("no hardware wallet", () => { + const to = { + owner: Principal.fromHex("abcd"), + subaccount: [] as [], + }; + const amount = BigInt(100000); + + it("fetches transaction fee if not present", async () => { + const service = mock>(); + service.transfer_fee.mockResolvedValue({ + transfer_fee: { e8s: BigInt(10_000) }, + }); + service.icrc1_transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + await ledger.icrc1Transfer({ + to, + amount, + }); + + expect(service.transfer_fee).toBeCalled(); + }); + + it("calls transfer certified service with data", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const fee = BigInt(10_000); + const memo = new Uint8Array([3, 4, 5, 6]); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + await ledger.icrc1Transfer({ + to, + amount, + fee, + memo, + }); + + expect(service.icrc1_transfer).toBeCalledWith({ + to, + fee: [fee], + amount, + memo: [memo], + created_at_time: [], + from_subaccount: [], + }); + }); + + it("sets a default memo if not passed", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const fee = BigInt(10_000); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + await ledger.icrc1Transfer({ + to, + amount, + fee, + }); + + expect(service.icrc1_transfer).toBeCalledWith({ + to, + fee: [fee], + amount, + memo: [], + created_at_time: [], + from_subaccount: [], + }); + }); + + it("handles createdAt parameter", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const fee = BigInt(10_000); + const memo = new Uint8Array([3, 4, 5, 6]); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + const createdAt = BigInt(123132223); + await ledger.icrc1Transfer({ + to, + amount, + fee, + memo, + createdAt, + }); + + expect(service.icrc1_transfer).toBeCalledWith({ + to, + fee: [fee], + amount, + memo: [memo], + created_at_time: [createdAt], + from_subaccount: [], + }); + }); + + it("handles from subaccount", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const fee = BigInt(10_000); + const memo = new Uint8Array(); + const fromSubAccount = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, + ]); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + await ledger.icrc1Transfer({ + to, + amount, + fee, + memo, + fromSubAccount, + }); + + expect(service.icrc1_transfer).toBeCalledWith({ + to, + fee: [fee], + amount, + memo: [memo], + created_at_time: [], + from_subaccount: [fromSubAccount], + }); + }); + + it("handles to subaccount", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const fee = BigInt(10_000); + const memo = new Uint8Array(); + const toSubAccount = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, + ]); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + }); + await ledger.icrc1Transfer({ + to: { + ...to, + subaccount: [toSubAccount], + }, + amount, + fee, + memo, + }); + + expect(service.icrc1_transfer).toBeCalledWith({ + to: { + ...to, + subaccount: [toSubAccount], + }, + fee: [fee], + amount, + memo: [memo], + created_at_time: [], + from_subaccount: [], + }); + }); + + it("handles duplicate transaction", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Err: { + Duplicate: { + duplicate_of: BigInt(10), + }, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.icrc1Transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(TxDuplicateError); + }); + + it("handles insufficient balance", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Err: { + InsufficientFunds: { + balance: BigInt(12312414), + }, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.icrc1Transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(InsufficientFundsError); + }); + + it("handles old tx", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Err: { + TooOld: null, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.icrc1Transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(TxTooOldError); + }); + + it("handles bad fee", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Err: { + BadFee: { + expected_fee: BigInt(1234), + }, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.icrc1Transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(BadFeeError); + }); + + it("handles transaction created in the future", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Err: { + CreatedInFuture: { ledger_time: BigInt(1234) }, + }, + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + }); + const call = async () => + await ledger.icrc1Transfer({ + to, + amount, + fee: BigInt(10_000), + }); + + expect(call).rejects.toThrowError(TxCreatedInFutureError); + }); + }); + + describe("for hardware wallet", () => { + const to = { + owner: Principal.fromHex("abcd"), + subaccount: [] as [], + }; + const amount = BigInt(100000); + + it("should set a default fee for a transfer", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + hardwareWallet: true, + }); + + const memo = new Uint8Array(); + await ledger.icrc1Transfer({ + to, + amount, + memo, + }); + + expect(service.transfer_fee).not.toBeCalled(); + + expect(service.icrc1_transfer).toBeCalledWith({ + to, + fee: [BigInt(10000)], + amount, + memo: [memo], + created_at_time: [], + from_subaccount: [], + }); + }); + + it("should use custom fee for a transfer", async () => { + const service = mock>(); + service.icrc1_transfer.mockResolvedValue({ + Ok: BigInt(1234), + }); + const ledger = LedgerCanister.create({ + certifiedServiceOverride: service, + serviceOverride: service, + hardwareWallet: true, + }); + + const fee = BigInt(990_000); + const memo = new Uint8Array(); + await ledger.icrc1Transfer({ + to, + amount, + fee, + memo, + }); + + expect(service.transfer_fee).not.toBeCalled(); + + expect(service.icrc1_transfer).toBeCalledWith({ + to, + fee: [fee], + amount, + memo: [memo], + created_at_time: [], + from_subaccount: [], + }); + }); + }); + }); +}); diff --git a/packages/ledger-icp/src/ledger.canister.ts b/packages/ledger-icp/src/ledger.canister.ts index cb0ff5c3..095352d9 100644 --- a/packages/ledger-icp/src/ledger.canister.ts +++ b/packages/ledger-icp/src/ledger.canister.ts @@ -1 +1,236 @@ -export {}; +import type { ActorSubclass, Agent } from "@dfinity/agent"; +import type { Principal } from "@dfinity/principal"; +import { createServices } from "@dfinity/utils"; +import type { _SERVICE as LedgerService } from "../candid/ledger"; +import { idlFactory as certifiedIdlFactory } from "../candid/ledger.certified.idl"; +import { idlFactory } from "../candid/ledger.idl"; +import type { AccountIdentifier } from "./account_identifier"; +import { + subAccountNumbersToSubaccount, + toICPTs, + toIcrc1TransferRawRequest, + toTransferRawRequest, +} from "./canisters/ledger/ledger.request.converts"; +import { MAINNET_LEDGER_CANISTER_ID } from "./constants/canister_ids"; +import { TRANSACTION_FEE } from "./constants/constants"; +import { + mapIcrc1TransferError, + mapTransferError, + mapTransferProtoError, +} from "./errors/ledger.errors"; +import type { BlockHeight } from "./types/common"; +import type { + LedgerCanisterCall, + LedgerCanisterOptions, +} from "./types/ledger.options"; +import type { + Icrc1TransferRequest, + TransferRequest, +} from "./types/ledger_converters"; +import { importNnsProto, queryCall, updateCall } from "./utils/proto.utils"; + +export class LedgerCanister { + private constructor( + private readonly agent: Agent, + private readonly canisterId: Principal, + private readonly service: ActorSubclass, + private readonly certifiedService: ActorSubclass, + private readonly updateFetcher: LedgerCanisterCall, + private readonly queryFetcher: LedgerCanisterCall, + private readonly hardwareWallet: boolean = false, + ) {} + + public static create(options: LedgerCanisterOptions = {}) { + const canisterId: Principal = + options.canisterId ?? MAINNET_LEDGER_CANISTER_ID; + + const { service, certifiedService, agent } = createServices({ + options: { + ...options, + canisterId, + }, + idlFactory, + certifiedIdlFactory, + }); + + return new LedgerCanister( + agent, + canisterId, + service, + certifiedService, + options.updateCallOverride ?? updateCall, + options.queryCallOverride ?? queryCall, + options.hardwareWallet, + ); + } + + /** + * Returns the balance of the specified account identifier. + * + * If `certified` is true, the request is fetched as an update call, otherwise + * it is fetched using a query call. + * + * @throws {@link Error} + */ + public accountBalance = async ({ + accountIdentifier, + certified = true, + }: { + accountIdentifier: AccountIdentifier; + certified?: boolean; + }): Promise => { + if (this.hardwareWallet) { + return this.accountBalanceHardwareWallet({ + accountIdentifier, + certified, + }); + } + const service = certified ? this.certifiedService : this.service; + const tokens = await service.account_balance({ + account: accountIdentifier.toUint8Array(), + }); + return tokens.e8s; + }; + + /** + * Returns the transaction fee of the ledger canister + * @returns {BigInt} + */ + public transactionFee = async () => { + const { + transfer_fee: { e8s }, + } = await this.service.transfer_fee({}); + return e8s; + }; + + /** + * Transfer ICP from the caller to the destination `accountIdentifier`. + * Returns the index of the block containing the tx if it was successful. + * + * @throws {@link TransferError} + */ + public transfer = async (request: TransferRequest): Promise => { + if (this.hardwareWallet) { + return this.transferHardwareWallet(request); + } + // When candid is implemented, the previous lines will go away. + // But the transaction fee method is not supported by Ledger App yet. + if (request.fee === undefined) { + request.fee = this.hardwareWallet + ? TRANSACTION_FEE + : await this.transactionFee(); + } + const rawRequest = toTransferRawRequest(request); + const response = await this.certifiedService.transfer(rawRequest); + if ("Err" in response) { + throw mapTransferError(response.Err); + } + return response.Ok; + }; + + /** + * Transfer ICP from the caller to the destination `Account`. + * Returns the index of the block containing the tx if it was successful. + * + * @throws {@link TransferError} + */ + public icrc1Transfer = async ( + request: Icrc1TransferRequest, + ): Promise => { + // The transaction fee method is not supported by Ledger App yet. + if (request.fee === undefined) { + request.fee = this.hardwareWallet + ? TRANSACTION_FEE + : await this.transactionFee(); + } + const rawRequest = toIcrc1TransferRawRequest(request); + const response = await this.certifiedService.icrc1_transfer(rawRequest); + if ("Err" in response) { + throw mapIcrc1TransferError(response.Err); + } + return response.Ok; + }; + + private accountBalanceHardwareWallet = async ({ + accountIdentifier, + certified = true, + }: { + accountIdentifier: AccountIdentifier; + certified?: boolean; + }): Promise => { + const callMethod = certified ? this.updateFetcher : this.queryFetcher; + + const { AccountBalanceRequest: AccountBalanceRequestConstructor, ICPTs } = + await importNnsProto(); + + const request = new AccountBalanceRequestConstructor(); + request.setAccount(await accountIdentifier.toProto()); + + const responseBytes = await callMethod({ + agent: this.agent, + canisterId: this.canisterId, + methodName: "account_balance_pb", + arg: request.serializeBinary(), + }); + + return BigInt( + ICPTs.deserializeBinary(new Uint8Array(responseBytes)).getE8s(), + ); + }; + + private transferHardwareWallet = async ({ + to, + amount, + memo, + fee, + fromSubAccount, + createdAt, + }: TransferRequest): Promise => { + const { SendRequest, Payment, Memo, TimeStamp, BlockHeight } = + await importNnsProto(); + + const request = new SendRequest(); + request.setTo(await to.toProto()); + + const payment = new Payment(); + payment.setReceiverGets(await toICPTs(amount)); + request.setPayment(payment); + + request.setMaxFee(await toICPTs(fee ?? TRANSACTION_FEE)); + + // Always explicitly set the memo for compatibility with ledger wallet - hardware wallet + const requestMemo = new Memo(); + requestMemo.setMemo((memo ?? BigInt(0)).toString()); + request.setMemo(requestMemo); + + if (createdAt !== undefined) { + const timestamp = new TimeStamp(); + timestamp.setTimestampNanos(createdAt.toString()); + request.setCreatedAtTime(timestamp); + } + + if (fromSubAccount !== undefined) { + request.setFromSubaccount( + await subAccountNumbersToSubaccount(fromSubAccount), + ); + } + + try { + const responseBytes = await this.updateFetcher({ + agent: this.agent, + canisterId: this.canisterId, + methodName: "send_pb", + arg: request.serializeBinary(), + }); + + // Successful tx. Return the block height. + return BigInt(BlockHeight.deserializeBinary(responseBytes).getHeight()); + } catch (err) { + if (err instanceof Error) { + throw mapTransferProtoError(err); + } + + throw err; + } + }; +} diff --git a/packages/ledger-icp/src/types/common.ts b/packages/ledger-icp/src/types/common.ts new file mode 100644 index 00000000..be3040b3 --- /dev/null +++ b/packages/ledger-icp/src/types/common.ts @@ -0,0 +1,3 @@ +export type AccountIdentifier = string; +export type BlockHeight = bigint; +export type E8s = bigint; diff --git a/packages/nns/src/types/ledger.options.ts b/packages/ledger-icp/src/types/ledger.options.ts similarity index 100% rename from packages/nns/src/types/ledger.options.ts rename to packages/ledger-icp/src/types/ledger.options.ts diff --git a/packages/nns/src/types/ledger_converters.ts b/packages/ledger-icp/src/types/ledger_converters.ts similarity index 100% rename from packages/nns/src/types/ledger_converters.ts rename to packages/ledger-icp/src/types/ledger_converters.ts diff --git a/packages/ledger-icp/src/utils/proto.utils.ts b/packages/ledger-icp/src/utils/proto.utils.ts new file mode 100644 index 00000000..2b3a2522 --- /dev/null +++ b/packages/ledger-icp/src/utils/proto.utils.ts @@ -0,0 +1,87 @@ +import type { Agent } from "@dfinity/agent"; +import { polling } from "@dfinity/agent"; +import type { Principal } from "@dfinity/principal"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +type ModuleType = typeof import("@dfinity/nns-proto"); +export const importNnsProto = (): Promise => + import("@dfinity/nns-proto"); + +/** + * Submits an update call to the IC. + * @returns The (binary) response if the request succeeded, an error otherwise. + */ +export const updateCall = async ({ + agent, + canisterId, + methodName, + arg, +}: { + agent: Agent; + canisterId: Principal; + methodName: string; + arg: ArrayBuffer; +}): Promise => { + const submitResponse = await agent.call(canisterId, { + methodName, + arg, + effectiveCanisterId: canisterId, + }); + + if (!submitResponse.response.ok) { + throw new Error( + [ + "Call failed:", + ` Method: ${methodName}`, + ` Canister ID: ${canisterId}`, + ` Request ID: ${submitResponse.requestId}`, + ` HTTP status code: ${submitResponse.response.status}`, + ` HTTP status text: ${submitResponse.response.statusText}`, + ].join("\n"), + ); + } + + const blob = await polling.pollForResponse( + agent, + canisterId, + submitResponse.requestId, + polling.defaultStrategy(), + ); + + return new Uint8Array(blob); +}; + +/** + * Submits a query call to the IC. + * @returns The (binary) response if the request succeeded, an error otherwise. + */ +export const queryCall = async ({ + agent, + canisterId, + methodName, + arg, +}: { + agent: Agent; + canisterId: Principal; + methodName: string; + arg: ArrayBuffer; +}): Promise => { + const queryResponse = await agent.query(canisterId, { + methodName, + arg, + }); + + if (queryResponse.status == "rejected") { + throw new Error( + [ + "Call failed:", + ` Method: ${methodName}`, + ` Canister ID: ${canisterId}`, + ` HTTP status code: ${queryResponse.reject_code}`, + ` HTTP status text: ${queryResponse.reject_message}`, + ].join("\n"), + ); + } + + return new Uint8Array(queryResponse.reply.arg); +}; diff --git a/packages/nns/package.json b/packages/nns/package.json index c0dfb2c3..25136529 100644 --- a/packages/nns/package.json +++ b/packages/nns/package.json @@ -53,6 +53,7 @@ "peerDependencies": { "@dfinity/agent": "^0.19.2", "@dfinity/candid": "^0.19.2", + "@dfinity/ledger-icp": "^0.0.1", "@dfinity/nns-proto": "^0.0.9", "@dfinity/principal": "^0.19.2", "@dfinity/utils": "^0.0.23" diff --git a/packages/nns/src/canisters/governance/request.converters.ts b/packages/nns/src/canisters/governance/request.converters.ts index fbda50ec..abe9e1e1 100644 --- a/packages/nns/src/canisters/governance/request.converters.ts +++ b/packages/nns/src/canisters/governance/request.converters.ts @@ -1,3 +1,4 @@ +import type { AccountIdentifier as AccountIdentifierClass } from "@dfinity/ledger-icp"; import { Principal } from "@dfinity/principal"; import { arrayBufferToUint8Array, toNullable } from "@dfinity/utils"; import type { @@ -33,7 +34,6 @@ import type { Tokens as RawTokens, VotingRewardParameters as RawVotingRewardParameters, } from "../../../candid/governance"; -import type { AccountIdentifier as AccountIdentifierClass } from "../../account_identifier"; import type { Vote } from "../../enums/governance.enums"; import { UnsupportedValueError } from "../../errors/governance.errors"; import type { AccountIdentifier, E8s, NeuronId } from "../../types/common"; diff --git a/packages/nns/src/canisters/governance/response.converters.ts b/packages/nns/src/canisters/governance/response.converters.ts index 91322846..8c2de269 100644 --- a/packages/nns/src/canisters/governance/response.converters.ts +++ b/packages/nns/src/canisters/governance/response.converters.ts @@ -1,3 +1,4 @@ +import { AccountIdentifier, SubAccount } from "@dfinity/ledger-icp"; import type { ListNeuronsResponse, BallotInfo as PbBallotInfo, @@ -54,7 +55,6 @@ import type { Tokens as RawTokens, VotingRewardParameters as RawVotingRewardParameters, } from "../../../candid/governance"; -import { AccountIdentifier, SubAccount } from "../../account_identifier"; import { NeuronState } from "../../enums/governance.enums"; import { UnsupportedValueError } from "../../errors/governance.errors"; import type { diff --git a/packages/nns/src/constants/canister_ids.ts b/packages/nns/src/constants/canister_ids.ts index a97d457a..937889d0 100644 --- a/packages/nns/src/constants/canister_ids.ts +++ b/packages/nns/src/constants/canister_ids.ts @@ -8,10 +8,6 @@ export const MAINNET_GOVERNANCE_CANISTER_ID = Principal.fromText( "rrkah-fqaaa-aaaaa-aaaaq-cai", ); -export const MAINNET_LEDGER_CANISTER_ID = Principal.fromText( - "ryjl3-tyaaa-aaaaa-aaaba-cai", -); - export const MAINNET_GENESIS_TOKEN_CANISTER_ID = Principal.fromText( "renrk-eyaaa-aaaaa-aaada-cai", ); diff --git a/packages/nns/src/governance.canister.spec.ts b/packages/nns/src/governance.canister.spec.ts index b4a4588f..bdb431ac 100644 --- a/packages/nns/src/governance.canister.spec.ts +++ b/packages/nns/src/governance.canister.spec.ts @@ -32,7 +32,7 @@ import { UnrecognizedTypeError, } from "./errors/governance.errors"; import { GovernanceCanister } from "./governance.canister"; -import { LedgerCanister } from "./ledger.canister"; +import { LedgerCanister } from "@dfinity/ledger-icp"; import { mockListNeuronsResponse, mockNeuron, diff --git a/packages/nns/src/governance.canister.ts b/packages/nns/src/governance.canister.ts index f6d7408a..ede3321c 100644 --- a/packages/nns/src/governance.canister.ts +++ b/packages/nns/src/governance.canister.ts @@ -1,4 +1,7 @@ import type { ActorSubclass, Agent } from "@dfinity/agent"; +import type { LedgerCanister } from "@dfinity/ledger-icp"; +import { AccountIdentifier, SubAccount } from "@dfinity/ledger-icp"; +import { E8S_PER_TOKEN } from "@dfinity/ledger-icp/src/constants/constants"; import type { ManageNeuron as PbManageNeuron } from "@dfinity/nns-proto"; import type { Principal } from "@dfinity/principal"; import { @@ -25,7 +28,6 @@ import type { } from "../candid/governance"; import { idlFactory as certifiedIdlFactory } from "../candid/governance.certified.idl"; import { idlFactory } from "../candid/governance.idl"; -import { AccountIdentifier, SubAccount } from "./account_identifier"; import { fromClaimOrRefreshNeuronRequest, fromListNeurons, @@ -75,7 +77,6 @@ import { simulateManageNeuron, } from "./canisters/governance/services"; import { MAINNET_GOVERNANCE_CANISTER_ID } from "./constants/canister_ids"; -import { E8S_PER_TOKEN } from "./constants/constants"; import type { Vote } from "./enums/governance.enums"; import { CouldNotClaimNeuronError, @@ -84,7 +85,6 @@ import { InsufficientAmountError, UnrecognizedTypeError, } from "./errors/governance.errors"; -import type { LedgerCanister } from "./ledger.canister"; import type { E8s, NeuronId } from "./types/common"; import type { GovernanceCanisterOptions } from "./types/governance.options"; import type { diff --git a/packages/nns/src/index.ts b/packages/nns/src/index.ts index a9506dbc..e8194126 100644 --- a/packages/nns/src/index.ts +++ b/packages/nns/src/index.ts @@ -1,18 +1,14 @@ export type { RewardEvent } from "../candid/governance"; export type { DeployedSns } from "../candid/sns_wasm"; -export { AccountIdentifier, SubAccount } from "./account_identifier"; export * from "./enums/governance.enums"; export * from "./errors/governance.errors"; -export * from "./errors/ledger.errors"; export { GenesisTokenCanister } from "./genesis_token.canister"; export { GovernanceCanister } from "./governance.canister"; export { ICP } from "./icp"; -export { LedgerCanister } from "./ledger.canister"; export { SnsWasmCanister } from "./sns_wasm.canister"; export * from "./types/common"; export * from "./types/governance.options"; export * from "./types/governance_converters"; -export * from "./types/ledger.options"; export type { SnsWasmCanisterOptions } from "./types/sns_wasm.options"; export * from "./utils/account_identifier.utils"; export * from "./utils/accounts.utils"; diff --git a/packages/nns/src/ledger.canister.spec.ts b/packages/nns/src/ledger.canister.spec.ts deleted file mode 100644 index 65bb98fd..00000000 --- a/packages/nns/src/ledger.canister.spec.ts +++ /dev/null @@ -1,980 +0,0 @@ -import { ActorSubclass } from "@dfinity/agent"; -import { Memo, Payment, SendRequest } from "@dfinity/nns-proto"; -import { Principal } from "@dfinity/principal"; -import { arrayOfNumberToUint8Array } from "@dfinity/utils"; -import { mock } from "jest-mock-extended"; -import type { _SERVICE as LedgerService } from "../candid/ledger"; -import { AccountIdentifier } from "./account_identifier"; -import { toICPTs } from "./canisters/ledger/ledger.request.converts"; -import { TRANSACTION_FEE } from "./constants/constants"; -import { - BadFeeError, - InsufficientFundsError, - InvalidSenderError, - TxCreatedInFutureError, - TxDuplicateError, - TxTooOldError, -} from "./errors/ledger.errors"; -import { LedgerCanister } from "./ledger.canister"; -import { E8s } from "./types/common"; - -describe("LedgerCanister", () => { - const accountIdentifier = AccountIdentifier.fromHex( - "3e8bbceef8b9338e56a1b561a127326e6614894ab9b0739df4cc3664d40a5958", - ); - describe("accountBalance", () => { - describe("no hardware wallet", () => { - const tokens = { - e8s: BigInt(30_000_000), - }; - it("returns account balance with query call", async () => { - const service = mock>(); - service.account_balance.mockResolvedValue(tokens); - const ledger = LedgerCanister.create({ - serviceOverride: service, - }); - - const balance = await ledger.accountBalance({ - accountIdentifier, - certified: false, - }); - expect(balance).toEqual(tokens.e8s); - expect(service.account_balance).toBeCalled(); - }); - - it("returns account balance with update call", async () => { - const service = mock>(); - service.account_balance.mockResolvedValue(tokens); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - - const balance = await ledger.accountBalance({ - accountIdentifier, - certified: true, - }); - expect(balance).toEqual(tokens.e8s); - expect(service.account_balance).toBeCalled(); - }); - }); - - describe("transactionFee", () => { - it("returns the transaction fee in e8s", async () => { - const fee = BigInt(10_000); - const service = mock>(); - service.transfer_fee.mockResolvedValue({ - transfer_fee: { - e8s: fee, - }, - }); - const ledger = LedgerCanister.create({ - serviceOverride: service, - }); - - const expectedFee = await ledger.transactionFee(); - expect(service.transfer_fee).toBeCalled(); - expect(expectedFee).toBe(fee); - }); - }); - - describe("for hardware wallet", () => { - it("returns account balance with query call", async () => { - const queryFetcher = jest - .fn() - .mockResolvedValue(new Uint8Array(32).fill(0)); - const ledger = LedgerCanister.create({ - queryCallOverride: queryFetcher, - hardwareWallet: true, - }); - const balance = await ledger.accountBalance({ - accountIdentifier, - certified: false, - }); - expect(typeof balance).toEqual("bigint"); - expect(queryFetcher).toBeCalled(); - }); - - it("returns account balance with update call", async () => { - const updateFetcher = jest - .fn() - .mockResolvedValue(new Uint8Array(32).fill(0)); - const ledger = LedgerCanister.create({ - updateCallOverride: updateFetcher, - hardwareWallet: true, - }); - const balance = await ledger.accountBalance({ - accountIdentifier, - certified: true, - }); - expect(typeof balance).toEqual("bigint"); - expect(updateFetcher).toBeCalled(); - }); - }); - }); - - describe("transfer", () => { - describe("no hardware wallet", () => { - const to = accountIdentifier; - const amount = BigInt(100000); - - it("fetches transaction fee if not present", async () => { - const service = mock>(); - service.transfer_fee.mockResolvedValue({ - transfer_fee: { e8s: BigInt(10_000) }, - }); - service.transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - await ledger.transfer({ - to, - amount, - }); - - expect(service.transfer_fee).toBeCalled(); - }); - - it("calls transfer certified service with data", async () => { - const service = mock>(); - service.transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const fee = BigInt(10_000); - const memo = BigInt(3456); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - await ledger.transfer({ - to, - amount, - fee, - memo, - }); - - expect(service.transfer).toBeCalledWith({ - to: to.toUint8Array(), - fee: { - e8s: fee, - }, - amount: { - e8s: amount, - }, - memo, - created_at_time: [], - from_subaccount: [], - }); - }); - - it("sets a default memo if not passed", async () => { - const service = mock>(); - service.transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const fee = BigInt(10_000); - const defaultMemo = BigInt(0); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - await ledger.transfer({ - to, - amount, - fee, - }); - - expect(service.transfer).toBeCalledWith({ - to: to.toUint8Array(), - fee: { - e8s: fee, - }, - amount: { - e8s: amount, - }, - memo: defaultMemo, - created_at_time: [], - from_subaccount: [], - }); - }); - - it("handles createdAt parameter", async () => { - const service = mock>(); - service.transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const fee = BigInt(10_000); - const memo = BigInt(3456); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - const createdAt = BigInt(123132223); - await ledger.transfer({ - to, - amount, - fee, - memo, - createdAt, - }); - - expect(service.transfer).toBeCalledWith({ - to: to.toUint8Array(), - fee: { - e8s: fee, - }, - amount: { - e8s: amount, - }, - memo, - created_at_time: [{ timestamp_nanos: createdAt }], - from_subaccount: [], - }); - }); - - it("handles subaccount", async () => { - const service = mock>(); - service.transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const fee = BigInt(10_000); - const memo = BigInt(0); - const fromSubAccount = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, - ]; - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - await ledger.transfer({ - to, - amount, - fee, - memo, - fromSubAccount, - }); - - expect(service.transfer).toBeCalledWith({ - to: to.toUint8Array(), - fee: { - e8s: fee, - }, - amount: { - e8s: amount, - }, - memo, - created_at_time: [], - from_subaccount: [arrayOfNumberToUint8Array(fromSubAccount)], - }); - }); - - it("handles duplicate transaction", async () => { - const service = mock>(); - service.transfer.mockResolvedValue({ - Err: { - TxDuplicate: { - duplicate_of: BigInt(10), - }, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(TxDuplicateError); - }); - - it("handles insufficient balance", async () => { - const service = mock>(); - service.transfer.mockResolvedValue({ - Err: { - InsufficientFunds: { - balance: { - e8s: BigInt(12312414), - }, - }, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(InsufficientFundsError); - }); - - it("handles old tx", async () => { - const service = mock>(); - service.transfer.mockResolvedValue({ - Err: { - TxTooOld: { - allowed_window_nanos: BigInt(1234), - }, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(TxTooOldError); - }); - - it("handles bad fee", async () => { - const service = mock>(); - service.transfer.mockResolvedValue({ - Err: { - BadFee: { - expected_fee: { - e8s: BigInt(1234), - }, - }, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(BadFeeError); - }); - - it("handles transaction created in the future", async () => { - const service = mock>(); - service.transfer.mockResolvedValue({ - Err: { - TxCreatedInFuture: null, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(TxCreatedInFutureError); - }); - }); - - describe("for hardware wallet", () => { - const to = accountIdentifier; - const amount = BigInt(100000); - - it("handles invalid sender", async () => { - const ledger = LedgerCanister.create({ - updateCallOverride: () => { - throw new Error(`Reject code: 5 - Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'Sending from 2vxsx-fae is not allowed', rosetta-api/ledger_canister/src/main.rs:135:9`); - }, - hardwareWallet: true, - }); - - const call = async () => - await ledger.transfer({ - to, - amount, - }); - - await expect(call).rejects.toThrow(new InvalidSenderError()); - }); - - it("handles duplicate transaction", async () => { - const ledger = LedgerCanister.create({ - updateCallOverride: () => { - throw new Error(`Reject code: 5 - Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'transaction is a duplicate of another transaction in block 1235123', rosetta-api/ledger_canister/src/main.rs:135:9`); - }, - hardwareWallet: true, - }); - - const call = async () => - await ledger.transfer({ - to, - amount, - }); - - await expect(call).rejects.toThrow( - new TxDuplicateError(BigInt(1235123)), - ); - }); - - it("handles insufficient balance", async () => { - const ledger = LedgerCanister.create({ - updateCallOverride: () => { - throw new Error(`Reject code: 5 - Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'the debit account doesn't have enough funds to complete the transaction, current balance: 123.46789123', rosetta-api/ledger_canister/src/main.rs:135:9`); - }, - hardwareWallet: true, - }); - - const call = async () => - await ledger.transfer({ - to, - amount, - }); - - await expect(call).rejects.toThrow( - new InsufficientFundsError(BigInt(12346789123)), - ); - }); - - it("handles future tx", async () => { - const ledger = LedgerCanister.create({ - updateCallOverride: () => { - throw new Error(`Reject code: 5 - Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'transaction's created_at_time is in future', rosetta-api/ledger_canister/src/main.rs:135:9`); - }, - hardwareWallet: true, - }); - - const call = async () => - await ledger.transfer({ - to, - amount, - }); - - await expect(call).rejects.toThrow(new TxCreatedInFutureError()); - }); - - it("handles old tx", async () => { - const ledger = LedgerCanister.create({ - updateCallOverride: () => { - throw new Error(`Reject code: 5 - Reject text: Canister ryjl3-tyaaa-aaaaa-aaaba-cai trapped explicitly: Panicked at 'transaction is older than 123 seconds', rosetta-api/ledger_canister/src/main.rs:135:9`); - }, - hardwareWallet: true, - }); - - const call = async () => - await ledger.transfer({ - to, - amount, - }); - - await expect(call).rejects.toThrow(new TxTooOldError(123)); - }); - - it("handles subaccount for hw", async () => { - const ledger = LedgerCanister.create({ - updateCallOverride: jest - .fn() - .mockResolvedValue(new Uint8Array(32).fill(0)), - hardwareWallet: true, - }); - const fromSubAccount = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, - ]; - - const res = await ledger.transfer({ - to, - amount, - fromSubAccount, - }); - - expect(typeof res).toEqual("bigint"); - }); - - const initExpectedRequest = async ({ - to, - amount, - memo, - fee, - }: { - to: AccountIdentifier; - amount: bigint; - memo?: bigint; - fee?: E8s; - }): Promise => { - const expectedRequest = new SendRequest(); - expectedRequest.setTo(await to.toProto()); - - const payment = new Payment(); - payment.setReceiverGets(await toICPTs(amount)); - expectedRequest.setPayment(payment); - - const requestMemo = new Memo(); - requestMemo.setMemo((memo ?? BigInt(0)).toString()); - expectedRequest.setMemo(requestMemo); - - expectedRequest.setMaxFee(await toICPTs(fee ?? TRANSACTION_FEE)); - - return expectedRequest; - }; - - it("should set a default fee for a transfer", async () => { - const ledger = LedgerCanister.create({ - updateCallOverride: () => Promise.resolve(new Uint8Array()), - hardwareWallet: true, - }); - - // @ts-ignore - private function - const spy = jest.spyOn(ledger, "updateFetcher"); - - const expectedRequest = await initExpectedRequest({ to, amount }); - - await ledger.transfer({ - to, - amount, - }); - - expect(spy).toHaveBeenCalledWith({ - // @ts-ignore - private variable - agent: ledger.agent, - // @ts-ignore - private variable - canisterId: ledger.canisterId, - methodName: "send_pb", - arg: expectedRequest.serializeBinary(), - }); - }); - - it("should use custom fee for a transfer", async () => { - const ledger = LedgerCanister.create({ - updateCallOverride: () => Promise.resolve(new Uint8Array()), - hardwareWallet: true, - }); - - // @ts-ignore - private function - const spy = jest.spyOn(ledger, "updateFetcher"); - - const fee = BigInt(990_000); - - const expectedRequest = await initExpectedRequest({ to, amount, fee }); - - await ledger.transfer({ - to, - amount, - fee, - }); - - expect(spy).toHaveBeenCalledWith({ - // @ts-ignore - private variable - agent: ledger.agent, - // @ts-ignore - private variable - canisterId: ledger.canisterId, - methodName: "send_pb", - arg: expectedRequest.serializeBinary(), - }); - }); - - it("should use custom memo for a transfer", async () => { - const ledger = LedgerCanister.create({ - updateCallOverride: () => Promise.resolve(new Uint8Array()), - hardwareWallet: true, - }); - - // @ts-ignore - private function - const spy = jest.spyOn(ledger, "updateFetcher"); - - const memo = BigInt(990_000); - - const expectedRequest = await initExpectedRequest({ to, amount, memo }); - - await ledger.transfer({ - to, - amount, - memo, - }); - - expect(spy).toHaveBeenCalledWith({ - // @ts-ignore - private variable - agent: ledger.agent, - // @ts-ignore - private variable - canisterId: ledger.canisterId, - methodName: "send_pb", - arg: expectedRequest.serializeBinary(), - }); - }); - }); - }); - - describe("icrc1Transfer", () => { - describe("no hardware wallet", () => { - const to = { - owner: Principal.fromHex("abcd"), - subaccount: [] as [], - }; - const amount = BigInt(100000); - - it("fetches transaction fee if not present", async () => { - const service = mock>(); - service.transfer_fee.mockResolvedValue({ - transfer_fee: { e8s: BigInt(10_000) }, - }); - service.icrc1_transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - await ledger.icrc1Transfer({ - to, - amount, - }); - - expect(service.transfer_fee).toBeCalled(); - }); - - it("calls transfer certified service with data", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const fee = BigInt(10_000); - const memo = new Uint8Array([3, 4, 5, 6]); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - await ledger.icrc1Transfer({ - to, - amount, - fee, - memo, - }); - - expect(service.icrc1_transfer).toBeCalledWith({ - to, - fee: [fee], - amount, - memo: [memo], - created_at_time: [], - from_subaccount: [], - }); - }); - - it("sets a default memo if not passed", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const fee = BigInt(10_000); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - await ledger.icrc1Transfer({ - to, - amount, - fee, - }); - - expect(service.icrc1_transfer).toBeCalledWith({ - to, - fee: [fee], - amount, - memo: [], - created_at_time: [], - from_subaccount: [], - }); - }); - - it("handles createdAt parameter", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const fee = BigInt(10_000); - const memo = new Uint8Array([3, 4, 5, 6]); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - const createdAt = BigInt(123132223); - await ledger.icrc1Transfer({ - to, - amount, - fee, - memo, - createdAt, - }); - - expect(service.icrc1_transfer).toBeCalledWith({ - to, - fee: [fee], - amount, - memo: [memo], - created_at_time: [createdAt], - from_subaccount: [], - }); - }); - - it("handles from subaccount", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const fee = BigInt(10_000); - const memo = new Uint8Array(); - const fromSubAccount = new Uint8Array([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, - ]); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - await ledger.icrc1Transfer({ - to, - amount, - fee, - memo, - fromSubAccount, - }); - - expect(service.icrc1_transfer).toBeCalledWith({ - to, - fee: [fee], - amount, - memo: [memo], - created_at_time: [], - from_subaccount: [fromSubAccount], - }); - }); - - it("handles to subaccount", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const fee = BigInt(10_000); - const memo = new Uint8Array(); - const toSubAccount = new Uint8Array([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, - ]); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - }); - await ledger.icrc1Transfer({ - to: { - ...to, - subaccount: [toSubAccount], - }, - amount, - fee, - memo, - }); - - expect(service.icrc1_transfer).toBeCalledWith({ - to: { - ...to, - subaccount: [toSubAccount], - }, - fee: [fee], - amount, - memo: [memo], - created_at_time: [], - from_subaccount: [], - }); - }); - - it("handles duplicate transaction", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Err: { - Duplicate: { - duplicate_of: BigInt(10), - }, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.icrc1Transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(TxDuplicateError); - }); - - it("handles insufficient balance", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Err: { - InsufficientFunds: { - balance: BigInt(12312414), - }, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.icrc1Transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(InsufficientFundsError); - }); - - it("handles old tx", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Err: { - TooOld: null, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.icrc1Transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(TxTooOldError); - }); - - it("handles bad fee", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Err: { - BadFee: { - expected_fee: BigInt(1234), - }, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.icrc1Transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(BadFeeError); - }); - - it("handles transaction created in the future", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Err: { - CreatedInFuture: { ledger_time: BigInt(1234) }, - }, - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - }); - const call = async () => - await ledger.icrc1Transfer({ - to, - amount, - fee: BigInt(10_000), - }); - - expect(call).rejects.toThrowError(TxCreatedInFutureError); - }); - }); - - describe("for hardware wallet", () => { - const to = { - owner: Principal.fromHex("abcd"), - subaccount: [] as [], - }; - const amount = BigInt(100000); - - it("should set a default fee for a transfer", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - hardwareWallet: true, - }); - - const memo = new Uint8Array(); - await ledger.icrc1Transfer({ - to, - amount, - memo, - }); - - expect(service.transfer_fee).not.toBeCalled(); - - expect(service.icrc1_transfer).toBeCalledWith({ - to, - fee: [BigInt(10000)], - amount, - memo: [memo], - created_at_time: [], - from_subaccount: [], - }); - }); - - it("should use custom fee for a transfer", async () => { - const service = mock>(); - service.icrc1_transfer.mockResolvedValue({ - Ok: BigInt(1234), - }); - const ledger = LedgerCanister.create({ - certifiedServiceOverride: service, - serviceOverride: service, - hardwareWallet: true, - }); - - const fee = BigInt(990_000); - const memo = new Uint8Array(); - await ledger.icrc1Transfer({ - to, - amount, - fee, - memo, - }); - - expect(service.transfer_fee).not.toBeCalled(); - - expect(service.icrc1_transfer).toBeCalledWith({ - to, - fee: [fee], - amount, - memo: [memo], - created_at_time: [], - from_subaccount: [], - }); - }); - }); - }); -}); diff --git a/packages/nns/src/ledger.canister.ts b/packages/nns/src/ledger.canister.ts deleted file mode 100644 index 095352d9..00000000 --- a/packages/nns/src/ledger.canister.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { ActorSubclass, Agent } from "@dfinity/agent"; -import type { Principal } from "@dfinity/principal"; -import { createServices } from "@dfinity/utils"; -import type { _SERVICE as LedgerService } from "../candid/ledger"; -import { idlFactory as certifiedIdlFactory } from "../candid/ledger.certified.idl"; -import { idlFactory } from "../candid/ledger.idl"; -import type { AccountIdentifier } from "./account_identifier"; -import { - subAccountNumbersToSubaccount, - toICPTs, - toIcrc1TransferRawRequest, - toTransferRawRequest, -} from "./canisters/ledger/ledger.request.converts"; -import { MAINNET_LEDGER_CANISTER_ID } from "./constants/canister_ids"; -import { TRANSACTION_FEE } from "./constants/constants"; -import { - mapIcrc1TransferError, - mapTransferError, - mapTransferProtoError, -} from "./errors/ledger.errors"; -import type { BlockHeight } from "./types/common"; -import type { - LedgerCanisterCall, - LedgerCanisterOptions, -} from "./types/ledger.options"; -import type { - Icrc1TransferRequest, - TransferRequest, -} from "./types/ledger_converters"; -import { importNnsProto, queryCall, updateCall } from "./utils/proto.utils"; - -export class LedgerCanister { - private constructor( - private readonly agent: Agent, - private readonly canisterId: Principal, - private readonly service: ActorSubclass, - private readonly certifiedService: ActorSubclass, - private readonly updateFetcher: LedgerCanisterCall, - private readonly queryFetcher: LedgerCanisterCall, - private readonly hardwareWallet: boolean = false, - ) {} - - public static create(options: LedgerCanisterOptions = {}) { - const canisterId: Principal = - options.canisterId ?? MAINNET_LEDGER_CANISTER_ID; - - const { service, certifiedService, agent } = createServices({ - options: { - ...options, - canisterId, - }, - idlFactory, - certifiedIdlFactory, - }); - - return new LedgerCanister( - agent, - canisterId, - service, - certifiedService, - options.updateCallOverride ?? updateCall, - options.queryCallOverride ?? queryCall, - options.hardwareWallet, - ); - } - - /** - * Returns the balance of the specified account identifier. - * - * If `certified` is true, the request is fetched as an update call, otherwise - * it is fetched using a query call. - * - * @throws {@link Error} - */ - public accountBalance = async ({ - accountIdentifier, - certified = true, - }: { - accountIdentifier: AccountIdentifier; - certified?: boolean; - }): Promise => { - if (this.hardwareWallet) { - return this.accountBalanceHardwareWallet({ - accountIdentifier, - certified, - }); - } - const service = certified ? this.certifiedService : this.service; - const tokens = await service.account_balance({ - account: accountIdentifier.toUint8Array(), - }); - return tokens.e8s; - }; - - /** - * Returns the transaction fee of the ledger canister - * @returns {BigInt} - */ - public transactionFee = async () => { - const { - transfer_fee: { e8s }, - } = await this.service.transfer_fee({}); - return e8s; - }; - - /** - * Transfer ICP from the caller to the destination `accountIdentifier`. - * Returns the index of the block containing the tx if it was successful. - * - * @throws {@link TransferError} - */ - public transfer = async (request: TransferRequest): Promise => { - if (this.hardwareWallet) { - return this.transferHardwareWallet(request); - } - // When candid is implemented, the previous lines will go away. - // But the transaction fee method is not supported by Ledger App yet. - if (request.fee === undefined) { - request.fee = this.hardwareWallet - ? TRANSACTION_FEE - : await this.transactionFee(); - } - const rawRequest = toTransferRawRequest(request); - const response = await this.certifiedService.transfer(rawRequest); - if ("Err" in response) { - throw mapTransferError(response.Err); - } - return response.Ok; - }; - - /** - * Transfer ICP from the caller to the destination `Account`. - * Returns the index of the block containing the tx if it was successful. - * - * @throws {@link TransferError} - */ - public icrc1Transfer = async ( - request: Icrc1TransferRequest, - ): Promise => { - // The transaction fee method is not supported by Ledger App yet. - if (request.fee === undefined) { - request.fee = this.hardwareWallet - ? TRANSACTION_FEE - : await this.transactionFee(); - } - const rawRequest = toIcrc1TransferRawRequest(request); - const response = await this.certifiedService.icrc1_transfer(rawRequest); - if ("Err" in response) { - throw mapIcrc1TransferError(response.Err); - } - return response.Ok; - }; - - private accountBalanceHardwareWallet = async ({ - accountIdentifier, - certified = true, - }: { - accountIdentifier: AccountIdentifier; - certified?: boolean; - }): Promise => { - const callMethod = certified ? this.updateFetcher : this.queryFetcher; - - const { AccountBalanceRequest: AccountBalanceRequestConstructor, ICPTs } = - await importNnsProto(); - - const request = new AccountBalanceRequestConstructor(); - request.setAccount(await accountIdentifier.toProto()); - - const responseBytes = await callMethod({ - agent: this.agent, - canisterId: this.canisterId, - methodName: "account_balance_pb", - arg: request.serializeBinary(), - }); - - return BigInt( - ICPTs.deserializeBinary(new Uint8Array(responseBytes)).getE8s(), - ); - }; - - private transferHardwareWallet = async ({ - to, - amount, - memo, - fee, - fromSubAccount, - createdAt, - }: TransferRequest): Promise => { - const { SendRequest, Payment, Memo, TimeStamp, BlockHeight } = - await importNnsProto(); - - const request = new SendRequest(); - request.setTo(await to.toProto()); - - const payment = new Payment(); - payment.setReceiverGets(await toICPTs(amount)); - request.setPayment(payment); - - request.setMaxFee(await toICPTs(fee ?? TRANSACTION_FEE)); - - // Always explicitly set the memo for compatibility with ledger wallet - hardware wallet - const requestMemo = new Memo(); - requestMemo.setMemo((memo ?? BigInt(0)).toString()); - request.setMemo(requestMemo); - - if (createdAt !== undefined) { - const timestamp = new TimeStamp(); - timestamp.setTimestampNanos(createdAt.toString()); - request.setCreatedAtTime(timestamp); - } - - if (fromSubAccount !== undefined) { - request.setFromSubaccount( - await subAccountNumbersToSubaccount(fromSubAccount), - ); - } - - try { - const responseBytes = await this.updateFetcher({ - agent: this.agent, - canisterId: this.canisterId, - methodName: "send_pb", - arg: request.serializeBinary(), - }); - - // Successful tx. Return the block height. - return BigInt(BlockHeight.deserializeBinary(responseBytes).getHeight()); - } catch (err) { - if (err instanceof Error) { - throw mapTransferProtoError(err); - } - - throw err; - } - }; -} From 3d2a554efa990967f7d3d98d143037272a0ea927 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 26 Sep 2023 14:04:21 +0200 Subject: [PATCH 02/25] docs: breaking change --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb4cd47d..fe6cf3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - utils `v0.0.23` - nns-proto `v0.0.9` +## Breaking Changes ⚠️ + +- ICP ledger-related features have been relocated from `@dfinity/nns` to a new dedicated library called `@dfinity/ledger-icp` + ## Features - add support for `icrc2_transfer_from`, `icrc2_approve` and `icrc2_allowance` in `@dfinity/ledger` From 620f4dfcc65dd7445b5e1ca2ef2ed969d6e0af55 Mon Sep 17 00:00:00 2001 From: Formatting Committer Date: Tue, 26 Sep 2023 12:07:19 +0000 Subject: [PATCH 03/25] Updating formatting --- packages/nns/src/governance.canister.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nns/src/governance.canister.spec.ts b/packages/nns/src/governance.canister.spec.ts index bdb431ac..7f2c6ffa 100644 --- a/packages/nns/src/governance.canister.spec.ts +++ b/packages/nns/src/governance.canister.spec.ts @@ -6,6 +6,7 @@ import { type Agent, type RequestId, } from "@dfinity/agent"; +import { LedgerCanister } from "@dfinity/ledger-icp"; import { ManageNeuronResponse as PbManageNeuronResponse, NeuronId as PbNeuronId, @@ -32,7 +33,6 @@ import { UnrecognizedTypeError, } from "./errors/governance.errors"; import { GovernanceCanister } from "./governance.canister"; -import { LedgerCanister } from "@dfinity/ledger-icp"; import { mockListNeuronsResponse, mockNeuron, From ed2e32899b1d5dc0beca6977015cfeda351afa44 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 26 Sep 2023 14:24:43 +0200 Subject: [PATCH 04/25] docs: move documentation --- scripts/docs.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/docs.js b/scripts/docs.js index 1a1c405b..da134ae7 100644 --- a/scripts/docs.js +++ b/scripts/docs.js @@ -3,12 +3,10 @@ const { generateDocumentation } = require("tsdoc-markdown"); const nnsInputFiles = [ - "./packages/nns/src/account_identifier.ts", "./packages/nns/src/genesis_token.canister.ts", "./packages/nns/src/governance.canister.ts", "./packages/nns/src/icp.ts", "./packages/nns/src/token.ts", - "./packages/nns/src/ledger.canister.ts", "./packages/nns/src/sns_wasm.canister.ts", "./packages/nns/src/utils/neurons.utils.ts", ]; @@ -33,7 +31,10 @@ const ledgerInputFiles = [ "./packages/ledger/src/index.canister.ts", ]; -const ledgerICPInputFiles = ["./packages/ledger/src/ledger.canister.ts"]; +const ledgerICPInputFiles = [ + "./packages/nns/src/account_identifier.ts", + "./packages/nns/src/ledger.canister.ts", +]; const ckBTCInputFiles = [ "./packages/ckbtc/src/minter.canister.ts", From 8ebd81875e0c6dc21983c2fb64326b76246228b7 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 26 Sep 2023 14:26:34 +0200 Subject: [PATCH 05/25] feat: move ICP --- packages/{nns => ledger-icp}/src/icp.spec.ts | 0 packages/{nns => ledger-icp}/src/icp.ts | 0 packages/ledger-icp/src/index.ts | 1 + packages/nns/src/index.ts | 1 - 4 files changed, 1 insertion(+), 1 deletion(-) rename packages/{nns => ledger-icp}/src/icp.spec.ts (100%) rename packages/{nns => ledger-icp}/src/icp.ts (100%) diff --git a/packages/nns/src/icp.spec.ts b/packages/ledger-icp/src/icp.spec.ts similarity index 100% rename from packages/nns/src/icp.spec.ts rename to packages/ledger-icp/src/icp.spec.ts diff --git a/packages/nns/src/icp.ts b/packages/ledger-icp/src/icp.ts similarity index 100% rename from packages/nns/src/icp.ts rename to packages/ledger-icp/src/icp.ts diff --git a/packages/ledger-icp/src/index.ts b/packages/ledger-icp/src/index.ts index c03a1be0..d44e3725 100644 --- a/packages/ledger-icp/src/index.ts +++ b/packages/ledger-icp/src/index.ts @@ -1,4 +1,5 @@ export { AccountIdentifier, SubAccount } from "./account_identifier"; export * from "./errors/ledger.errors"; +export { ICP } from "./icp"; export { LedgerCanister } from "./ledger.canister"; export * from "./types/ledger.options"; diff --git a/packages/nns/src/index.ts b/packages/nns/src/index.ts index e8194126..660f9b30 100644 --- a/packages/nns/src/index.ts +++ b/packages/nns/src/index.ts @@ -4,7 +4,6 @@ export * from "./enums/governance.enums"; export * from "./errors/governance.errors"; export { GenesisTokenCanister } from "./genesis_token.canister"; export { GovernanceCanister } from "./governance.canister"; -export { ICP } from "./icp"; export { SnsWasmCanister } from "./sns_wasm.canister"; export * from "./types/common"; export * from "./types/governance.options"; From 7867ff6a894d565048705bfd41a43aba9ac5b2cd Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Tue, 26 Sep 2023 14:27:32 +0200 Subject: [PATCH 06/25] docs: icp --- scripts/docs.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/docs.js b/scripts/docs.js index da134ae7..a6a79dc7 100644 --- a/scripts/docs.js +++ b/scripts/docs.js @@ -5,8 +5,6 @@ const { generateDocumentation } = require("tsdoc-markdown"); const nnsInputFiles = [ "./packages/nns/src/genesis_token.canister.ts", "./packages/nns/src/governance.canister.ts", - "./packages/nns/src/icp.ts", - "./packages/nns/src/token.ts", "./packages/nns/src/sns_wasm.canister.ts", "./packages/nns/src/utils/neurons.utils.ts", ]; @@ -32,6 +30,7 @@ const ledgerInputFiles = [ ]; const ledgerICPInputFiles = [ + "./packages/nns/src/icp.ts", "./packages/nns/src/account_identifier.ts", "./packages/nns/src/ledger.canister.ts", ]; From 9c68d8dc64e405f3e19170cb3d618d36e3c6d7e4 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 10:29:22 +0200 Subject: [PATCH 07/25] chore: redo as in main --- packages/nns/package.json | 1 - packages/nns/src/account_identifier.spec.ts | 123 ++++++++++++++++ packages/nns/src/account_identifier.ts | 115 +++++++++++++++ .../governance/request.converters.ts | 2 +- .../governance/response.converters.ts | 2 +- .../ledger/ledger.request.converts.ts | 74 ++++++++++ packages/nns/src/constants/canister_ids.ts | 4 + packages/nns/src/constants/constants.ts | 9 ++ packages/nns/src/errors/ledger.errors.ts | 137 ++++++++++++++++++ packages/nns/src/governance.canister.spec.ts | 2 +- packages/nns/src/governance.canister.ts | 6 +- packages/nns/src/icp.spec.ts | 56 +++++++ packages/nns/src/icp.ts | 50 +++++++ packages/nns/src/index.ts | 5 + packages/nns/src/types/ledger.options.ts | 23 +++ packages/nns/src/types/ledger_converters.ts | 38 +++++ 16 files changed, 640 insertions(+), 7 deletions(-) create mode 100644 packages/nns/src/account_identifier.spec.ts create mode 100644 packages/nns/src/account_identifier.ts create mode 100644 packages/nns/src/canisters/ledger/ledger.request.converts.ts create mode 100644 packages/nns/src/constants/constants.ts create mode 100644 packages/nns/src/errors/ledger.errors.ts create mode 100644 packages/nns/src/icp.spec.ts create mode 100644 packages/nns/src/icp.ts create mode 100644 packages/nns/src/types/ledger.options.ts create mode 100644 packages/nns/src/types/ledger_converters.ts diff --git a/packages/nns/package.json b/packages/nns/package.json index 25136529..c0dfb2c3 100644 --- a/packages/nns/package.json +++ b/packages/nns/package.json @@ -53,7 +53,6 @@ "peerDependencies": { "@dfinity/agent": "^0.19.2", "@dfinity/candid": "^0.19.2", - "@dfinity/ledger-icp": "^0.0.1", "@dfinity/nns-proto": "^0.0.9", "@dfinity/principal": "^0.19.2", "@dfinity/utils": "^0.0.23" diff --git a/packages/nns/src/account_identifier.spec.ts b/packages/nns/src/account_identifier.spec.ts new file mode 100644 index 00000000..5cf15dd2 --- /dev/null +++ b/packages/nns/src/account_identifier.spec.ts @@ -0,0 +1,123 @@ +import { Principal } from "@dfinity/principal"; +import { describe, expect, it } from "@jest/globals"; +import { AccountIdentifier, SubAccount } from "./account_identifier"; + +describe("SubAccount", () => { + it("only accepts 32-byte blobs", () => { + expect(SubAccount.fromBytes(new Uint8Array([1, 2]))).toBeInstanceOf(Error); + expect(SubAccount.fromBytes(new Uint8Array(31))).toBeInstanceOf(Error); + expect(SubAccount.fromBytes(new Uint8Array(33))).toBeInstanceOf(Error); + expect(SubAccount.fromBytes(new Uint8Array(32))).toBeInstanceOf(SubAccount); + }); + + it("defines ZERO as a 32-byte zeroed array", () => { + expect(SubAccount.ZERO.toUint8Array()).toEqual(new Uint8Array(32).fill(0)); + }); + + it("can be initialized from a principal", () => { + expect(SubAccount.fromPrincipal(Principal.fromText("aaaaa-aa"))).toEqual( + SubAccount.fromBytes(new Uint8Array(32).fill(0)), + ); + + expect( + SubAccount.fromPrincipal( + Principal.fromText( + "bl375-kyc3r-uvghl-oqn24-6chib-zxm3v-z3soy-p6ygm-ff5yu-p7kkm-oae", + ), + ), + ).toEqual( + SubAccount.fromBytes( + new Uint8Array([ + 29, 2, 220, 105, 83, 29, 110, 131, 117, 207, 8, 232, 14, 110, 205, + 215, 59, 147, 176, 255, 96, 204, 41, 123, 138, 63, 234, 83, 28, 2, 0, + 0, + ]), + ), + ); + + expect( + SubAccount.fromPrincipal( + Principal.fromText("kb4lg-bqaaa-aaaab-qabfq-cai"), + ), + ).toEqual( + SubAccount.fromBytes( + new Uint8Array([ + 10, 0, 0, 0, 0, 0, 48, 0, 75, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]), + ), + ); + }); + + it("can be initialized from an ID", () => { + expect(SubAccount.fromID(0)).toEqual( + SubAccount.fromBytes(new Uint8Array(32).fill(0)), + ); + const bytes = new Uint8Array(32).fill(0); + bytes[31] = 1; + expect(SubAccount.fromID(1)).toEqual(SubAccount.fromBytes(bytes)); + bytes[31] = 255; + expect(SubAccount.fromID(255)).toEqual(SubAccount.fromBytes(bytes)); + }); + + it("throws an exception if initialized with an ID < 0", () => { + expect(() => { + SubAccount.fromID(-1); + }).toThrow(); + }); + + it("throws an exception if initialized with an ID > 255", () => { + expect(() => { + SubAccount.fromID(256); + }).toThrow(); + }); +}); + +describe("AccountIdentifier", () => { + test("can be initialized from a hex string", () => { + expect( + AccountIdentifier.fromHex( + "d3e13d4777e22367532053190b6c6ccf57444a61337e996242b1abfb52cf92c8", + ).toHex(), + ).toBe("d3e13d4777e22367532053190b6c6ccf57444a61337e996242b1abfb52cf92c8"); + }); + + test("can be initialized from a principal", () => { + expect( + AccountIdentifier.fromPrincipal({ + principal: Principal.fromText( + "bwz3t-ercuj-owo6s-4adfr-sbu4o-l72hg-kfhc5-5sapm-tj6bn-3scho-uqe", + ), + }).toHex(), + ).toBe("df4ad42194201b15ecbbe66ff68559a126854d8141fd935c5bd53433c2fb28d4"); + + expect( + AccountIdentifier.fromPrincipal({ + principal: Principal.fromText( + "bwz3t-ercuj-owo6s-4adfr-sbu4o-l72hg-kfhc5-5sapm-tj6bn-3scho-uqe", + ), + subAccount: SubAccount.ZERO, + }).toHex(), + ).toBe("df4ad42194201b15ecbbe66ff68559a126854d8141fd935c5bd53433c2fb28d4"); + }); + + test("can be initialized from a principal and subaccount", () => { + expect( + AccountIdentifier.fromPrincipal({ + principal: Principal.fromText( + "bwz3t-ercuj-owo6s-4adfr-sbu4o-l72hg-kfhc5-5sapm-tj6bn-3scho-uqe", + ), + subAccount: SubAccount.fromID(1), + }).toHex(), + ).toBe("16c3ca805340f0e426023bea907488100f93d5e2a654644d5d6881c7a7b2071e"); + + expect( + AccountIdentifier.fromPrincipal({ + principal: Principal.fromText( + "bwz3t-ercuj-owo6s-4adfr-sbu4o-l72hg-kfhc5-5sapm-tj6bn-3scho-uqe", + ), + subAccount: SubAccount.fromID(255), + }).toHex(), + ).toBe("f9d8833b97d142d888d00606e2cadec4e70b9798d71c35091a20daaa14082e67"); + }); +}); diff --git a/packages/nns/src/account_identifier.ts b/packages/nns/src/account_identifier.ts new file mode 100644 index 00000000..3212ce1a --- /dev/null +++ b/packages/nns/src/account_identifier.ts @@ -0,0 +1,115 @@ +import type { AccountIdentifier as AccountIdentifierPb } from "@dfinity/nns-proto"; +import type { Principal } from "@dfinity/principal"; +import { + arrayOfNumberToUint8Array, + asciiStringToByteArray, + bigEndianCrc32, + uint8ArrayToHexString, +} from "@dfinity/utils"; +import { sha224 } from "@noble/hashes/sha256"; +import type { AccountIdentifier as AccountIdentifierCandid } from "../candid/governance"; +import { importNnsProto } from "./utils/proto.utils"; + +export class AccountIdentifier { + private constructor(private readonly bytes: Uint8Array) {} + + public static fromHex(hex: string): AccountIdentifier { + return new AccountIdentifier(Uint8Array.from(Buffer.from(hex, "hex"))); + } + + public static fromPrincipal({ + principal, + subAccount = SubAccount.ZERO, + }: { + principal: Principal; + subAccount?: SubAccount; + }): AccountIdentifier { + // Hash (sha224) the principal, the subAccount and some padding + const padding = asciiStringToByteArray("\x0Aaccount-id"); + + const shaObj = sha224.create(); + shaObj.update( + arrayOfNumberToUint8Array([ + ...padding, + ...principal.toUint8Array(), + ...subAccount.toUint8Array(), + ]), + ); + const hash = shaObj.digest(); + + // Prepend the checksum of the hash and convert to a hex string + const checksum = bigEndianCrc32(hash); + const bytes = new Uint8Array([...checksum, ...hash]); + return new AccountIdentifier(bytes); + } + + /** + * @returns An AccountIdentifier protobuf object. + */ + public async toProto(): Promise { + const { AccountIdentifier: AccountIdentifierConstructor } = + await importNnsProto(); + const accountIdentifier = new AccountIdentifierConstructor(); + accountIdentifier.setHash(this.bytes); + return accountIdentifier; + } + + public toHex(): string { + return uint8ArrayToHexString(this.bytes); + } + + public toUint8Array(): Uint8Array { + return this.bytes; + } + + public toNumbers(): number[] { + return Array.from(this.bytes); + } + + public toAccountIdentifierHash(): AccountIdentifierCandid { + return { + hash: this.toUint8Array(), + }; + } +} + +export class SubAccount { + private constructor(private readonly bytes: Uint8Array) {} + + public static fromBytes(bytes: Uint8Array): SubAccount | Error { + if (bytes.length != 32) { + return Error("Subaccount length must be 32-bytes"); + } + + return new SubAccount(bytes); + } + + public static fromPrincipal(principal: Principal): SubAccount { + const bytes = new Uint8Array(32).fill(0); + + const principalBytes = principal.toUint8Array(); + bytes[0] = principalBytes.length; + + for (let i = 0; i < principalBytes.length; i++) { + bytes[1 + i] = principalBytes[i]; + } + + return new SubAccount(bytes); + } + + public static fromID(id: number): SubAccount { + if (id < 0 || id > 255) { + throw "Subaccount ID must be >= 0 and <= 255"; + } + + const bytes: Uint8Array = new Uint8Array(32).fill(0); + bytes[31] = id; + return new SubAccount(bytes); + } + + public static ZERO: SubAccount = this.fromID(0); + + public toUint8Array(): Uint8Array { + return this.bytes; + } +} diff --git a/packages/nns/src/canisters/governance/request.converters.ts b/packages/nns/src/canisters/governance/request.converters.ts index abe9e1e1..fbda50ec 100644 --- a/packages/nns/src/canisters/governance/request.converters.ts +++ b/packages/nns/src/canisters/governance/request.converters.ts @@ -1,4 +1,3 @@ -import type { AccountIdentifier as AccountIdentifierClass } from "@dfinity/ledger-icp"; import { Principal } from "@dfinity/principal"; import { arrayBufferToUint8Array, toNullable } from "@dfinity/utils"; import type { @@ -34,6 +33,7 @@ import type { Tokens as RawTokens, VotingRewardParameters as RawVotingRewardParameters, } from "../../../candid/governance"; +import type { AccountIdentifier as AccountIdentifierClass } from "../../account_identifier"; import type { Vote } from "../../enums/governance.enums"; import { UnsupportedValueError } from "../../errors/governance.errors"; import type { AccountIdentifier, E8s, NeuronId } from "../../types/common"; diff --git a/packages/nns/src/canisters/governance/response.converters.ts b/packages/nns/src/canisters/governance/response.converters.ts index 8c2de269..91322846 100644 --- a/packages/nns/src/canisters/governance/response.converters.ts +++ b/packages/nns/src/canisters/governance/response.converters.ts @@ -1,4 +1,3 @@ -import { AccountIdentifier, SubAccount } from "@dfinity/ledger-icp"; import type { ListNeuronsResponse, BallotInfo as PbBallotInfo, @@ -55,6 +54,7 @@ import type { Tokens as RawTokens, VotingRewardParameters as RawVotingRewardParameters, } from "../../../candid/governance"; +import { AccountIdentifier, SubAccount } from "../../account_identifier"; import { NeuronState } from "../../enums/governance.enums"; import { UnsupportedValueError } from "../../errors/governance.errors"; import type { diff --git a/packages/nns/src/canisters/ledger/ledger.request.converts.ts b/packages/nns/src/canisters/ledger/ledger.request.converts.ts new file mode 100644 index 00000000..e1bde1c0 --- /dev/null +++ b/packages/nns/src/canisters/ledger/ledger.request.converts.ts @@ -0,0 +1,74 @@ +import type { ICPTs, Subaccount } from "@dfinity/nns-proto"; +import { arrayOfNumberToUint8Array, toNullable } from "@dfinity/utils"; +import type { + TransferArg as Icrc1TransferRawRequest, + Tokens, + TransferArgs as TransferRawRequest, +} from "../../../candid/ledger"; +import { TRANSACTION_FEE } from "../../constants/constants"; +import type { + Icrc1TransferRequest, + TransferRequest, +} from "../../types/ledger_converters"; +import { importNnsProto } from "../../utils/proto.utils"; + +export const subAccountNumbersToSubaccount = async ( + subAccountNumbers: number[], +): Promise => { + const bytes = new Uint8Array(subAccountNumbers).buffer; + const { Subaccount: SubaccountConstructor } = await importNnsProto(); + const subaccount: Subaccount = new SubaccountConstructor(); + subaccount.setSubAccount(new Uint8Array(bytes)); + return subaccount; +}; + +export const toICPTs = async (amount: bigint): Promise => { + const { ICPTs: ICPTsConstructor } = await importNnsProto(); + const result = new ICPTsConstructor(); + result.setE8s(amount.toString(10)); + return result; +}; + +const e8sToTokens = (e8s: bigint): Tokens => ({ e8s }); + +export const toTransferRawRequest = ({ + to, + amount, + memo, + fee, + fromSubAccount, + createdAt, +}: TransferRequest): TransferRawRequest => ({ + to: to.toUint8Array(), + fee: e8sToTokens(fee ?? TRANSACTION_FEE), + amount: e8sToTokens(amount), + // Always explicitly set the memo for compatibility with ledger wallet - hardware wallet + memo: memo ?? BigInt(0), + created_at_time: + createdAt !== undefined ? [{ timestamp_nanos: createdAt }] : [], + from_subaccount: + fromSubAccount === undefined + ? [] + : [arrayOfNumberToUint8Array(fromSubAccount)], +}); + +// WARNING: When using the ICRC-1 interface of the ICP ledger, there is no +// relationship between the memo and the icrc1Memo of a transaction. The ICRC-1 +// interface simply cannot set the memo field and the non-ICRC-1 interface +// cannot set the icrc1Memo field, even though the icrc1Memo field is called +// just "memo" in canister method params. +export const toIcrc1TransferRawRequest = ({ + fromSubAccount, + to, + amount, + fee, + icrc1Memo, + createdAt, +}: Icrc1TransferRequest): Icrc1TransferRawRequest => ({ + to, + fee: toNullable(fee ?? TRANSACTION_FEE), + amount, + memo: toNullable(icrc1Memo), + created_at_time: toNullable(createdAt), + from_subaccount: toNullable(fromSubAccount), +}); diff --git a/packages/nns/src/constants/canister_ids.ts b/packages/nns/src/constants/canister_ids.ts index 937889d0..a97d457a 100644 --- a/packages/nns/src/constants/canister_ids.ts +++ b/packages/nns/src/constants/canister_ids.ts @@ -8,6 +8,10 @@ export const MAINNET_GOVERNANCE_CANISTER_ID = Principal.fromText( "rrkah-fqaaa-aaaaa-aaaaq-cai", ); +export const MAINNET_LEDGER_CANISTER_ID = Principal.fromText( + "ryjl3-tyaaa-aaaaa-aaaba-cai", +); + export const MAINNET_GENESIS_TOKEN_CANISTER_ID = Principal.fromText( "renrk-eyaaa-aaaaa-aaada-cai", ); diff --git a/packages/nns/src/constants/constants.ts b/packages/nns/src/constants/constants.ts new file mode 100644 index 00000000..9f24a952 --- /dev/null +++ b/packages/nns/src/constants/constants.ts @@ -0,0 +1,9 @@ +export const SUB_ACCOUNT_BYTE_LENGTH = 32; +export const CREATE_CANISTER_MEMO = BigInt(0x41455243); // CREA, +export const TOP_UP_CANISTER_MEMO = BigInt(0x50555054); // TPUP + +export const TRANSACTION_FEE = BigInt(10_000); + +// Note: Canister IDs are not constant, so are not provided here. +// The same applies to HOST. +export const E8S_PER_TOKEN = BigInt(100000000); diff --git a/packages/nns/src/errors/ledger.errors.ts b/packages/nns/src/errors/ledger.errors.ts new file mode 100644 index 00000000..85387782 --- /dev/null +++ b/packages/nns/src/errors/ledger.errors.ts @@ -0,0 +1,137 @@ +import { convertStringToE8s } from "@dfinity/utils"; +import type { + Icrc1TransferError as RawIcrc1TransferError, + TransferError as RawTransferError, +} from "../../candid/ledger"; +import type { BlockHeight } from "../types/common"; + +export class TransferError extends Error {} + +export class InvalidSenderError extends TransferError {} + +export class InsufficientFundsError extends TransferError { + constructor(public readonly balance: bigint) { + super(); + } +} + +export class TxTooOldError extends TransferError { + constructor(public readonly allowed_window_secs?: number | undefined) { + super(); + } +} + +export class TxCreatedInFutureError extends TransferError {} + +export class TxDuplicateError extends TransferError { + constructor(public readonly duplicateOf: BlockHeight) { + super(); + } +} + +export class BadFeeError extends TransferError { + constructor(public readonly expectedFee: bigint) { + super(); + } +} + +export const mapTransferError = ( + rawTransferError: RawTransferError, +): TransferError => { + if ("TxDuplicate" in rawTransferError) { + return new TxDuplicateError(rawTransferError.TxDuplicate.duplicate_of); + } + if ("InsufficientFunds" in rawTransferError) { + return new InsufficientFundsError( + rawTransferError.InsufficientFunds.balance.e8s, + ); + } + if ("TxCreatedInFuture" in rawTransferError) { + return new TxCreatedInFutureError(); + } + if ("TxTooOld" in rawTransferError) { + return new TxTooOldError( + Number(rawTransferError.TxTooOld.allowed_window_nanos), + ); + } + if ("BadFee" in rawTransferError) { + return new BadFeeError(rawTransferError.BadFee.expected_fee.e8s); + } + // Edge case + return new TransferError( + `Unknown error type ${JSON.stringify(rawTransferError)}`, + ); +}; + +export const mapIcrc1TransferError = ( + rawTransferError: RawIcrc1TransferError, +): TransferError => { + if ("Duplicate" in rawTransferError) { + return new TxDuplicateError(rawTransferError.Duplicate.duplicate_of); + } + if ("InsufficientFunds" in rawTransferError) { + return new InsufficientFundsError( + rawTransferError.InsufficientFunds.balance, + ); + } + if ("CreatedInFuture" in rawTransferError) { + return new TxCreatedInFutureError(); + } + if ("TooOld" in rawTransferError) { + return new TxTooOldError(); + } + if ("BadFee" in rawTransferError) { + return new BadFeeError(rawTransferError.BadFee.expected_fee); + } + // Edge case + return new TransferError( + `Unknown error type ${JSON.stringify(rawTransferError)}`, + ); +}; + +export const mapTransferProtoError = (responseBytes: Error): TransferError => { + const { message } = responseBytes; + + if (message.includes("Reject code: 5")) { + // Match against the different error types. + // This string matching is fragile. It's a stop-gap solution until + // we migrate to the candid interface. + + if (message.match(/Sending from (.*) is not allowed/)) { + return new InvalidSenderError(); + } + + { + const m = message.match(/transaction.*duplicate.* in block (\d+)/); + if (m && m.length > 1) { + return new TxDuplicateError(BigInt(m[1])); + } + } + + { + const m = message.match( + /debit account.*, current balance: (\d*(\.\d*)?)/, + ); + if (m && m.length > 1) { + const balance = convertStringToE8s(m[1]); + if (typeof balance === "bigint") { + return new InsufficientFundsError(balance); + } + } + } + + if (message.includes("is in future")) { + return new TxCreatedInFutureError(); + } + + { + const m = message.match(/older than (\d+)/); + if (m && m.length > 1) { + return new TxTooOldError(Number.parseInt(m[1])); + } + } + } + + // Unknown error. Throw as-is. + throw responseBytes; +}; diff --git a/packages/nns/src/governance.canister.spec.ts b/packages/nns/src/governance.canister.spec.ts index 7f2c6ffa..b4a4588f 100644 --- a/packages/nns/src/governance.canister.spec.ts +++ b/packages/nns/src/governance.canister.spec.ts @@ -6,7 +6,6 @@ import { type Agent, type RequestId, } from "@dfinity/agent"; -import { LedgerCanister } from "@dfinity/ledger-icp"; import { ManageNeuronResponse as PbManageNeuronResponse, NeuronId as PbNeuronId, @@ -33,6 +32,7 @@ import { UnrecognizedTypeError, } from "./errors/governance.errors"; import { GovernanceCanister } from "./governance.canister"; +import { LedgerCanister } from "./ledger.canister"; import { mockListNeuronsResponse, mockNeuron, diff --git a/packages/nns/src/governance.canister.ts b/packages/nns/src/governance.canister.ts index 13740cc9..7ba52a01 100644 --- a/packages/nns/src/governance.canister.ts +++ b/packages/nns/src/governance.canister.ts @@ -1,7 +1,4 @@ import type { ActorSubclass, Agent } from "@dfinity/agent"; -import type { LedgerCanister } from "@dfinity/ledger-icp"; -import { AccountIdentifier, SubAccount } from "@dfinity/ledger-icp"; -import { E8S_PER_TOKEN } from "@dfinity/ledger-icp/src/constants/constants"; import type { ManageNeuron as PbManageNeuron } from "@dfinity/nns-proto"; import type { Principal } from "@dfinity/principal"; import { @@ -28,6 +25,7 @@ import type { } from "../candid/governance"; import { idlFactory as certifiedIdlFactory } from "../candid/governance.certified.idl"; import { idlFactory } from "../candid/governance.idl"; +import { AccountIdentifier, SubAccount } from "./account_identifier"; import { fromClaimOrRefreshNeuronRequest, fromListNeurons, @@ -77,6 +75,7 @@ import { simulateManageNeuron, } from "./canisters/governance/services"; import { MAINNET_GOVERNANCE_CANISTER_ID } from "./constants/canister_ids"; +import { E8S_PER_TOKEN } from "./constants/constants"; import type { Vote } from "./enums/governance.enums"; import { CouldNotClaimNeuronError, @@ -85,6 +84,7 @@ import { InsufficientAmountError, UnrecognizedTypeError, } from "./errors/governance.errors"; +import type { LedgerCanister } from "./ledger.canister"; import type { E8s, NeuronId } from "./types/common"; import type { GovernanceCanisterOptions } from "./types/governance.options"; import type { diff --git a/packages/nns/src/icp.spec.ts b/packages/nns/src/icp.spec.ts new file mode 100644 index 00000000..0f6078a2 --- /dev/null +++ b/packages/nns/src/icp.spec.ts @@ -0,0 +1,56 @@ +import { FromStringToTokenError } from "@dfinity/utils"; +import { describe, expect, it } from "@jest/globals"; +import { ICP } from "./icp"; + +describe("ICP", () => { + it("can be initialized from a whole number string", () => { + expect(ICP.fromString("1")).toEqual(ICP.fromE8s(BigInt(100000000))); + expect(ICP.fromString("1234")).toEqual(ICP.fromE8s(BigInt(123400000000))); + expect(ICP.fromString("000001234")).toEqual( + ICP.fromE8s(BigInt(123400000000)), + ); + expect(ICP.fromString(" 1")).toEqual(ICP.fromE8s(BigInt(100000000))); + expect(ICP.fromString("1,000")).toEqual(ICP.fromE8s(BigInt(100000000000))); + expect(ICP.fromString("1'000")).toEqual(ICP.fromE8s(BigInt(100000000000))); + expect(ICP.fromString("1'000'000")).toEqual( + ICP.fromE8s(BigInt(100000000000000)), + ); + }); + + it("can be initialized from a fractional number string", () => { + expect(ICP.fromString("0.1")).toEqual(ICP.fromE8s(BigInt(10000000))); + expect(ICP.fromString("0.0001")).toEqual(ICP.fromE8s(BigInt(10000))); + expect(ICP.fromString("0.00000001")).toEqual(ICP.fromE8s(BigInt(1))); + expect(ICP.fromString("0.0000000001")).toEqual( + FromStringToTokenError.FractionalMoreThan8Decimals, + ); + expect(ICP.fromString(".01")).toEqual(ICP.fromE8s(BigInt(1000000))); + }); + + it("can be initialized from a mixed string", () => { + expect(ICP.fromString("1.1")).toEqual(ICP.fromE8s(BigInt(110000000))); + expect(ICP.fromString("1.1")).toEqual(ICP.fromE8s(BigInt(110000000))); + expect(ICP.fromString("12,345.00000001")).toEqual( + ICP.fromE8s(BigInt(1234500000001)), + ); + expect(ICP.fromString("12'345.00000001")).toEqual( + ICP.fromE8s(BigInt(1234500000001)), + ); + expect(ICP.fromString("12345.00000001")).toEqual( + ICP.fromE8s(BigInt(1234500000001)), + ); + }); + + it("returns an error on invalid formats", () => { + expect(ICP.fromString("1.1.1")).toBe(FromStringToTokenError.InvalidFormat); + expect(ICP.fromString("a")).toBe(FromStringToTokenError.InvalidFormat); + expect(ICP.fromString("3.a")).toBe(FromStringToTokenError.InvalidFormat); + expect(ICP.fromString("123asdf$#@~!")).toBe( + FromStringToTokenError.InvalidFormat, + ); + }); + + it("rejects negative numbers", () => { + expect(ICP.fromString("-1")).toBe(FromStringToTokenError.InvalidFormat); + }); +}); diff --git a/packages/nns/src/icp.ts b/packages/nns/src/icp.ts new file mode 100644 index 00000000..bdf4680d --- /dev/null +++ b/packages/nns/src/icp.ts @@ -0,0 +1,50 @@ +import type { ICPTs } from "@dfinity/nns-proto"; +import { + ICPToken, + convertStringToE8s, + type FromStringToTokenError, + type Token, +} from "@dfinity/utils"; +import { importNnsProto } from "./utils/proto.utils"; + +/** + * We don't extend to keep `fromE8s` and `fromString` as backwards compatible. + * @deprecated + */ +export class ICP { + private constructor( + private e8s: bigint, + public token: Token, + ) {} + + public static fromE8s(amount: bigint): ICP { + return new ICP(amount, ICPToken); + } + + /** + * Initialize from a string. Accepted formats: + * + * 1234567.8901 + * 1'234'567.8901 + * 1,234,567.8901 + */ + public static fromString(amount: string): ICP | FromStringToTokenError { + const e8s = convertStringToE8s(amount); + if (typeof e8s === "bigint") { + return new ICP(e8s, ICPToken); + } + return e8s; + } + + public toE8s(): bigint { + return this.e8s; + } + + public async toProto(): Promise { + const { ICPTs: ICPTsConstructor } = await importNnsProto(); + + const proto = new ICPTsConstructor(); + proto.setE8s(this.e8s.toString()); + return proto; + } +} diff --git a/packages/nns/src/index.ts b/packages/nns/src/index.ts index 660f9b30..a9506dbc 100644 --- a/packages/nns/src/index.ts +++ b/packages/nns/src/index.ts @@ -1,13 +1,18 @@ export type { RewardEvent } from "../candid/governance"; export type { DeployedSns } from "../candid/sns_wasm"; +export { AccountIdentifier, SubAccount } from "./account_identifier"; export * from "./enums/governance.enums"; export * from "./errors/governance.errors"; +export * from "./errors/ledger.errors"; export { GenesisTokenCanister } from "./genesis_token.canister"; export { GovernanceCanister } from "./governance.canister"; +export { ICP } from "./icp"; +export { LedgerCanister } from "./ledger.canister"; export { SnsWasmCanister } from "./sns_wasm.canister"; export * from "./types/common"; export * from "./types/governance.options"; export * from "./types/governance_converters"; +export * from "./types/ledger.options"; export type { SnsWasmCanisterOptions } from "./types/sns_wasm.options"; export * from "./utils/account_identifier.utils"; export * from "./utils/accounts.utils"; diff --git a/packages/nns/src/types/ledger.options.ts b/packages/nns/src/types/ledger.options.ts new file mode 100644 index 00000000..91af71ff --- /dev/null +++ b/packages/nns/src/types/ledger.options.ts @@ -0,0 +1,23 @@ +import type { Agent } from "@dfinity/agent"; +import type { Principal } from "@dfinity/principal"; +import type { CanisterOptions } from "@dfinity/utils"; +import type { _SERVICE as LedgerService } from "../../candid/ledger"; + +export type LedgerCanisterCall = (params: { + agent: Agent; + canisterId: Principal; + methodName: string; + arg: ArrayBuffer; +}) => Promise; + +export interface LedgerCanisterOptions extends CanisterOptions { + // The method to use for performing an update call. Primarily overridden + // in test for mocking. + updateCallOverride?: LedgerCanisterCall; + // The method to use for performing a query call. Primarily overridden + // in test for mocking. + queryCallOverride?: LedgerCanisterCall; + // Ledger IC App needs requests built with Protobuf. + // This flag ensures that the methods use Protobuf. + hardwareWallet?: boolean; +} diff --git a/packages/nns/src/types/ledger_converters.ts b/packages/nns/src/types/ledger_converters.ts new file mode 100644 index 00000000..ade1ade7 --- /dev/null +++ b/packages/nns/src/types/ledger_converters.ts @@ -0,0 +1,38 @@ +import type { + Account, + Icrc1Timestamp, + Icrc1Tokens, + SubAccount, +} from "../../candid/ledger"; +import type { AccountIdentifier } from "../account_identifier"; +import type { E8s } from "./common"; + +export type TransferRequest = { + to: AccountIdentifier; + amount: bigint; + memo?: bigint; + fee?: E8s; + // TODO: If didc is updated in nns-dapp as well, this array of number will become a Uint8Array + fromSubAccount?: number[]; + // Nanoseconds since unix epoc to trigger deduplication and avoid other issues + // See the link for more details on deduplication + // https://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-1/README.md#transaction_deduplication + createdAt?: bigint; +}; + +// WARNING: When using the ICRC-1 interface of the ICP ledger, there is no +// relationship between the memo and the icrc1Memo of a transaction. The ICRC-1 +// interface simply cannot set the memo field and the non-ICRC-1 interface +// cannot set the icrc1Memo field, even though the icrc1Memo field is called +// just "memo" in canister method params. +export type Icrc1TransferRequest = { + to: Account; + amount: Icrc1Tokens; + icrc1Memo?: Uint8Array; + fee?: Icrc1Tokens; + fromSubAccount?: SubAccount; + // Nanoseconds since unix epoc to trigger deduplication and avoid other issues + // See the link for more details on deduplication + // https://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-1/README.md#transaction_deduplication + createdAt?: Icrc1Timestamp; +}; From 8de71feaf29005e7bb398a26ef4d9508bbe7ed36 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 10:34:27 +0200 Subject: [PATCH 08/25] build: utils --- package-lock.json | 3 +-- packages/ledger-icp/package.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0db838f5..89b89d7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7077,7 +7077,7 @@ }, "packages/ledger-icp": { "name": "@dfinity/ledger-icp", - "version": "0.0.1", + "version": "1.0.0", "license": "Apache-2.0", "peerDependencies": { "@dfinity/agent": "^0.19.2", @@ -7112,7 +7112,6 @@ "peerDependencies": { "@dfinity/agent": "^0.19.2", "@dfinity/candid": "^0.19.2", - "@dfinity/ledger-icp": "^0.0.1", "@dfinity/nns-proto": "^0.0.9", "@dfinity/principal": "^0.19.2", "@dfinity/utils": "^0.0.23" diff --git a/packages/ledger-icp/package.json b/packages/ledger-icp/package.json index 4c6a21f3..8375030b 100644 --- a/packages/ledger-icp/package.json +++ b/packages/ledger-icp/package.json @@ -42,6 +42,6 @@ "@dfinity/candid": "^0.19.2", "@dfinity/nns-proto": "^0.0.9", "@dfinity/principal": "^0.19.2", - "@dfinity/utils": "^1.0.0" + "@dfinity/utils": "^0.0.23" } } From ea3843f5f5bc0f1632408e652376be85cdbc9a18 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 10:35:08 +0200 Subject: [PATCH 09/25] build: version --- package-lock.json | 2 +- packages/ledger-icp/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 89b89d7a..7b2d91e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7077,7 +7077,7 @@ }, "packages/ledger-icp": { "name": "@dfinity/ledger-icp", - "version": "1.0.0", + "version": "0.0.1", "license": "Apache-2.0", "peerDependencies": { "@dfinity/agent": "^0.19.2", diff --git a/packages/ledger-icp/package.json b/packages/ledger-icp/package.json index 8375030b..96334224 100644 --- a/packages/ledger-icp/package.json +++ b/packages/ledger-icp/package.json @@ -1,6 +1,6 @@ { "name": "@dfinity/ledger-icp", - "version": "1.0.0", + "version": "0.0.1", "description": "A library for interfacing with the ICP ledger on the Internet Computer.", "license": "Apache-2.0", "main": "dist/cjs/index.cjs.js", From f4de1986a63d239c691a66b0d291c115815f889f Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Oct 2023 08:36:57 +0000 Subject: [PATCH 10/25] =?UTF-8?q?=F0=9F=A4=96=20Documentation=20auto-updat?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ledger-icp/README.md | 253 +++++++++++++++++++++++----------- 1 file changed, 176 insertions(+), 77 deletions(-) diff --git a/packages/ledger-icp/README.md b/packages/ledger-icp/README.md index 78a2652f..1723b3d8 100644 --- a/packages/ledger-icp/README.md +++ b/packages/ledger-icp/README.md @@ -55,135 +55,234 @@ const data = await metadata(); -### :factory: IcrcLedgerCanister +### :factory: ICP -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L27) +We don't extend to keep `fromE8s` and `fromString` as backwards compatible. + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L14) #### Methods -- [create](#gear-create) -- [metadata](#gear-metadata) -- [transactionFee](#gear-transactionfee) -- [balance](#gear-balance) -- [transfer](#gear-transfer) -- [totalTokensSupply](#gear-totaltokenssupply) -- [transferFrom](#gear-transferfrom) -- [approve](#gear-approve) -- [allowance](#gear-allowance) +- [fromE8s](#gear-frome8s) +- [fromString](#gear-fromstring) +- [toE8s](#gear-toe8s) +- [toProto](#gear-toproto) -##### :gear: create +##### :gear: fromE8s -| Method | Type | -| -------- | ---------------------------------------------------------------------- | -| `create` | `(options: IcrcLedgerCanisterOptions<_SERVICE>) => IcrcLedgerCanister` | +| Method | Type | +| --------- | ------------------------- | +| `fromE8s` | `(amount: bigint) => ICP` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L28) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L20) -##### :gear: metadata +##### :gear: fromString -The token metadata (name, symbol, etc.). +Initialize from a string. Accepted formats: -| Method | Type | -| ---------- | ------------------------------------------------------------- | -| `metadata` | `(params: QueryParams) => Promise` | +1234567.8901 +1'234'567.8901 +1,234,567.8901 -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L42) +| Method | Type | +| ------------ | --------------------------------------------------- | +| `fromString` | `(amount: string) => ICP or FromStringToTokenError` | -##### :gear: transactionFee +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L31) -The ledger transaction fees. +##### :gear: toE8s -| Method | Type | -| ---------------- | ------------------------------------------ | -| `transactionFee` | `(params: QueryParams) => Promise` | +| Method | Type | +| ------- | -------------- | +| `toE8s` | `() => bigint` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L50) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L39) -##### :gear: balance +##### :gear: toProto -Returns the balance for a given account provided as owner and with optional subaccount. +| Method | Type | +| --------- | ---------------------- | +| `toProto` | `() => Promise` | -| Method | Type | -| --------- | -------------------------------------------- | -| `balance` | `(params: BalanceParams) => Promise` | +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L43) -Parameters: +### :factory: AccountIdentifier -- `params`: The parameters to get the balance of an account. +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L13) -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L59) +#### Methods -##### :gear: transfer +- [fromHex](#gear-fromhex) +- [fromPrincipal](#gear-fromprincipal) +- [toProto](#gear-toproto) +- [toHex](#gear-tohex) +- [toUint8Array](#gear-touint8array) +- [toNumbers](#gear-tonumbers) +- [toAccountIdentifierHash](#gear-toaccountidentifierhash) + +##### :gear: fromHex + +| Method | Type | +| --------- | ------------------------------------ | +| `fromHex` | `(hex: string) => AccountIdentifier` | + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L16) + +##### :gear: fromPrincipal -Transfers tokens from the sender to the given account. +| Method | Type | +| --------------- | ------------------------------------------------------------------------------------------------------- | +| `fromPrincipal` | `({ principal, subAccount, }: { principal: Principal; subAccount?: SubAccount; }) => AccountIdentifier` | -| Method | Type | -| ---------- | --------------------------------------------- | -| `transfer` | `(params: TransferParams) => Promise` | +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L20) -Parameters: +##### :gear: toProto -- `params`: The parameters to transfer tokens. +| Method | Type | +| --------- | ---------------------------------- | +| `toProto` | `() => Promise` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L72) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L49) -##### :gear: totalTokensSupply +##### :gear: toHex -Returns the total supply of tokens. +| Method | Type | +| ------- | -------------- | +| `toHex` | `() => string` | -| Method | Type | -| ------------------- | ------------------------------------------ | -| `totalTokensSupply` | `(params: QueryParams) => Promise` | +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L57) -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L88) +##### :gear: toUint8Array + +| Method | Type | +| -------------- | ------------------ | +| `toUint8Array` | `() => Uint8Array` | + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L61) + +##### :gear: toNumbers + +| Method | Type | +| ----------- | ---------------- | +| `toNumbers` | `() => number[]` | + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L65) + +##### :gear: toAccountIdentifierHash + +| Method | Type | +| ------------------------- | ------------------------- | +| `toAccountIdentifierHash` | `() => AccountIdentifier` | + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L69) + +### :factory: SubAccount + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L76) + +#### Methods -##### :gear: transferFrom +- [fromBytes](#gear-frombytes) +- [fromPrincipal](#gear-fromprincipal) +- [fromID](#gear-fromid) +- [toUint8Array](#gear-touint8array) -Transfers a token amount from the `from` account to the `to` account using the allowance of the spender's account (`SpenderAccount = { owner = caller; subaccount = spender_subaccount }`). The ledger draws the fees from the `from` account. +##### :gear: fromBytes -Reference: https://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-2/README.md#icrc2_transfer_from +| Method | Type | +| ----------- | -------------------------------------------- | +| `fromBytes` | `(bytes: Uint8Array) => SubAccount or Error` | -| Method | Type | -| -------------- | ------------------------------------------------- | -| `transferFrom` | `(params: TransferFromParams) => Promise` | +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L79) -Parameters: +##### :gear: fromPrincipal -- `params`: The parameters to transfer tokens from to. +| Method | Type | +| --------------- | -------------------------------------- | +| `fromPrincipal` | `(principal: Principal) => SubAccount` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L101) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L87) -##### :gear: approve +##### :gear: fromID -This method entitles the `spender` to transfer token `amount` on behalf of the caller from account `{ owner = caller; subaccount = from_subaccount }`. +| Method | Type | +| -------- | ---------------------------- | +| `fromID` | `(id: number) => SubAccount` | -Reference: https://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-2/README.md#icrc2_approve +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L100) -| Method | Type | -| --------- | -------------------------------------------- | -| `approve` | `(params: ApproveParams) => Promise` | +##### :gear: toUint8Array -Parameters: +| Method | Type | +| -------------- | ------------------ | +| `toUint8Array` | `() => Uint8Array` | -- `params`: The parameters to approve. +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L112) -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L123) +### :factory: LedgerCanister + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/ledger.canister.ts#L32) + +#### Methods + +- [create](#gear-create) +- [accountBalance](#gear-accountbalance) +- [transactionFee](#gear-transactionfee) +- [transfer](#gear-transfer) +- [icrc1Transfer](#gear-icrc1transfer) + +##### :gear: create + +| Method | Type | +| -------- | ----------------------------------------------------- | +| `create` | `(options?: LedgerCanisterOptions) => LedgerCanister` | + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/ledger.canister.ts#L43) + +##### :gear: accountBalance + +Returns the balance of the specified account identifier. + +If `certified` is true, the request is fetched as an update call, otherwise +it is fetched using a query call. + +| Method | Type | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `accountBalance` | `({ accountIdentifier, certified, }: { accountIdentifier: AccountIdentifier; certified?: boolean; }) => Promise` | + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/ledger.canister.ts#L75) + +##### :gear: transactionFee + +Returns the transaction fee of the ledger canister + +| Method | Type | +| ---------------- | ----------------------- | +| `transactionFee` | `() => Promise` | + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/ledger.canister.ts#L99) + +##### :gear: transfer -##### :gear: allowance +Transfer ICP from the caller to the destination `accountIdentifier`. +Returns the index of the block containing the tx if it was successful. -Returns the token allowance that the `spender` account can transfer from the specified `account`, and the expiration time for that allowance, if any. +| Method | Type | +| ---------- | ----------------------------------------------- | +| `transfer` | `(request: TransferRequest) => Promise` | -Reference: https://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-2/README.md#icrc2_allowance +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/ledger.canister.ts#L112) -| Method | Type | -| ----------- | ------------------------------------------------- | -| `allowance` | `(params: AllowanceParams) => Promise` | +##### :gear: icrc1Transfer -Parameters: +Transfer ICP from the caller to the destination `Account`. +Returns the index of the block containing the tx if it was successful. -- `params`: The parameters to call the allowance. +| Method | Type | +| --------------- | ---------------------------------------------------- | +| `icrc1Transfer` | `(request: Icrc1TransferRequest) => Promise` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ledger/src/ledger.canister.ts#L145) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/ledger.canister.ts#L142) From 6f8b7aa4bca890705b80216dc5691a6fe5137af9 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 10:37:16 +0200 Subject: [PATCH 11/25] chore: sync main --- .../ledger-icp/src/ledger.canister.spec.ts | 36 +++++++++---------- packages/ledger-icp/src/ledger.canister.ts | 5 +++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/ledger-icp/src/ledger.canister.spec.ts b/packages/ledger-icp/src/ledger.canister.spec.ts index 65bb98fd..79ad5e3e 100644 --- a/packages/ledger-icp/src/ledger.canister.spec.ts +++ b/packages/ledger-icp/src/ledger.canister.spec.ts @@ -651,7 +651,7 @@ describe("LedgerCanister", () => { Ok: BigInt(1234), }); const fee = BigInt(10_000); - const memo = new Uint8Array([3, 4, 5, 6]); + const icrc1Memo = new Uint8Array([3, 4, 5, 6]); const ledger = LedgerCanister.create({ certifiedServiceOverride: service, }); @@ -659,14 +659,14 @@ describe("LedgerCanister", () => { to, amount, fee, - memo, + icrc1Memo, }); expect(service.icrc1_transfer).toBeCalledWith({ to, fee: [fee], amount, - memo: [memo], + memo: [icrc1Memo], created_at_time: [], from_subaccount: [], }); @@ -703,7 +703,7 @@ describe("LedgerCanister", () => { Ok: BigInt(1234), }); const fee = BigInt(10_000); - const memo = new Uint8Array([3, 4, 5, 6]); + const icrc1Memo = new Uint8Array([3, 4, 5, 6]); const ledger = LedgerCanister.create({ certifiedServiceOverride: service, }); @@ -712,7 +712,7 @@ describe("LedgerCanister", () => { to, amount, fee, - memo, + icrc1Memo, createdAt, }); @@ -720,7 +720,7 @@ describe("LedgerCanister", () => { to, fee: [fee], amount, - memo: [memo], + memo: [icrc1Memo], created_at_time: [createdAt], from_subaccount: [], }); @@ -732,7 +732,7 @@ describe("LedgerCanister", () => { Ok: BigInt(1234), }); const fee = BigInt(10_000); - const memo = new Uint8Array(); + const icrc1Memo = new Uint8Array(); const fromSubAccount = new Uint8Array([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, @@ -744,7 +744,7 @@ describe("LedgerCanister", () => { to, amount, fee, - memo, + icrc1Memo, fromSubAccount, }); @@ -752,7 +752,7 @@ describe("LedgerCanister", () => { to, fee: [fee], amount, - memo: [memo], + memo: [icrc1Memo], created_at_time: [], from_subaccount: [fromSubAccount], }); @@ -764,7 +764,7 @@ describe("LedgerCanister", () => { Ok: BigInt(1234), }); const fee = BigInt(10_000); - const memo = new Uint8Array(); + const icrc1Memo = new Uint8Array(); const toSubAccount = new Uint8Array([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, @@ -779,7 +779,7 @@ describe("LedgerCanister", () => { }, amount, fee, - memo, + icrc1Memo, }); expect(service.icrc1_transfer).toBeCalledWith({ @@ -789,7 +789,7 @@ describe("LedgerCanister", () => { }, fee: [fee], amount, - memo: [memo], + memo: [icrc1Memo], created_at_time: [], from_subaccount: [], }); @@ -925,11 +925,11 @@ describe("LedgerCanister", () => { hardwareWallet: true, }); - const memo = new Uint8Array(); + const icrc1Memo = new Uint8Array(); await ledger.icrc1Transfer({ to, amount, - memo, + icrc1Memo, }); expect(service.transfer_fee).not.toBeCalled(); @@ -938,7 +938,7 @@ describe("LedgerCanister", () => { to, fee: [BigInt(10000)], amount, - memo: [memo], + memo: [icrc1Memo], created_at_time: [], from_subaccount: [], }); @@ -956,12 +956,12 @@ describe("LedgerCanister", () => { }); const fee = BigInt(990_000); - const memo = new Uint8Array(); + const icrc1Memo = new Uint8Array(); await ledger.icrc1Transfer({ to, amount, fee, - memo, + icrc1Memo, }); expect(service.transfer_fee).not.toBeCalled(); @@ -970,7 +970,7 @@ describe("LedgerCanister", () => { to, fee: [fee], amount, - memo: [memo], + memo: [icrc1Memo], created_at_time: [], from_subaccount: [], }); diff --git a/packages/ledger-icp/src/ledger.canister.ts b/packages/ledger-icp/src/ledger.canister.ts index 095352d9..6a9f3e75 100644 --- a/packages/ledger-icp/src/ledger.canister.ts +++ b/packages/ledger-icp/src/ledger.canister.ts @@ -128,6 +128,11 @@ export class LedgerCanister { return response.Ok; }; + // WARNING: When using the ICRC-1 interface of the ICP ledger, there is no + // relationship between the memo and the icrc1Memo of a transaction. The + // ICRC-1 interface simply cannot set the memo field and the non-ICRC-1 + // interface cannot set the icrc1Memo field, even though the icrc1Memo field + // is called just "memo" in canister method params. /** * Transfer ICP from the caller to the destination `Account`. * Returns the index of the block containing the tx if it was successful. From 481fb79bb5c4a17333eefb283a8765227a6d4713 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 10:38:49 +0200 Subject: [PATCH 12/25] feat: copy account related utils --- packages/ledger-icp/src/index.ts | 2 + .../src/utils/account_identifier.utils.ts | 42 +++++++++++++++++++ .../src/utils/accounts.utils.spec.ts | 17 ++++++++ .../ledger-icp/src/utils/accounts.utils.ts | 27 ++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 packages/ledger-icp/src/utils/account_identifier.utils.ts create mode 100644 packages/ledger-icp/src/utils/accounts.utils.spec.ts create mode 100644 packages/ledger-icp/src/utils/accounts.utils.ts diff --git a/packages/ledger-icp/src/index.ts b/packages/ledger-icp/src/index.ts index d44e3725..d9770499 100644 --- a/packages/ledger-icp/src/index.ts +++ b/packages/ledger-icp/src/index.ts @@ -3,3 +3,5 @@ export * from "./errors/ledger.errors"; export { ICP } from "./icp"; export { LedgerCanister } from "./ledger.canister"; export * from "./types/ledger.options"; +export * from "./utils/account_identifier.utils"; +export * from "./utils/accounts.utils"; \ No newline at end of file diff --git a/packages/ledger-icp/src/utils/account_identifier.utils.ts b/packages/ledger-icp/src/utils/account_identifier.utils.ts new file mode 100644 index 00000000..68f6c2df --- /dev/null +++ b/packages/ledger-icp/src/utils/account_identifier.utils.ts @@ -0,0 +1,42 @@ +import type { Principal } from "@dfinity/principal"; +import { + arrayOfNumberToUint8Array, + asciiStringToByteArray, + bigEndianCrc32, + uint8ArrayToHexString, +} from "@dfinity/utils"; +import { sha224 } from "@noble/hashes/sha256"; +import { Buffer } from "buffer"; +import type { AccountIdentifier } from "../types/common"; + +export const accountIdentifierToBytes = ( + accountIdentifier: AccountIdentifier, +): Uint8Array => + Uint8Array.from(Buffer.from(accountIdentifier, "hex")).subarray(4); + +export const accountIdentifierFromBytes = ( + accountIdentifier: Uint8Array, +): AccountIdentifier => Buffer.from(accountIdentifier).toString("hex"); + +export const principalToAccountIdentifier = ( + principal: Principal, + subAccount?: Uint8Array, +): string => { + // Hash (sha224) the principal, the subAccount and some padding + const padding = asciiStringToByteArray("\x0Aaccount-id"); + + const shaObj = sha224.create(); + shaObj.update( + arrayOfNumberToUint8Array([ + ...padding, + ...principal.toUint8Array(), + ...(subAccount ?? Array(32).fill(0)), + ]), + ); + const hash = shaObj.digest(); + + // Prepend the checksum of the hash and convert to a hex string + const checksum = bigEndianCrc32(hash); + const bytes = new Uint8Array([...checksum, ...hash]); + return uint8ArrayToHexString(bytes); +}; diff --git a/packages/ledger-icp/src/utils/accounts.utils.spec.ts b/packages/ledger-icp/src/utils/accounts.utils.spec.ts new file mode 100644 index 00000000..1f0fa0a3 --- /dev/null +++ b/packages/ledger-icp/src/utils/accounts.utils.spec.ts @@ -0,0 +1,17 @@ +import { InvalidAccountIDError } from "../errors/governance.errors"; +import { checkAccountId } from "./accounts.utils"; + +describe("accounts-utils", () => { + describe("checkAccountId", () => { + it("should not throw if valid account id", () => { + checkAccountId( + "cd70bfa0f092c38a0ff8643d4617219761eb61d199b15418c0b1114d59e30f8e", + ); + }); + + it("should throw if not valid account id", () => { + const call1 = () => checkAccountId("not-valid"); + expect(call1).toThrow(InvalidAccountIDError); + }); + }); +}); diff --git a/packages/ledger-icp/src/utils/accounts.utils.ts b/packages/ledger-icp/src/utils/accounts.utils.ts new file mode 100644 index 00000000..d767e5ee --- /dev/null +++ b/packages/ledger-icp/src/utils/accounts.utils.ts @@ -0,0 +1,27 @@ +import { bigEndianCrc32 } from "@dfinity/utils"; +import { InvalidAccountIDError } from "../errors/governance.errors"; + +/** + * Checks account id check sum + * + * @throws InvalidAccountIDError + */ +export const checkAccountId = (accountId: string): void => { + // Verify the checksum of the given address. + if (accountId.length != 64) { + throw new InvalidAccountIDError( + `Invalid account identifier ${accountId}. The account identifier must be 64 chars in length.`, + ); + } + + const toAccountBytes = Buffer.from(accountId, "hex"); + const foundChecksum = toAccountBytes.slice(0, 4); + const expectedCheckum = Buffer.from(bigEndianCrc32(toAccountBytes.slice(4))); + if (!expectedCheckum.equals(foundChecksum)) { + throw new InvalidAccountIDError( + `Account identifier ${accountId} has an invalid checksum. Are you sure the account identifier is correct?\n\nExpected checksum: ${expectedCheckum.toString( + "hex", + )}\nFound checksum: ${foundChecksum.toString("hex")}`, + ); + } +}; From a52a320cab345a0e9c1d485a7b9d8540b0d8e128 Mon Sep 17 00:00:00 2001 From: Formatting Committer Date: Mon, 2 Oct 2023 08:39:44 +0000 Subject: [PATCH 13/25] Updating formatting --- packages/ledger-icp/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ledger-icp/src/index.ts b/packages/ledger-icp/src/index.ts index d9770499..9df81341 100644 --- a/packages/ledger-icp/src/index.ts +++ b/packages/ledger-icp/src/index.ts @@ -4,4 +4,4 @@ export { ICP } from "./icp"; export { LedgerCanister } from "./ledger.canister"; export * from "./types/ledger.options"; export * from "./utils/account_identifier.utils"; -export * from "./utils/accounts.utils"; \ No newline at end of file +export * from "./utils/accounts.utils"; From 1cd278ec6b39a061fb71e8458cb02aaf11e6f93d Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 10:42:29 +0200 Subject: [PATCH 14/25] fix: build --- packages/ledger-icp/src/errors/ledger.errors.ts | 2 ++ packages/ledger-icp/src/index.ts | 2 +- packages/ledger-icp/src/utils/accounts.utils.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ledger-icp/src/errors/ledger.errors.ts b/packages/ledger-icp/src/errors/ledger.errors.ts index 85387782..eecf44de 100644 --- a/packages/ledger-icp/src/errors/ledger.errors.ts +++ b/packages/ledger-icp/src/errors/ledger.errors.ts @@ -9,6 +9,8 @@ export class TransferError extends Error {} export class InvalidSenderError extends TransferError {} +export class InvalidAccountIDError extends Error {} + export class InsufficientFundsError extends TransferError { constructor(public readonly balance: bigint) { super(); diff --git a/packages/ledger-icp/src/index.ts b/packages/ledger-icp/src/index.ts index d9770499..9df81341 100644 --- a/packages/ledger-icp/src/index.ts +++ b/packages/ledger-icp/src/index.ts @@ -4,4 +4,4 @@ export { ICP } from "./icp"; export { LedgerCanister } from "./ledger.canister"; export * from "./types/ledger.options"; export * from "./utils/account_identifier.utils"; -export * from "./utils/accounts.utils"; \ No newline at end of file +export * from "./utils/accounts.utils"; diff --git a/packages/ledger-icp/src/utils/accounts.utils.ts b/packages/ledger-icp/src/utils/accounts.utils.ts index d767e5ee..bce3eb65 100644 --- a/packages/ledger-icp/src/utils/accounts.utils.ts +++ b/packages/ledger-icp/src/utils/accounts.utils.ts @@ -1,5 +1,5 @@ import { bigEndianCrc32 } from "@dfinity/utils"; -import { InvalidAccountIDError } from "../errors/governance.errors"; +import { InvalidAccountIDError } from "../errors/ledger.errors"; /** * Checks account id check sum From 6a1cbdc48ab25cc0c8b9df510845c194e3f53076 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 10:43:01 +0200 Subject: [PATCH 15/25] fix: import --- packages/ledger-icp/src/utils/accounts.utils.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ledger-icp/src/utils/accounts.utils.spec.ts b/packages/ledger-icp/src/utils/accounts.utils.spec.ts index 1f0fa0a3..cd25c401 100644 --- a/packages/ledger-icp/src/utils/accounts.utils.spec.ts +++ b/packages/ledger-icp/src/utils/accounts.utils.spec.ts @@ -1,4 +1,4 @@ -import { InvalidAccountIDError } from "../errors/governance.errors"; +import { InvalidAccountIDError } from "../errors/ledger.errors"; import { checkAccountId } from "./accounts.utils"; describe("accounts-utils", () => { From 90383984bfd1273709e7e432adc7a1b1fd6972b8 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 11:40:34 +0200 Subject: [PATCH 16/25] docs: next release --- CHANGELOG.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aaa22bb..f4646ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 2023-XX-YY + +## Release +- nns `v2.0.0` +- ledger-icp `v1.0.0` + +## Breaking Changes ⚠️ + +- ICP ledger-related features have been relocated from `@dfinity/nns` to a new dedicated library called `@dfinity/ledger-icp` + # 1.0.0 (2023-10-02) ## Release @@ -11,10 +21,6 @@ - utils `v1.0.0` - nns-proto `v1.0.0` -## Breaking Changes ⚠️ - -- ICP ledger-related features have been relocated from `@dfinity/nns` to a new dedicated library called `@dfinity/ledger-icp` - ## Features - add support for `icrc2_transfer_from`, `icrc2_approve` and `icrc2_allowance` in `@dfinity/ledger` From b3a79559ddce0e1c02be3c612d3eaab1b735239f Mon Sep 17 00:00:00 2001 From: Formatting Committer Date: Mon, 2 Oct 2023 09:41:29 +0000 Subject: [PATCH 17/25] Updating formatting --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4646ced..3d0f6539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 2023-XX-YY ## Release + - nns `v2.0.0` - ledger-icp `v1.0.0` From 663692a78835c4e0038853c68f2ce487d8f3ccc1 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 12:38:22 +0200 Subject: [PATCH 18/25] build: ledger-icp size check --- package.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/package.json b/package.json index fab5fe18..5d39da3a 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,17 @@ "@dfinity/utils" ] }, + { + "name": "@dfinity/ledger-icp", + "path": "./packages/ledger-icp/dist/index.js", + "limit": "4 kB", + "ignore": [ + "@dfinity/agent", + "@dfinity/candid", + "@dfinity/principal", + "@dfinity/utils" + ] + }, { "name": "@dfinity/nns", "path": "./packages/nns/dist/index.js", From 4ee9a1251fed1d6ecce1f902d97b30e5d1c1e47b Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 12:38:45 +0200 Subject: [PATCH 19/25] build: ignore proto in ledger-icp --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d39da3a..b1bfd5fe 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,8 @@ "@dfinity/agent", "@dfinity/candid", "@dfinity/principal", - "@dfinity/utils" + "@dfinity/utils", + "@dfinity/nns-proto" ] }, { From df13b8c9c712a937ea907284ee7c36f6fb747f1a Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 12:42:25 +0200 Subject: [PATCH 20/25] build: adjust ledger-icp max size --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b1bfd5fe..cb31deaa 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ { "name": "@dfinity/ledger-icp", "path": "./packages/ledger-icp/dist/index.js", - "limit": "4 kB", + "limit": "15 kB", "ignore": [ "@dfinity/agent", "@dfinity/candid", From c25e783785add17f4be5f0f1005f527814df27cf Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 12:43:54 +0200 Subject: [PATCH 21/25] docs: add peer and remove ICP docs --- packages/ledger-icp/README.md | 2 +- scripts/docs.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ledger-icp/README.md b/packages/ledger-icp/README.md index 1723b3d8..81955a3d 100644 --- a/packages/ledger-icp/README.md +++ b/packages/ledger-icp/README.md @@ -24,7 +24,7 @@ npm i @dfinity/ledger-icp The bundle needs peer dependencies, be sure that following resources are available in your project as well. ```bash -npm i @dfinity/agent @dfinity/candid @dfinity/principal @dfinity/utils +npm i @dfinity/agent @dfinity/candid @dfinity/principal @dfinity/utils @dfinity/nns-proto ``` ## Usage diff --git a/scripts/docs.js b/scripts/docs.js index 9645bde8..748fb644 100644 --- a/scripts/docs.js +++ b/scripts/docs.js @@ -36,7 +36,6 @@ const ledgerInputFiles = [ const ledgerIcrcInputFiles = ["./packages/ledger/src/ledger.canister.ts"]; const ledgerICPInputFiles = [ - "./packages/nns/src/icp.ts", "./packages/nns/src/account_identifier.ts", "./packages/nns/src/ledger.canister.ts", ]; From e89b30b6077a43c029a72e20f1fb4c5d389807d2 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 12:45:09 +0200 Subject: [PATCH 22/25] feat: don't copy deprecated ICP --- packages/ledger-icp/src/icp.spec.ts | 56 ----------------------------- packages/ledger-icp/src/icp.ts | 50 -------------------------- packages/ledger-icp/src/index.ts | 1 - 3 files changed, 107 deletions(-) delete mode 100644 packages/ledger-icp/src/icp.spec.ts delete mode 100644 packages/ledger-icp/src/icp.ts diff --git a/packages/ledger-icp/src/icp.spec.ts b/packages/ledger-icp/src/icp.spec.ts deleted file mode 100644 index 0f6078a2..00000000 --- a/packages/ledger-icp/src/icp.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { FromStringToTokenError } from "@dfinity/utils"; -import { describe, expect, it } from "@jest/globals"; -import { ICP } from "./icp"; - -describe("ICP", () => { - it("can be initialized from a whole number string", () => { - expect(ICP.fromString("1")).toEqual(ICP.fromE8s(BigInt(100000000))); - expect(ICP.fromString("1234")).toEqual(ICP.fromE8s(BigInt(123400000000))); - expect(ICP.fromString("000001234")).toEqual( - ICP.fromE8s(BigInt(123400000000)), - ); - expect(ICP.fromString(" 1")).toEqual(ICP.fromE8s(BigInt(100000000))); - expect(ICP.fromString("1,000")).toEqual(ICP.fromE8s(BigInt(100000000000))); - expect(ICP.fromString("1'000")).toEqual(ICP.fromE8s(BigInt(100000000000))); - expect(ICP.fromString("1'000'000")).toEqual( - ICP.fromE8s(BigInt(100000000000000)), - ); - }); - - it("can be initialized from a fractional number string", () => { - expect(ICP.fromString("0.1")).toEqual(ICP.fromE8s(BigInt(10000000))); - expect(ICP.fromString("0.0001")).toEqual(ICP.fromE8s(BigInt(10000))); - expect(ICP.fromString("0.00000001")).toEqual(ICP.fromE8s(BigInt(1))); - expect(ICP.fromString("0.0000000001")).toEqual( - FromStringToTokenError.FractionalMoreThan8Decimals, - ); - expect(ICP.fromString(".01")).toEqual(ICP.fromE8s(BigInt(1000000))); - }); - - it("can be initialized from a mixed string", () => { - expect(ICP.fromString("1.1")).toEqual(ICP.fromE8s(BigInt(110000000))); - expect(ICP.fromString("1.1")).toEqual(ICP.fromE8s(BigInt(110000000))); - expect(ICP.fromString("12,345.00000001")).toEqual( - ICP.fromE8s(BigInt(1234500000001)), - ); - expect(ICP.fromString("12'345.00000001")).toEqual( - ICP.fromE8s(BigInt(1234500000001)), - ); - expect(ICP.fromString("12345.00000001")).toEqual( - ICP.fromE8s(BigInt(1234500000001)), - ); - }); - - it("returns an error on invalid formats", () => { - expect(ICP.fromString("1.1.1")).toBe(FromStringToTokenError.InvalidFormat); - expect(ICP.fromString("a")).toBe(FromStringToTokenError.InvalidFormat); - expect(ICP.fromString("3.a")).toBe(FromStringToTokenError.InvalidFormat); - expect(ICP.fromString("123asdf$#@~!")).toBe( - FromStringToTokenError.InvalidFormat, - ); - }); - - it("rejects negative numbers", () => { - expect(ICP.fromString("-1")).toBe(FromStringToTokenError.InvalidFormat); - }); -}); diff --git a/packages/ledger-icp/src/icp.ts b/packages/ledger-icp/src/icp.ts deleted file mode 100644 index bdf4680d..00000000 --- a/packages/ledger-icp/src/icp.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ICPTs } from "@dfinity/nns-proto"; -import { - ICPToken, - convertStringToE8s, - type FromStringToTokenError, - type Token, -} from "@dfinity/utils"; -import { importNnsProto } from "./utils/proto.utils"; - -/** - * We don't extend to keep `fromE8s` and `fromString` as backwards compatible. - * @deprecated - */ -export class ICP { - private constructor( - private e8s: bigint, - public token: Token, - ) {} - - public static fromE8s(amount: bigint): ICP { - return new ICP(amount, ICPToken); - } - - /** - * Initialize from a string. Accepted formats: - * - * 1234567.8901 - * 1'234'567.8901 - * 1,234,567.8901 - */ - public static fromString(amount: string): ICP | FromStringToTokenError { - const e8s = convertStringToE8s(amount); - if (typeof e8s === "bigint") { - return new ICP(e8s, ICPToken); - } - return e8s; - } - - public toE8s(): bigint { - return this.e8s; - } - - public async toProto(): Promise { - const { ICPTs: ICPTsConstructor } = await importNnsProto(); - - const proto = new ICPTsConstructor(); - proto.setE8s(this.e8s.toString()); - return proto; - } -} diff --git a/packages/ledger-icp/src/index.ts b/packages/ledger-icp/src/index.ts index 9df81341..4ccf938a 100644 --- a/packages/ledger-icp/src/index.ts +++ b/packages/ledger-icp/src/index.ts @@ -1,6 +1,5 @@ export { AccountIdentifier, SubAccount } from "./account_identifier"; export * from "./errors/ledger.errors"; -export { ICP } from "./icp"; export { LedgerCanister } from "./ledger.canister"; export * from "./types/ledger.options"; export * from "./utils/account_identifier.utils"; From 6812625c2472d9a2dba3785d6c522cb87b065658 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Oct 2023 10:46:59 +0000 Subject: [PATCH 23/25] =?UTF-8?q?=F0=9F=A4=96=20Documentation=20auto-updat?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ledger-icp/README.md | 51 ----------------------------------- 1 file changed, 51 deletions(-) diff --git a/packages/ledger-icp/README.md b/packages/ledger-icp/README.md index 81955a3d..17000779 100644 --- a/packages/ledger-icp/README.md +++ b/packages/ledger-icp/README.md @@ -55,57 +55,6 @@ const data = await metadata(); -### :factory: ICP - -We don't extend to keep `fromE8s` and `fromString` as backwards compatible. - -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L14) - -#### Methods - -- [fromE8s](#gear-frome8s) -- [fromString](#gear-fromstring) -- [toE8s](#gear-toe8s) -- [toProto](#gear-toproto) - -##### :gear: fromE8s - -| Method | Type | -| --------- | ------------------------- | -| `fromE8s` | `(amount: bigint) => ICP` | - -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L20) - -##### :gear: fromString - -Initialize from a string. Accepted formats: - -1234567.8901 -1'234'567.8901 -1,234,567.8901 - -| Method | Type | -| ------------ | --------------------------------------------------- | -| `fromString` | `(amount: string) => ICP or FromStringToTokenError` | - -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L31) - -##### :gear: toE8s - -| Method | Type | -| ------- | -------------- | -| `toE8s` | `() => bigint` | - -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L39) - -##### :gear: toProto - -| Method | Type | -| --------- | ---------------------- | -| `toProto` | `() => Promise` | - -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/icp.ts#L43) - ### :factory: AccountIdentifier [:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/nns/src/account_identifier.ts#L13) From 6cb79573becdf2f13e9a46ef5be9414e67c0b3ad Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 12:48:14 +0200 Subject: [PATCH 24/25] docs: order --- scripts/docs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/docs.js b/scripts/docs.js index 748fb644..b9eaae60 100644 --- a/scripts/docs.js +++ b/scripts/docs.js @@ -36,8 +36,8 @@ const ledgerInputFiles = [ const ledgerIcrcInputFiles = ["./packages/ledger/src/ledger.canister.ts"]; const ledgerICPInputFiles = [ - "./packages/nns/src/account_identifier.ts", "./packages/nns/src/ledger.canister.ts", + "./packages/nns/src/account_identifier.ts", ]; const ckBTCInputFiles = [ From 0c1597974f063c230e08156ee50e9c23c698b26d Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Mon, 2 Oct 2023 12:52:45 +0200 Subject: [PATCH 25/25] docs: add ledger-icp --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f81c5a96..be939057 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The libraries are still in active development, and new features will incremental - [sns](/packages/sns): interacting with a Service Nervous System (SNS) project - [cmc](/packages/cmc): interfacing with the **cmc** canister of the IC - [ledger](/packages/ledger): interacting with ICRC compatible **ledgers** +- [ledger-icp](/packages/ledger-icp): interfacing with the **ICP ledger** - [ckBTC](/packages/ckbtc): interfacing with **ckBTC** - [ic-management](/packages/ic-management): interfacing with the **IC management canister** - [utils](/packages/utils): a collection of utilities and constants