htmls
funnAI · Simplified ICP Top-Up — Proposed Protocol-Canister Changes

Simplified ICP Top-Up — Proposed Protocol-Canister Changes

GameState canister · funnAI / PoAIW

Source discussion: OpenChat thread (Lorimer · icpp_pro · patnorris) — Plan approved & ready for implementation

Status: review complete — ready for implementation. The OpenChat thread reached agreement in principle, and patnorris has reviewed this doc. His two questions are resolved: (1) why a new notifyTopUp rather than updating topUpCyclesForAnyMainerAgent? — see D5 in §Key decisions; and (2) since verifyIncomingPayment changes, test every path that touches it — covered by the regression suite in §Testing. No open design questions remain; implementation of the refactor can begin.

Net effect: a mAIner is identified by the ICP transfer memo, not by a call argument. Top-ups become a single permissionless notifyTopUp({ blockId }) call. This enables Jupiter-Faucet perpetual funding and any other 3rd-party app that wants to manage mAIner TopUps.

Also includes a prerequisite bug fix: verifyIncomingPayment must follow ICP-ledger archive blocks (see Implementation). Without it, both the new endpoint and the recovery of already-archived top-ups fail with #InvalidId. Deploy with the fix included! (Note to Lorimer: this bug-fix will unlock your previous stuck topups…)

Deferred: an automatic, timer-based sweep of ICP transfers into the GameState account is not part of this initial implementation. We keep it as simple as possible to unlock the Jupiter Faucet app.

1 · Design overviewflow
Caller — Jupiter Faucet · other 3rd-party apps ICP Ledger canister ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_transfer({ to, memo }) to = GameState acct · memo = <mAIner ID> (*) GameState ICP account ICP credited here returns blockId GameState canister r5m5y-diaaa-aaaaa-qanaa-cai notifyTopUp({ blockId }) resolve the mAIner from the memo resolveMainerByPrefix(memo) match → top up mAIner no / ambiguous → #InvalidId record block · double-spend guard query_blocks(blockId) * Provide at least the first 8 characters of the mAIner ID, '-' included (e.g. "2r3eo-5q"); the full ID also works. 8-char minimum: Jupiter Faucet packs <GameState-id>.<mAIner> into one 32-byte memo, leaving 8 chars for the mAIner ID. ↗ live example on the Jupiter Faucet memo tracker

In GameState canister, the notify path reuses the existing topup implementation logic. The processTopUpCyclesForMainer / handleIncomingFunds / checkExistingTransactionBlock / putRedeemedTransactionBlock machinery is reused unchanged — only the resolution of which mAIner and the trigger change.

Reference & live validation. Jupiter Faucet already documents and exercises this exact flow: how it works · memo tracker. Its neuron memo r5m5ydiaaaaaaaaqanaacai.2r3eo-5q splits on the . into where = r5m5y-diaaa-aaaaa-qanaa-cai (GameState prd, dashes stripped to fit) and who = 2r3eo-5q (mAIner prefix). The faucet forwards only the who part to GameState, so GameState receives 2r3eo-5q verbatim.
2 · Background & rationalecontext
  • Lorimer's proposal: identify the mAIner by the ICP transfer's memo — no caller-supplied address. The sender (Jupiter Faucet, or any app) then notifies the protocol with the block id. "Super simple UX."
  • Prefix, not full id: the memo only needs to unambiguously identify a mAIner — the first 8 characters of the canister id suffice. This is what makes the Jupiter Faucet perpetual-funding integration fit, because that flow must pack both where (GameState principal) and who (mAIner) into one memo, leaving 8 chars for the mAIner. The protocol maps prefix → full principal trivially; an external system cannot without undesirable coupling.
3 · ImplementationMain.mo · Types.mo
Summary of the change

From → To

