Motoko payment and service marketplace library for ICP canisters. x402 charges, streaming sessions, encrypted content, paid services with a coordinator pattern, multi-token EVM settlement across 5 chains, and ERC-8004 agent identity on Base.
- Content — Upload encrypted blobs, gate with x402, deliver on payment
- Charges — Synchronous paid API calls (HTTP 402 → pay → 200)
- Services — Async coordinator: canister escrows funds, operator computes off-chain, canister verifies and settles
mops add ic402import Ic402 "mo:ic402";
import Principal "mo:base/Principal";
persistent actor MyService {
transient let gate = Ic402.Gateway(
{
recipient = { owner = Principal.fromActor(MyService); subaccount = null };
tokens = [{
ledger = Principal.fromText("xevnm-gaaaa-aaaar-qafnq-cai"); // ckUSDC
symbol = "ckUSDC"; decimals = 6;
}];
evmChains = [];
evmRpcCanister = null;
ecdsaKeyName = null;
nonceExpirySeconds = null;
},
Principal.fromActor(MyService),
);
// Charge for a service call
public shared(msg) func search(query : Text, sig : ?Ic402.PaymentSignature) : async {
#paymentRequired : [Ic402.PaymentRequirement];
#ok : Text;
} {
switch (sig) {
case (null) { #paymentRequired(gate.requireAll(1_000)) };
case (?s) {
switch (await gate.settleFrom(msg.caller, s, ?1_000)) { // the caller must be the payer (ICP)
case (#ok(_)) { #ok("Results for: " # query) };
case (_) { #paymentRequired(gate.requireAll(1_000)) };
};
};
};
};
var stableGateway : ?Ic402.StableGatewayState = null;
do { switch (stableGateway) { case (?d) { gate.loadStable(d) }; case (null) {} } };
system func preupgrade() { stableGateway := ?gate.toStable() };
system func postupgrade() { stableGateway := null };
gate.startTimers<system>();
};See example/main.mo for the full working example with all features.
┌─────────────────────────────────────────────────────────┐
│ Your Canister │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │
│ │ Charge │ │ Session │ │ Service Registry │ │
│ │ (x402) │ │ (Escrow +│ │ (Jobs, Verify, │ │
│ │ │ │ Vouchers)│ │ Settle) │ │
│ └────┬─────┘ └────┬─────┘ └────────┬───────────┘ │
│ │ │ │ │
│ ┌────▼──────────────▼─────────────────▼─────────────┐ │
│ │ Settlement (dual-chain) │ │
│ │ ICP: ICRC-2 transfer_from │ │
│ │ EVM: EVM RPC canister → getTransactionReceipt │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │
│ │ContentStore (opt)│ │EvmSigner + Identity (opt)│ │
│ │Encrypted storage │ │Remote signing + ERC-8004 │ │
│ └ ─ ─ ─ ─ ─ ─ ─ ─ ┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │
└─────────────────────────────────────────────────────────┘
x402 charge over HTTP:
Client → GET /content/x → 402 (ICP + EVM payment options)
Client → pay USDC (any chain)
Client → GET + X-PAYMENT header → 200 + content
Streaming sessions: deposit once → stream Ed25519 vouchers × N → close (settle + refund). A fixed few on-chain transactions (deposit, settle, refund), however many calls the deposit covers.
Paid services (coordinator):
Buyer ──[pay]──> Canister ──[assign]──> Your Client
│ │
│ escrow │ compute off-chain
│ │
[verify] <──[result+proof]─┘
│
[settle payment]
[refund remainder to buyer]
For outbound EVM operations (paying external x402 APIs, transfers, agent registration), the canister signs with tECDSA and the client handles RPC. This eliminates EVM RPC calls from the canister, reducing cycles 40-85%.
Client probes URL → canister signs → client retries with payment header
Client provides nonce+gas → canister signs tx → client broadcasts
| Feature | Description |
|---|---|
| x402 charges | Standard HTTP 402, works with any x402 client |
| Streaming sessions | Escrow + Ed25519 vouchers: a fixed few on-chain transactions (deposit, settle, refund), however many calls the deposit covers |
| Paid services | Coordinator pattern: escrow, assign, verify (ZK/hash/buyer), settle |
| Cross-rail settlement | Marketplace jobs and streaming sessions settle/refund on their native rail — ICP from the pool, or on-chain to the EVM payout address (confirmed broadcast) |
| EIP-712 signing | Generic typed data signing — DEX agent wallets, permits, any EIP-712 protocol |
| 5 EVM chains | Base, Ethereum, Avalanche, Optimism, Arbitrum — multiple tokens per chain, settlement keyed to the paid asset |
| Remote signing | Canister signs, client broadcasts — no EVM RPC dependency for outbound |
| Encrypted content | ChaCha20-Poly1305 at rest, 3 delivery patterns |
| ZK verification | Groth16/BN254 via reference Rust canister (~$0.005, 100-1000x cheaper than Ethereum) |
| Policy engine | Per-caller limits, rate limiting, session caps, daily budgets |
| Agent discovery | ERC-8004 on Base for cross-chain service registration |
An ICP canister replaces the HTTP server, the wallet, the escrow, and the payment processor. ic402 makes this a one-line import.
- HTTPS outcalls — verify EVM payments directly, no oracle or bridge
- tECDSA — native EVM address, remote signing for outbound operations
- HTTP serving — canister IS the server, standard x402 responses
- Persistent state — escrow, jobs, encrypted content survive upgrades
- Coordinator model — the canister IS the smart contract, no external escrow needed
Costs are bimodal — everything is cheap except signing and broadcasting an EVM transaction.
| Operation | Net cost |
|---|---|
| x402 verify / 402 / content delivery | trivial (query, no outcall) |
| ICP settle (ICRC‑2) | ~10–500M cycles (≈ <$0.001) |
| Session voucher | ~8M cycles when the call is made as the session key (SDK/MCP ≥ 2.17.0); ~420M on the legacy signed‑voucher path (≈ $0.0006, the in‑canister Ed25519 check); no outcall, no gas |
| EVM settle (sign + broadcast + confirm) | ~17B cycles local / ~40–80B mainnet est. (≈ $0.02–0.10) + EVM gas |
Per‑call EVM settle is underwater below ~$0.05–0.10 — use the ICP rail or sessions for micropayments. Measured numbers, the cycle buffer to hold, and rail‑selection guidance: docs/costs-and-rails.md.
The money paths are sound — recipient binding, exact value == amount, single‑use nonces, confirm‑before‑deliver. But two things you must know before deploying with real funds:
- The library is not secure‑by‑default. Four access‑control checks (policy‑mutation auth, roster redaction, cross‑resource underpayment, content gating) live only in
example/main.mo— copy them or inherit a price‑bypass / roster‑leak / content‑leak. - One trust root. Every admin/signer/recovery power is
Principal.isController, so a single stolen controller key = total EVM drain + arbitrary signing.
Read docs/security-model.md (integration checklist + key‑custody guidance) before going to mainnet.
git clone https://github.com/vhew/ic402.git && cd ic402
pnpm setup:local # install, start replica, deploy, fund
pnpm demo # interactive walkthrough (10 steps)Demo steps
- Configure — connect, derive tECDSA EVM address
- ADD Encrypted Content — upload, encrypt at rest
- SELL Content over x402 — hit paywall, pay with ICP or EVM, receive content
- DELETE Content — lifecycle management
- SELL Services over x402 — register service, buyer pays, your client computes, canister verifies (ZK/auto), settles
- BUY over x402 — canister signs, client pays external API (GoldRush)
- Streaming Micropayments — sessions: a fixed few on-chain transactions for many calls
- Agent Identity — ERC-8004 on Base
- EIP-712 Delegate Signing — generic typed data signing for DEX agent wallets (Hyperliquid, Vertex, Aevo)
- Policy + Summary — dual-sided spending limits
| Command | Covers |
|---|---|
mops test |
Motoko unit suites (16: gateway, sessions, escrow, serviceregistry, evmverify, evmsender, evmescrow, evmaddress, evmutils, eip712, nonce, policy, grant, contentstore, httphandler, utils) |
pnpm test:client |
TypeScript client SDK (@ic402/client, vitest) |
pnpm exec vitest run |
Root vitest — MCP guards + SSRF/security units (source-imported), plus the integration suite |
pnpm test:integration |
Replica-backed end-to-end (needs pnpm setup:local) |
bash scripts/setup-evm-outbound.sh then IC402_REQUIRE_EVM_OUTBOUND=1 pnpm exec vitest run test/evm-outbound.test.ts |
Hermetic EVM-outbound rail (sign → broadcast → confirm/park) against a scriptable EVM-RPC mock — no funded testnet |
The replica-backed suites return early (green) when their fixture isn't reachable. CI enforces them in dedicated jobs: test-integration runs with IC402_REQUIRE_REPLICA=1, and test-evm-outbound deploys the EVM-RPC mock and runs with IC402_REQUIRE_EVM_OUTBOUND=1, so a missing fixture is a hard failure rather than a silent skip.
| Method | Description |
|---|---|
requireAll(amount) |
Generate ICP + all EVM payment requirements |
settle(signature, expectedAmount?) |
Settle via ICRC-2 (ICP) or HTTPS outcall (EVM); expectedAmount binds the on-chain amount on the facilitator path. Candid endpoints: use settleFrom(msg.caller, …), which refuses an ICP payment the caller does not make |
verifyPayment(signature, expectedAmount, payTo, asset) |
Off-chain x402 verify verdict ({isValid, invalidReason?, payer?}) — no nonce, no broadcast, EVM only |
offerSession(intent) |
Create session offer |
openSession(...) |
Deposit escrow, create session |
consumeVoucher(voucher) |
Verify + consume session voucher (deprecated: prefer consumeVoucherFrom(msg.caller, voucher); refuses every voucher once setSignedVoucherFallback(false)) |
closeSession(caller, id) |
Settle consumed, refund remainder |
setPolicy(caller?, policy) |
Set spending policy |
setEvmChains(chains) / getEvmChains() |
Swap the accepted EVM chain/token set at runtime (e.g. Base Sepolia ↔ Base mainnet) — no redeploy; open sessions drain on their original chains. Transient: persist your choice consumer-side and re-apply after upgrade |
getGlobalPolicy() |
Read back the live global spending policy (example exposes it as the getPolicyConfig query) |
issueGrant(...) / verifyGrant(caller, grant) |
HMAC access grants (non-transferable — caller must equal grant.grantee) |
startTimers<system>() |
Start background timers (session expiry is self-arming: it runs only while sessions exist and disarms itself, so an idle canister burns no per-tick cycles — see costs §5) |
sessionExpiryTimerArmed() |
Whether the session sweep is ticking (false on an idle canister is the expected steady state) |
setSessionExpiryIntervalSeconds<system>(n) |
Sweep cadence, default 60s — bounds expiry latency and how long a stale session holds pool/concurrency capacity |
toStable() / loadStable(data) |
Upgrade persistence |
| Method | Description |
|---|---|
registerService(caller, def) |
Register a paid service |
enableService(caller, id) |
Activate for purchases |
listServices(enabledOnly) |
Service discovery |
submitRequest(buyer, serviceId, params, receipt, callback?) |
Create a paid job (buyer : Text — principal for ICP, 0x address for EVM) |
claimJob(caller, jobId) |
Operator claims work |
submitResult(caller, jobId, result, proof?, actualCost?) |
Submit + auto-verify |
confirmJob(buyer, jobId) |
Buyer confirms (BuyerConfirm) |
disputeJob(buyer, jobId, reason) |
Buyer disputes |
resolveDispute(jobId, refundBuyer) |
Admin: settle to operator or refund buyer (gate access) |
expireJobs() |
Timer: refund stale/disputed jobs |
armExpiryTimer<system>() |
Call on the job-creating path (the example calls it just before settle, keeping it out of the money-moved ⇒ job-exists window) so expiry starts at the 60s cadence instead of waiting for the hourly idle poll — createJobFromReceipt is sync and cannot arm it itself |
expiryTimerArmed() / expiryTimerActive() |
Whether the job sweep is armed at all / at its working cadence |
setExpiryIntervalSeconds<system>(n) / setExpiryIdlePollSeconds<system>(n) |
Working cadence (default 60s) and idle cadence (default 3600s; 0 disarms when there are no jobs — only if every call site arms it) |
toStable() / loadStable(data) |
Upgrade persistence |
Funds are custodied at the platform recipient account; settleJob pays the operator and refunds the buyer from there.
Verification methods: #AutoSettle, #HashMatch, #BuyerConfirm, #ZkGroth16 (external Rust verifier canister).
| Method | Description |
|---|---|
startTimers<system>() |
Seed the encryption key from canister randomness (call once after deploy) |
initExternalSeed(seed) |
Manually seed the key (alternative to startTimers) |
put(id, mimeType, data) |
Encrypt + store |
get(id) |
Decrypt + retrieve |
list() |
Content metadata |
Seeding is required. Writes trap until the master key is seeded with canister randomness (
startTimers()orinitExternalSeed(await raw_rand())). The key is then persisted across upgrades. This replaces the v1 deterministic key derived from the (public) canister principal.
| Method | Description |
|---|---|
signTypedData(domainSep, structHash) |
Sign arbitrary EIP-712 typed data (DEX agents, permits, any protocol) |
signErc20Transfer(...) |
Sign ERC-20 tx (client broadcasts) |
signEthTransfer(...) |
Sign ETH tx (client broadcasts) |
signEip3009Authorization(...) |
Sign x402 payment header |
signRegistration(...) |
Sign ERC-8004 registration tx |
getEvmAddress() |
Canister's tECDSA-derived EVM address |
Construct it one of two ways:
// The canister's own root key — the library default.
transient let signer = Ic402.EvmSigner.EvmSigner("key_1");
// A labelled derivation path — one key per purpose.
transient let payments = switch (
Ic402.EvmSigner.EvmSignerAt("key_1", [
Text.encodeUtf8("myapp"), Text.encodeUtf8("payments"),
Text.encodeUtf8("secp256k1"), Text.encodeUtf8("1"),
])
) { case (#ok(s)) { s }; case (#err(e)) { Debug.trap(e) } };The same applies to the other deriving sites — since 2.16.0 each has an …At form beside its
default, and all of them must use the same path for one address:
Ic402.EvmSender.EvmSenderAt("key_1", rpc, path) // outbound settlement
identity.getPublicKeyAt("key_1", path) // ERC-8004 agent identity
gate.setEvmDerivationPath(path) // the gateway's sender AND recipient
await gate.deriveEvmRecipientAt("key_1", path) // the inbound recipient payers are toldsetEvmDerivationPath is not persisted — re-apply it after every upgrade, in the same
init block that calls loadStable, exactly as setEvmChains requires. It refuses once the
recipient exists, and deriveEvmRecipientAt refuses a path the sender is not already on, so
the two can never drift apart.
Derive the recipient under one path and sign under another and the canister signs from an
address nothing funds, while deposits pile up at an address it never spends from. A CI gate
(scripts/check-derivation-paths.sh) fails the build if any library site reverts to a
hardcoded empty path.
One key per purpose; the empty path is the library default, not a decision. The choice is
permanent — the address derived under a path is published to payers, and a published
address must never move, so pick the path before anyone sends funds to it. There is no
setter and no per-call override for that reason. EvmSignerAt validates against
SchnorrSigner's own MAX_DERIVATION_PATH_ELEMENTS / MAX_DERIVATION_PATH_BYTES and
returns #err rather than trapping.
Threshold Schnorr beside EvmSigner's threshold ECDSA — Ed25519 (Solana, RFC8032) and
BIP340-secp256k1 (Bitcoin taproot, Nostr). A low-level primitive: no policy, no caps, no
audit log. Wrap it and enforce your own policy check and log entry before signing.
| Method | Description |
|---|---|
getPublicKey(algorithm, path) |
Derive a threshold public key + chain code. Costs no cycles |
sign(algorithm, path, message, aux) |
Sign a message. aux carries the BIP341 taproot tweak |
getBip340XOnlyKey(path) |
The 32-byte x-only key BIP340 verification needs |
signatureCost(algorithm) |
Cycles the next sign will attach, read from the replica |
The derivation path is a required parameter, never a default — a published key is
permanent once a customer uses it, so the choice is the caller's. Pass [] for the
canister's own root key.
transient let schnorr = Ic402.SchnorrSigner.SchnorrSigner("key_1");
switch (await schnorr.sign(#ed25519, [Text.encodeUtf8("agent-1")], message, null)) {
case (#ok(sig)) { /* 64 bytes */ };
case (#err(e)) { /* fails closed */ };
};Things that bite:
- Key names are shared with ECDSA —
dfx_test_key(local),test_key_1,key_1. The algorithm is a separate field, so one name serves both variants. bip340secp256k1returns a 33-byte SEC1-compressed key. BIP340 verification needs the 32-byte x-only form — drop the leading parity byte, or usegetBip340XOnlyKey.- With
aux = #bip341, the signature verifies against the TWEAKED output key, not the keygetPublicKeyreturns (that is BIP341'sinternal_pubkey). Apply the taproot tweak first.#bip341is rejected for#ed25519. - Ed25519 signatures are non-deterministic — the same message signs to different bytes each time. Verify; never compare signature bytes.
- Messages are signed whole, not hashed — unlike
sign_with_ecdsa's mandatory 32-byte digest, both Schnorr algorithms take arbitrary-length input. - No internal retry. A
SYS_UNKNOWN/CANISTER_ERRORreject does not prove no signature was produced, so retrying could yield two signatures for one authorised request.
Cost per signature is set by the subnet the KEY lives on, not the caller's, and is the same for
both algorithms (and for ECDSA): 10,000,000,000 cycles for test_key_1, 26,153,846,153 for
key_1. getPublicKey is free.
| Method | Description |
|---|---|
domainSeparator(name, version, chainId, contract) |
Build EIP-712 domain separator |
digest(domainSep, structHash) |
Compute EIP-712 message digest |
encodeTypeString(name, fields) / typeHashOf(name, fields) |
Canonical type string + typeHash from an ordered [(fieldName, solidityType)] — the reviewable artifact an audit layer should store |
encodeData(name, fields, values) / hashStructOf(name, fields, values) |
Field-driven struct encoding (FieldValue variants) — flat atomic types only (address/bool/string/bytes/bytes1..32/uint8..256); arrays, nested structs, and int* are rejected fail-closed, so what gets signed is auditable field-by-field, never an opaque hash |
| Method | Description |
|---|---|
EvmAddress.keccak256(bytes) |
Keccak-256 hash |
EvmAddress.keccak256Text(text) |
Keccak-256 of UTF-8 string |
EvmUtils.abiEncodeUint256(n) |
ABI encode a uint256 |
EvmUtils.hexToBytes(hex) / bytesToHex(bytes) |
Hex conversion |
These primitives enable consumers to build EIP-712 messages for any protocol (Hyperliquid, Vertex, Aevo, ERC-2612 permits) and sign them with the canister's tECDSA key.
| Method | Description |
|---|---|
getCard() |
Agent card metadata |
getEvmAddress() |
Canister's EVM address via tECDSA |
| Method | Description |
|---|---|
http402(requirements, resourceUrl) |
Build HTTP 402 response (x402 v2 PaymentRequired) |
paymentRequiredJson(requirements, resourceUrl, errorMsg?) |
Render the v2 PaymentRequired JSON object |
http402WithSettlement(settlementJson) |
402 carrying a v2 SettlementResponse (settlement failure on a paid request) |
settlementResponseJson(success, txHash?, network, payer, amount, errorReason?) |
Render the v2 SettlementResponse (emitted in PAYMENT-RESPONSE) |
verifyResponseJson(isValid, invalidReason?, payer?) |
Render the facilitator POST /verify response |
discoveryItemJson(resourceUrl, resType, acceptsJson) |
One entry in the GET /discovery/resources listing |
http200Json(json) / http200(body, mimeType) |
Build HTTP 200 |
http202Json(json) |
Build HTTP 202 Accepted (async services) |
httpUpgrade() |
Signal upgrade to update call |
parseX402PaymentHeader(base64) |
Parse x402 v2 header |
The canister IS the facilitator — it advertises and settles its own payments, no third party. The example (example/main.mo) wires the standard v2 facilitator endpoints over HTTP:
| Endpoint | Description |
|---|---|
GET /supported |
Advertise the (x402Version, scheme, network) kinds it can settle + signer address (Gateway.supportedJson) |
POST /verify |
Off-chain authorization verdict (Gateway.verifyPayment → verifyResponseJson) |
POST /settle |
Broadcast + settle on-chain (Gateway.settle → settlementResponseJson) |
GET /discovery/resources |
List paid resources with their v2 accepts[] (Bazaar discovery, non-minting) |
See docs/x402-compliance.md for the full v2 conformance status of the EVM rail.
src/ic402/ Motoko library (published to mops)
Gateway.mo Charges, settlement, sessions, policy
ServiceRegistry.mo Paid services: jobs, verification, settlement
EvmSigner.mo Remote EVM + EIP-712 signing (client broadcasts)
SchnorrSigner.mo Threshold Schnorr: Ed25519 + BIP340-secp256k1
Eip712.mo EIP-712 typed data hashing (domain separators, digests)
EvmAddress.mo EVM address derivation + keccak256
EvmUtils.mo ABI encoding, hex conversion, byte utilities
EvmSender.mo EVM execution (internal, for inbound settlement)
HttpHandler.mo x402 HTTP response helpers
ContentStore.mo Encrypted blob storage (optional)
Identity.mo ERC-8004 agent metadata (optional)
Types.mo Shared types
example/ Example canister + interactive demo
main.mo Reference implementation (all features, 10-step demo)
client/ Interactive demo client
zk-verifier/ Reference Groth16 verifier (Rust, optional)
evm-rpc-mock/ Scriptable EVM-RPC mock (hermetic EVM-outbound tests)
packages/client/ TypeScript SDK (@ic402/client)
integrations/mcp/ MCP server (@ic402/mcp)
ic402 provides a generic signTypedData primitive for any protocol using EIP-712 typed data signatures. This is the building block for:
- Hyperliquid agent wallet registration + phantom agent order signing
- Vertex linked signer + order signing
- Aevo signing key registration + order signing
- ERC-2612 permit signatures for gasless token approvals
- Any EIP-712 protocol — the canister signs, your client submits
// Build domain separator and struct hash client-side (keccak256 + ABI encoding)
// Only the signing call goes to the canister
let result = await signer.signTypedData(domainSeparator, structHash);
// → { signature, signer, digest, v, r, s }The consuming canister (e.g., EngramX) computes EIP-712 messages for the target protocol and calls signTypedData for the tECDSA signature. ic402 provides the crypto primitives (Eip712, EvmAddress.keccak256, EvmUtils), the consumer provides the protocol-specific message formatting.
For services requiring trustless verification, deploy a Groth16 verifier canister alongside your ic402 canister. See example/zk-verifier/ for a reference implementation using arkworks.
- Cost: ~$0.005 per Groth16 verification (100-1000x cheaper than Ethereum)
- The ic402 library defines the
ZkVerifierActorinterface; you provide the verifier canister - Test fixtures included: proof + verification key for circuit "x² = 25, x = 5"
Dev setup, project layout, and conventions: CONTRIBUTING.md. Cutting a release — version bump, the stable-schema gate, publishing to mops + npm: RELEASING.md.
- Questions, integration help, design discussion: Discord
- Bugs and feature requests: GitHub Issues
- Security vulnerabilities: never in a public issue — use the private process in SECURITY.md
Participation is governed by our Code of Conduct.