Simplified ICP Top-Up — Proposed Protocol-Canister Changes
GameState canister · funnAI / PoAIW
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
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.
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
| Aspect | Today (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;
};
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
};
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
| Endpoint | Disposition |
|---|---|
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. |
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.
| Risk | Severity | Mitigation |
|---|---|---|
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:
|
6 · Correctnessreview
- Idempotent notify: duplicate
notifyTopUpcalls for the same already-redeemed block are rejected by the double-spend guard — safe to retry. - Prefix resolution:
resolveMainerByPrefixrequires a unique match of at leastMIN_MEMO_PREFIX_LEN(8) chars; an over-short or ambiguous prefix matches nothing →#InvalidId. It never credits the wrong mAIner.
7 · Key decisionssettled
| # | Decision | Outcome |
|---|---|---|
| D1 | Memo 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). |
| D2 | MIN_MEMO_PREFIX_LEN value | Resolved — 8. The most the Jupiter Faucet combined memo (<GameState-id>.<mAIner>) leaves for the mAIner part, and enough to identify a mAIner unambiguously. |
| D3 | Dash handling in prefix | Resolved — - is required and matched verbatim against the canister id (no normalisation). The real faucet memo keeps it, e.g. 2r3eo-5q. |
| D4 | Trigger | Resolved — single permissionless notifyTopUp({ blockId }). The on-chain sweep is deferred (see Future improvements). |
| D5 | New notifyTopUp vs. updating topUpCyclesForAnyMainerAgent in place (raised by patnorris) |
Resolved — add a new notifyTopUp and remove the old endpoint. Rationale:
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
notifyTopUpfor the same valid block at once → exactly oneOk, the restAlready redeemd…(claim-before-await holds; the mAIner is credited once).
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 →Oknow (was#InvalidIdbefore 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
fetchLedgerTransactionitself. A live-window block and an already-archived block both resolve to the same transaction (operation, amount, memo, destination) — proving the new archivecallbacktraversal returns data equivalent to the live path. - No duplicate
query_blocks. Each top-up path fetches the block exactly once (theledgerTxis threaded intoverifyIncomingPayment) — 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):
getRedeemedTransactionBlocksAdminnow 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.recurringTimerpattern) 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 callsnotifyTopUp. 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
#Donationarm) 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.