AspectToday (implemented)Proposed
Trigger topUpCyclesForAnyMainerAgent({ blockId, mainerAgentAddress }) notifyTopUp({ blockId }) — no address, permissionless
mAIner identity Caller passes mainerAgentAddress as a call argument Derived from the transfer memo: an unambiguous prefix of the mAIner canister id
Who sends ICP Any 3rd-party app Jupiter Faucet or any 3rd-party app
Unmatched / leftover ICP N/A (rejected before send, or stuck) Notify rejects with #InvalidId; ICP stays in GameState (a future sweep could collect it)
Types.mo — notify input

Add the notify input (block id only). No other type changes — the existing RedeemedForOptions.#MainerTopUp is reused.

// Notify input: the mAIner is derived from the block's memo, not passed in.
public type NotifyTopUpInput = { paymentTransactionBlockId : Nat64 };

TopUpResult / TopUpRecord already exist and are reused for notifyTopUp.

Main.mo — memo → mAIner resolver

New private helper. Iterates mainerAgentCanistersStorage (the authoritative HashMap<Text, OfficialMainerAgentCanister>) and returns the unique mAIner whose textual canister id starts with the supplied prefix. Ambiguous or empty → null → notify rejects with #InvalidId.

// Decode the icrc1_memo to the mAIner id-prefix (ASCII, dashes included,
// e.g. "2r3eo-5q"). Matched verbatim against the canister id — no normalisation.
private func memoToPrefix(tx : TokenLedger.CandidTransaction) : ?Text {
    switch (tx.icrc1_memo) {
        case (?b) { Text.decodeUtf8(b) };            // ASCII id-prefix, '-' required
        case (null) { null };
    };
};

