Skip to content

Inter-canister trade settlement - #38

Draft
ktimam wants to merge 4 commits into
icppWorld:mainfrom
ktimam:feature/inter-canister-trade-settlement
Draft

Inter-canister trade settlement#38
ktimam wants to merge 4 commits into
icppWorld:mainfrom
ktimam:feature/inter-canister-trade-settlement

Conversation

@ktimam

@ktimam ktimam commented May 18, 2026

Copy link
Copy Markdown

Inter-canister trade settlement � IC_Call helper

Summary

Adds a high-level C++ API for inter-canister calls to icpp-pro. Before this
PR, an icpp-pro canister could only be called; it had no clean way to
call out to another canister without writing raw ic0_call_* imports and
manually shepherding env cookies through reply/reject WASM callbacks.

This is the prerequisite for the inter-canister trade settlement work in
the ICSoccerWorld marketplace (ICMarketplace), where every buy_listing,
accept_offer, place_bid and settle_auction needs to:

  1. Pull payment via icrc2_transfer_from on a currency ledger.
  2. Move the NFT via icrc37_transfer_from on the collection canister.
  3. Disburse seller_receives + royalty + protocol_fee via three
    icrc1_transfer calls.
  4. Compensate (refund) on any partial failure.

All of those require async outbound calls from an update handler, which
this PR makes ergonomic.

What the PR adds

File Purpose
src/icpp/ic/icapi/ic_call.h Public API: IC_Call::call(...), IC_Call::raw_call(...), IC_CallBuilder, CallReject, CallRejectCode.
src/icpp/ic/icapi/ic_call.cpp Implementation: env-cookie registry, exported __icpp_call_reply_trampoline / __icpp_call_reject_trampoline WASM callbacks, Candid arg serialisation, principal-bytes extraction.
test/canisters/canister_call/ End-to-end test canister that performs a self-call: ping_self("hi") issues an inter-canister call to its own echo(text) method and finishes the original message from inside the on_reply callback.

No build-script changes are needed � config_default.py already globs
ic/icapi/*.cpp and ic/icapi/*.h automatically.

API surface

// Low-level
uint32_t IC_Call::call(
    const CandidTypePrincipal &callee,
    const std::string         &method,
    const CandidArgs          &args,
    std::function<void(const VecBytes&)>     on_reply,
    std::function<void(const CallReject&)>   on_reject);

// Textual-principal convenience overload
uint32_t IC_Call::call(const std::string &callee_text, ...);

// Fluent builder
IC_CallBuilder(callee, "icrc1_transfer")
    .with_args(args)
    .on_reply ([](const VecBytes& b) { /* decode + continue */ })
    .on_reject([](const CallReject& r){ /* refund + abort   */ })
    .perform();

Return value of perform() / call() is the ic0.call_perform system code:
0 = queued, non-zero = system-level failure (call was never sent, no
callback will run).

Design notes

  • Env-cookie registry � every outbound call gets a monotonically
    increasing uint32_t cookie. The cookie is passed unmodified through
    ic0.call_new as the reply/reject env and is used to find the right C++
    handler in an std::unordered_map<uint32_t, Pending> when the IC fires
    the trampoline.

  • Trampolines__icpp_call_reply_trampoline(env) and
    __icpp_call_reject_trampoline(env) are exported as canister_callback
    symbols. They're the only WASM-table entries the IC ever sees; they
    immediately dispatch into the C++ map.

  • No state on the message stack � the original update message returns
    before the callee replies. State that needs to survive must live on the
    canister heap (typically the *Storage singleton, mirroring how the
    marketplace already keeps its in-flight trades).

  • Failure model � if ic0.call_perform returns non-zero, the cookie is
    removed before raw_call returns. If the callee traps or rejects, the
    registered on_reject is invoked with the IC's reject code + message.

  • Upgrade safetyIC_Call::clear_pending() drops every callback. User
    code should call it from their pre_upgrade hook (and persist any
    trade-state they want to resume in post_upgrade). This matches the
    existing pattern for set_timer continuations.

Test plan

The new canister_call test canister exercises the round trip end-to-end:

  1. pytest --network=local deploys the canister and calls echo("hello")
    directly to confirm the callee works.
  2. Calls ping_self("ic_call_works") and polls get_last_echoed until the
    echo round-trips through the inter-canister reply callback.
  3. Confirms get_pending_count is 0 after the call settles, proving the
    env cookie was freed (no leak).

Run from the canister directory:

