Skip to content

Commit f624495

Browse files
marc0oloclaude
andauthored
refactor(motoko/icp_transfer): call the ICP ledger via canister: import + --actor-id-alias (#1455)
Replace the hand-written ICP ledger actor type (and the hardcoded `actor("ryjl3-…")` reference) with a typed `canister:icp_ledger` import bound by moc's `--actor-id-alias`. All ledger types now come from the ledger's official Candid interface — nothing is hand-declared. Why `--actor-id-alias` (and not `--actor-env-alias` or generated bindings): the ICP ledger lives at the *same, fixed, well-known* principal (ryjl3-tyaaa-aaaaa-aaaba-cai) on both mainnet and the local development network. When the target id is fixed and universal, binding it at compile time is the simplest correct choice — no per-environment env-var injection needed (that's what `--actor-env-alias` is for), and no runtime `actor(id)` construction (that's for targets chosen at runtime). - candid/icp_ledger.did — the ledger's official interface, from the ledger-suite-icp-2025-08-29 release (`ledger.did`). - mops.toml — `--actor-id-alias icp_ledger ryjl3-tyaaa-aaaaa-aaaba-cai candid/icp_ledger.did`. - backend/app.mo — `import IcpLedger "canister:icp_ledger"`; types are now `IcpLedger.Tokens` / `IcpLedger.TransferArgs` / …; removed the hand-written Tokens/SubAccount/BlockIndex/AccountIdentifier/LedgerTransferArgs/TransferError/ TransferResult and the `actor("ryjl3-…")` line. Verified end-to-end: `icp deploy && bash test.sh` — all 3 tests pass (real ICP transfers via the imported ledger, with correct balance deltas). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5734c97 commit f624495

4 files changed

Lines changed: 619 additions & 41 deletions

File tree

motoko/icp_transfer/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ The example exposes three functions to make this concrete:
1414

1515
> The ICP ledger also supports the [ICRC-1](https://github.com/dfinity/ICRC-1) standard via `icrc1_transfer`. For new token integrations that don't require AccountIdentifier compatibility, ICRC-1 is the recommended interface. A comprehensive ICRC ledger example is planned.
1616
17+
## Calling the ICP ledger
18+
19+
The backend reaches the ledger through the typed import `import IcpLedger "canister:icp_ledger"` — no ledger types are declared in the code. Because the ICP ledger lives at the **same well-known principal** (`ryjl3-tyaaa-aaaaa-aaaba-cai`) on both mainnet and the local development network, the `--actor-id-alias` flag in `mops.toml` binds the import to that fixed id and types it against the ledger's official Candid interface (`candid/icp_ledger.did`). The request/response types (`IcpLedger.Tokens`, `IcpLedger.TransferArgs`, …) come straight from that interface.
20+
21+
> `--actor-id-alias` fits here because the target's id is *fixed and universal*. When a target's id varies per environment, `--actor-env-alias` resolves it from an injected env var instead; when the target is chosen at runtime, generate bindings and construct `actor(principal)` per call.
22+
23+
`candid/icp_ledger.did` is the ledger's own interface, taken from the [ICP ledger suite release](https://github.com/dfinity/ic/releases/tag/ledger-suite-icp-2025-08-29) (`ledger.did`). To refresh it after a new ledger release, download the `ledger.did` asset from that release.
24+
1725
## Build and deploy from the command line
1826

1927
### Prerequisites

motoko/icp_transfer/backend/app.mo

Lines changed: 16 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,48 +6,24 @@ import Result "mo:core/Result";
66
import Error "mo:core/Error";
77
import Principal "mo:core/Principal";
88

9+
// The ICP ledger is imported by name via `canister:icp_ledger`. It lives at the
10+
// same well-known principal (ryjl3-tyaaa-aaaaa-aaaba-cai) on both mainnet and the
11+
// local development network, so the mops.toml `--actor-id-alias` flag binds this
12+
// import to that fixed id and types it against the ledger's official Candid
13+
// interface (candid/icp_ledger.did) — no ledger types are declared here.
14+
import IcpLedger "canister:icp_ledger";
15+
916
actor IcpTransfer {
10-
// ICP Ledger types (matches the ICP ledger Candid interface)
11-
type Tokens = { e8s : Nat64 };
12-
type SubAccount = Blob;
13-
type BlockIndex = Nat64;
1417

18+
// Shared transfer logic.
19+
//
1520
// An AccountIdentifier is a 32-byte blob that encodes a principal and an
16-
// optional subaccount. It is the native account format used by the ICP ledger.
21+
// optional subaccount the native account format used by the ICP ledger.
1722
// Centralized exchanges (CEXs) identify accounts by this blob; wallets and
1823
// newer integrations prefer the ICRC-1 format (principal + subaccount directly).
19-
//
2024
// Use Principal.toLedgerAccount(subaccount) to compute one from a principal.
21-
type AccountIdentifier = Blob;
22-
23-
type TimeStamp = { timestamp_nanos : Nat64 };
24-
25-
type LedgerTransferArgs = {
26-
memo : Nat64;
27-
amount : Tokens;
28-
fee : Tokens;
29-
from_subaccount : ?SubAccount;
30-
to : AccountIdentifier;
31-
created_at_time : ?TimeStamp;
32-
};
33-
34-
type TransferError = {
35-
#BadFee : { expected_fee : Tokens };
36-
#InsufficientFunds : { balance : Tokens };
37-
#TxTooOld : { allowed_window_nanos : Nat64 };
38-
#TxCreatedInFuture;
39-
#TxDuplicate : { duplicate_of : BlockIndex };
40-
};
41-
42-
type TransferResult = { #Ok : BlockIndex; #Err : TransferError };
43-
44-
// The ICP ledger is a system canister available on both mainnet and
45-
// the local development network at this well-known principal.
46-
let icpLedger : actor { transfer : (LedgerTransferArgs) -> async TransferResult } = actor ("ryjl3-tyaaa-aaaaa-aaaba-cai");
47-
48-
// Shared transfer logic.
49-
func doTransfer(amount : Tokens, to : AccountIdentifier) : async Result.Result<BlockIndex, Text> {
50-
let transferArgs : LedgerTransferArgs = {
25+
func doTransfer(amount : IcpLedger.Tokens, to : IcpLedger.AccountIdentifier) : async Result.Result<IcpLedger.BlockIndex, Text> {
26+
let transferArgs : IcpLedger.TransferArgs = {
5127
memo = 0;
5228
amount;
5329
fee = { e8s = 10_000 };
@@ -59,7 +35,7 @@ actor IcpTransfer {
5935
created_at_time = null;
6036
};
6137
try {
62-
switch (await icpLedger.transfer(transferArgs)) {
38+
switch (await IcpLedger.transfer(transferArgs)) {
6339
case (#Err(e)) #err("Transfer failed: " # debug_show e);
6440
case (#Ok(blockIndex)) #ok blockIndex;
6541
};
@@ -70,15 +46,15 @@ actor IcpTransfer {
7046

7147
// Convert a principal and optional subaccount to its AccountIdentifier as a
7248
// lowercase hex string — the format shown in block explorers and CEX deposit screens.
73-
public query func toAccountIdHex(p : Principal, subaccount : ?SubAccount) : async Text {
49+
public query func toAccountIdHex(p : Principal, subaccount : ?IcpLedger.SubAccount) : async Text {
7450
let bytes = Array.fromIter(p.toLedgerAccount(subaccount).vals());
7551
Hex.toText(bytes);
7652
};
7753

7854
// Transfer ICP to a recipient identified by principal + optional subaccount.
7955
// Internally calls Principal.toLedgerAccount to derive the AccountIdentifier.
8056
// This is the most convenient form when you have a principal.
81-
public shared func transferToPrincipal(amount : Tokens, toPrincipal : Principal, toSubaccount : ?SubAccount) : async Result.Result<BlockIndex, Text> {
57+
public shared func transferToPrincipal(amount : IcpLedger.Tokens, toPrincipal : Principal, toSubaccount : ?IcpLedger.SubAccount) : async Result.Result<IcpLedger.BlockIndex, Text> {
8258
Debug.print("Transferring " # debug_show amount # " to principal " # debug_show toPrincipal);
8359
await doTransfer(amount, toPrincipal.toLedgerAccount(toSubaccount));
8460
};
@@ -91,7 +67,7 @@ actor IcpTransfer {
9167
// validates it and returns a clear error on mismatch. A fromHex helper with
9268
// CRC32 validation (equivalent to AccountIdentifier::from_hex in ic-ledger-types)
9369
// would be a valuable addition to a Motoko ICP library.
94-
public shared func transferToAccountId(amount : Tokens, toAccountIdHex : Text) : async Result.Result<BlockIndex, Text> {
70+
public shared func transferToAccountId(amount : IcpLedger.Tokens, toAccountIdHex : Text) : async Result.Result<IcpLedger.BlockIndex, Text> {
9571
switch (Hex.toArray(toAccountIdHex)) {
9672
case (#err(e)) #err("invalid hex: " # e);
9773
case (#ok(bytes)) {

0 commit comments

Comments
 (0)