// Unique, unambiguous prefix match across all registered mAIners.
private func resolveMainerByPrefix(prefix : Text) : ?Types.OfficialMainerAgentCanister {
    if (Text.size(prefix) < MIN_MEMO_PREFIX_LEN) { return null };
    var hit : ?Types.OfficialMainerAgentCanister = null;
    for (m in mainerAgentCanistersStorage.vals()) {
        if (Text.startsWith(m.address, #text prefix)) {  // literal match, '-' included
            switch (hit) {
                case (null) { hit := ?m };
                case (?_)  { return null };           // ambiguous → #InvalidId
            };
        };
    };
    hit;
};
reuses: mainerAgentCanistersStorage new const: MIN_MEMO_PREFIX_LEN = 8 no new stable state
Main.mo — notifyTopUp endpoint

Permissionless notify: takes only a block id, resolves the mAIner from the block's memo, then delegates to the existing shared helper.

public shared (msg) func notifyTopUp(input : Types.NotifyTopUpInput) : async Types.TopUpResult {
    if (Principal.isAnonymous(msg.caller)) { return #Err(#Unauthorized); };
    if (PAUSE_PROTOCOL and not Principal.isController(msg.caller)) {
        return #Err(#Other("Protocol is currently paused"));
    };
    // Cheap double-spend guard — runs before any ledger call
    if (checkExistingTransactionBlock(input.paymentTransactionBlockId)) {
        return #Err(#Other("Already redeemd this transaction block"));
    };
    // Fetch the block ONCE (archive-aware); reused by the verify step (no second query_blocks)
    let tx = switch (await fetchLedgerTransaction(input.paymentTransactionBlockId)) {
        case (?t) { t };
        case (null) { return #Err(#InvalidId); };
    };
    let mainerEntry = switch (Option.chain(memoToPrefix tx, resolveMainerByPrefix)) {
        case (?m) { m };
        case (null) { return #Err(#InvalidId); };    // memo didn't resolve to a mAIner
    };
    switch (await processTopUpCyclesForMainer({
        caller = msg.caller;
        paymentTransactionBlockId = input.paymentTransactionBlockId;
        mainerEntry;
        ledgerTx = tx;                                // reuse the already-fetched block — no re-query
    })) {
        case (#Ok(r)) { #Ok({ cyclesAdded = r.cyclesAdded; mainerAgentAddress = r.mainerEntry.address }); };
        case (#Err(e)) { #Err(e); };
    };
};

Anonymous is still rejected (consistent with existing endpoints). "Any caller" is safe because the memo names the target mAIner — a caller can only trigger the top-up the sender already intended.

Main.mo — fix: verify archived ICP blocks (prerequisite)

Bug. verifyIncomingPayment fetches the block with ICP_LEDGER_ACTOR.query_blocks and returns #Err(#InvalidId) when its blocks array is empty (Main.mo:5172). Once a block leaves the ledger's live window it is moved to an archive canister — query_blocks then returns empty blocks plus an archived_blocks range — so verification wrongly fails for every archived block.

Impact. This breaks every payment-verifying path: the new notifyTopUp / processTopUpCyclesForMainer and the admin recovery completeTopUpCyclesForMainerAgentAdmin. A Jupiter Faucet disbursal notified after its block is archived would fail with #InvalidId.

Fix. Extract an archive-aware fetchLedgerTransaction(blockId) helper; when the live window misses, follow the matching archived_blocks range's callback (get_blocks on the archive canister). Callers fetch the block once with it and pass the result down — verifyIncomingPayment no longer queries the ledger itself (also removes the duplicate query_blocks, see Cycle drain in §Security).

private func fetchLedgerTransaction(blockId : Nat64) : async ?TokenLedger.CandidTransaction {
    let args : TokenLedger.GetBlocksArgs = { start = blockId; length = 1 };
    let resp = await ICP_LEDGER_ACTOR.query_blocks(args);
    if (resp.blocks.size() >= 1) { return ?resp.blocks[0].transaction };   // still in the live window
    // archived: find the range holding this block and query its archive canister
    for (range in resp.archived_blocks.vals()) {
        if (blockId >= range.start and blockId < range.start + range.length) {
            switch (await range.callback(args)) {
                case (#Ok(r)) { if (r.blocks.size() >= 1) { return ?r.blocks[0].transaction } };
                case (#Err(_)) {};
            };
        };
    };
    null;
};

// ProcessTopUpInput carries the already-fetched block: add  ledgerTx : TokenLedger.CandidTransaction
// verifyIncomingPayment takes it instead of calling query_blocks itself — drop the fetch +
// empty check (Main.mo:5164-5176) and read the operation/amount straight off input.ledgerTx.
// Each caller (notifyTopUp, the gated endpoint, the admin recovery) does the single fetch:
let tx = switch (await fetchLedgerTransaction(blockId)) {
    case (?t) { t };
    case (null) { return #Err(#InvalidId); };        // also covers archived blocks now
};
Rollout & recovery — order matters. Deploy this change with the fix included, then recover the stuck Jan-21 top-ups (user sent ICP, top-up never completed) by calling completeTopUpCyclesForMainerAgentAdmin once per block — 33_125_476, 33_124_011, 33_123_564 (1 ICP each). Before the fix these calls return #InvalidId because the blocks are already archived.
Main.mo — concurrency guard (claim-before-await)

Closes the double-redemption TOCTOU (see §Security). The block id is claimed in the same synchronous slice as the double-spend check — before any await — so a concurrent call sees the claim and bails. Motoko runs a message atomically between awaits, which is what makes this correct. The claim is released on every failure path so a failed attempt can be retried.

// transient: in-flight claims; entries are short-lived and need not survive upgrades
transient var inFlightBlocks = TrieSet.empty<Nat64>();

// in notifyTopUp — same slice as the check, no await in between:
if (checkExistingTransactionBlock(blockId) or TrieSet.mem(inFlightBlocks, blockId, hashN64 blockId, Nat64.equal)) {
    return #Err(#Other("Already redeemd this transaction block"));
};
inFlightBlocks := TrieSet.put(inFlightBlocks, blockId, hashN64 blockId, Nat64.equal);   // claim

let result = try {
    // ... fetch once, resolve, processTopUpCyclesForMainer (records via putRedeemedTransactionBlock on success) ...
} catch (e) { #Err(#Other(Error.message(e))) };

inFlightBlocks := TrieSet.delete(inFlightBlocks, blockId, hashN64 blockId, Nat64.equal);  // release (success or fail)
result;

A trap (not a catchable reject) rolls state back to the last await commit, so structure the credit + record so a trap can't leave a delivered top-up without its redeemed-block record.

4 · Backward compatibility & deprecationmigration
EndpointDisposition
topUpCyclesForMainerAgent (gated)Keep as-is — serves default funnAI frontend.
topUpCyclesForAnyMainerAgent (requires mAIner ID)Remove. Will be superseded by new notifyTopUp. Is not in active use today, so removing is ok.
Reviewer question (patnorris): why a new notifyTopUp instead of just updating topUpCyclesForAnyMainerAgent? See D5 in §Key decisions — short answer: the signature changes anyway (the mainerAgentAddress argument is dropped), the old endpoint has no live callers to preserve, and an intention-revealing name signals the new permissionless, memo-based semantics to 3rd-party callers.
5 · Securityrisks

Adversarial risks on the new notifyTopUp path and how each is addressed.

RiskSeverityMitigation
Cycle drain / DoS — spam notifyTopUp to burn GameState cycles via repeated query_blocks Low–moderate (griefing only; no funds at risk) Cheap guards (anonymous, pause, double-spend) run before any ledger call, and the block is fetched once, so per-call cost is small and bounded. Further options:
  • Per-caller rate limit / throttle on calls that don't end in a redemption.
  • Whitelist callers — only whitelisted principals may call notifyTopUp; only an admin (controller) can change the whitelist. Start by whitelisting the Jupiter Faucet canister. Closes the vector entirely; trades off the open "any 3rd-party app" model.
6 · Correctnessreview
  • Idempotent notify: duplicate notifyTopUp calls for the same already-redeemed block are rejected by the double-spend guard — safe to retry.
  • Prefix resolution: resolveMainerByPrefix requires a unique match of at least MIN_MEMO_PREFIX_LEN (8) chars; an over-short or ambiguous prefix matches nothing → #InvalidId. It never credits the wrong mAIner.
7 · Key decisionssettled
#DecisionOutcome
D1Memo field: icrc1_memo (ASCII blob) vs legacy memo : Nat64 Resolved — icrc1_memo as ASCII text. Confirmed by the live Jupiter Faucet flow (32-char ASCII memo, which the 8-byte legacy Nat64 can't hold).
D2MIN_MEMO_PREFIX_LEN valueResolved — 8. The most the Jupiter Faucet combined memo (<GameState-id>.<mAIner>) leaves for the mAIner part, and enough to identify a mAIner unambiguously.
D3Dash handling in prefixResolved — - is required and matched verbatim against the canister id (no normalisation). The real faucet memo keeps it, e.g. 2r3eo-5q.
D4TriggerResolved — single permissionless notifyTopUp({ blockId }). The on-chain sweep is deferred (see Future improvements).
D5New notifyTopUp vs. updating topUpCyclesForAnyMainerAgent in place (raised by patnorris) Resolved — add a new notifyTopUp and remove the old endpoint. Rationale:
  • The interface changes either way. notifyTopUp deliberately drops the mainerAgentAddress argument (the mAIner is now memo-derived). Repurposing the old endpoint would be a breaking signature change to it anyway — not an in-place tweak.
  • No callers to preserve. topUpCyclesForAnyMainerAgent is not in active use today (see §4), so there is nothing to keep backward-compatible; renaming costs nothing.
  • Intention-revealing name. "notify with a block id" communicates the new permissionless, memo-based semantics to 3rd-party callers (Jupiter Faucet) far better than overloading topUpCyclesForAnyMainerAgent…Address with different behavior.
  • Clean audit boundary. The gated topUpCyclesForMainerAgent stays untouched; the new permissionless path is a distinct, separately-reviewable endpoint.
Low cost either way — this is a naming/ergonomics call, and the shared processTopUpCyclesForMainer machinery is identical regardless.
8 · Testingqa

Smoke tests in PoAIW/src/GameState/test/test_game_state_canister.py.

New endpoint — notifyTopUp

  • notifyTopUp: anonymous → Unauthorized.
  • Memo = first-8 prefix of mAIner-A → Ok, cycles credited to A.
  • Memo = full mAIner-A id (dashes included) → Ok.
  • Memo dash-stripped / wrong prefix / too short / ambiguous → InvalidId.
  • Replay same block id → Already redeemd….
  • Concurrency: fire several notifyTopUp for the same valid block at once → exactly one Ok, the rest Already redeemd… (claim-before-await holds; the mAIner is credited once).
Regression — every path that touches verifyIncomingPayment (raised by patnorris). Because verifyIncomingPayment is being changed (it now takes the already-fetched ledgerTx instead of calling query_blocks itself), all existing top-up paths that flow through it must be re-tested — not just the new endpoint. A green notifyTopUp alone is not sufficient sign-off.

Regression — existing paths through verifyIncomingPayment

  • topUpCyclesForMainerAgent (gated, default frontend path — kept). Must still succeed end-to-end: valid live-window block → Ok, cycles credited; wrong/zero amount or wrong destination account → same error as before the change; replayed block → Already redeemd…. This is the most important regression to cover — it is the in-production funnAI flow.
  • completeTopUpCyclesForMainerAgentAdmin (admin recovery). Live-window block → Ok; archived block → Ok now (was #InvalidId before the fix); unknown block → #InvalidId. Use the three stuck Jan-21 blocks (33_125_476, 33_124_011, 33_123_564) as the archived-block fixtures.
  • Archive-awareness of fetchLedgerTransaction itself. A live-window block and an already-archived block both resolve to the same transaction (operation, amount, memo, destination) — proving the new archive callback traversal returns data equivalent to the live path.
  • No duplicate query_blocks. Each top-up path fetches the block exactly once (the ledgerTx is threaded into verifyIncomingPayment) — guards against the cycle-drain regression noted in §Security.

If topUpCyclesForAnyMainerAgent is removed in the same PR (per §4), confirm its smoke tests are dropped too, so the suite reflects the shipped surface.

9 · Out of scopelater
  • Removing the legacy gated/ungated endpoints (separate cleanup PR once adoption is confirmed).
  • ICRC-2 approve-then-pull as a longer-term replacement for send-then-redeem.
  • Frontend rework — tracked separately; protocol-canister side ships independently.
  • Related finding (separate fix): getRedeemedTransactionBlocksAdmin now exceeds the 2 MB response limit — needs pagination. Surfaced during the same investigation; not required for this change.
10 · Future improvementslater

Deliberately left out of this change to keep it simple; candidates for a follow-up once the notifyTopUp path is proven:

  • Daily on-chain sweep timer. A recurring timer (mirroring the existing auctionTimerId / Timer.recurringTimer pattern) that scans ICP transfers into GameState since a persisted cursor and processes any that weren't notified — so a top-up succeeds even if no one calls notifyTopUp. Needs ICP-ledger archive traversal for backlog.
  • Donation / orphaned-ICP collection. Transfers whose memo doesn't resolve to a mAIner could be booked (e.g. a #Donation arm) and retained by the protocol instead of staying unprocessed — also resolving the pre-existing "stuck ICP" problem. Naturally pairs with the sweep.

Building blocks reused unchanged: processTopUpCyclesForMainer, handleIncomingFunds, verifyIncomingPayment, checkExistingTransactionBlock / putRedeemedTransactionBlock, mainerAgentCanistersStorage.