cd test/canisters/canister_call
icpp build-wasm
dfx start --clean --background
dfx deploy
pytest --network=local

Backwards compatibility

  • Additive only. No existing icpp-pro APIs are touched.
  • No new public exports clash with user canisters � both trampolines are
    prefixed __icpp_.
  • WASM size impact: a few hundred bytes for the map + dispatcher; trampolines
    are inlinable.

Downstream work that depends on this PR

The ICMarketplace canister (https://github.com/ktimam/ICSoccerWorldServer)
has a parallel branch feature/phase-1b-inter-canister-trade-settlement
that:

  1. Adds an InFlightTrade state machine to MarketplaceStorage.
  2. Replaces the TODO Phase 1B comment blocks in MarketplaceServer.cpp
    (in buy_listing, make_offer, accept_offer, cancel_offer,
    place_bid, settle_auction) with real IC_Call invocations.
  3. Adds idempotent memo-tagged retries (memo = sha256(trade_id || step)).
  4. Adds an end-to-end test that asserts ledger balances actually move and
    the ICRC-7 owner flips.

That PR is blocked on this one merging.

Checklist

  • New files compile against icpp-pro main (5.4.1).
  • No build-script changes needed (auto-globbed).
  • IC_Call::pending_count() exposed for diagnostics.
  • IC_Call::clear_pending() exposed for pre_upgrade hooks.
  • End-to-end test canister canister_call added with pytest suite.
  • Failure paths return system code so user code can branch on
    "queued vs not queued".
  • Reject path forwards IC reject code + message to user handler.
  • (reviewer) Confirm WASM-size delta < 1 KiB on a minimal greet canister.
  • (reviewer) Confirm trampoline name __icpp_call_* doesn't collide with
    any reserved icpp internals.

@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 987b5619-aaff-4de5-9cc4-7f1aaff236b5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- canister_init.cpp: invoke __wasm_call_ctors() exactly once from the
  first CanisterInit construction (wasm builds only). Without this, C++
  global ctors never run for canisters that have any non-zero-initialised
  globals (for example a `static std::string g_x = "v";`) and accessing
  them traps at runtime.

- ic_call.cpp: rename exported trampolines from
  `canister_callback __icpp_*` to plain `__icpp_call_reply_trampoline`
  / `__icpp_call_reject_trampoline`. The IC reserves the `canister_`
  prefix for standard entry points and rejects modules that re-use it;
  trampolines are referenced by function-table index, not by exported
  name, so they don't need it.

- ic_api.h: document the from_wire(CandidType) by-value gotcha and the
  required `CandidTypeText t(&str)` pointer-style idiom.

- test/canisters/canister_call: align with icpp_candid 5.4 API
  (`.get_v()`), single-space `canister_query` export prefix, drop the
  disallowed `ic_api.to_wire()` call inside `canister_init`.
@icppWorld icppWorld self-assigned this May 20, 2026
ktimam and others added 2 commits June 12, 2026 15:46
The mock header declared ic0_msg_reject_ic0_msg_size /
ic0_msg_reject_ic0_msg_copy (a bad search-and-replace), while ic0.cpp
defines and ic_call.cpp calls ic0_msg_reject_msg_size /
ic0_msg_reject_msg_copy. Any icpp build-native of a canister using
IC_Call failed to compile ic_call.cpp with undeclared-identifier errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An upgraded wasm instance never executes canister_init, so globals with
dynamic initializers stayed zero-initialized after --mode upgrade. The
ic_call pending-callback unordered_map was left with max_load_factor ==
0.0, which makes libc++ double its bucket array on EVERY insert: each
inter-canister call doubled the wasm heap (buckets ~ 2^calls) until the
canister exceeded its 3 GiB wasm_memory_limit (deterministic 3.37 GiB
peak after ~28 calls) and every subsequent update failed until
reinstall.

- icpp_run_global_ctors_once(): shared once-per-instance guard, declared
  in ic_api.h, called from both CanisterBase constructors so init,
  post_upgrade, update, query, and callback entries all trigger it.
  Canisters whose upgrade hooks don't construct an IC_API should call it
  directly (and never raw __wasm_call_ctors(), which re-runs ctors and
  resets restored globals).
- canister_init.cpp: drop the local CtorRunner in favour of the shared
  guard.
- ic_call.cpp: make the pending-callback registry a function-local
  static so it is guard-initialized on first use and immune to the
  global-ctor wiring; fix mojibake and a stale comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants