@swarm-tips/client 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +5 -0
  2. package/SBOM.spdx.json +5 -5
  3. package/dist/coordination-game/api.d.ts +276 -0
  4. package/dist/coordination-game/api.js +146 -0
  5. package/dist/coordination-game/evm/abi.d.ts +409 -0
  6. package/dist/coordination-game/evm/abi.js +371 -0
  7. package/dist/coordination-game/evm/cross-chain.d.ts +33 -0
  8. package/dist/coordination-game/evm/cross-chain.js +76 -0
  9. package/dist/coordination-game/evm/index.d.ts +6 -0
  10. package/dist/coordination-game/evm/index.js +6 -0
  11. package/dist/coordination-game/evm/rules.d.ts +112 -0
  12. package/dist/coordination-game/evm/rules.js +128 -0
  13. package/dist/coordination-game/evm/session-funding.d.ts +111 -0
  14. package/dist/coordination-game/evm/session-funding.js +407 -0
  15. package/dist/coordination-game/evm/session.d.ts +58 -0
  16. package/dist/coordination-game/evm/session.js +165 -0
  17. package/dist/coordination-game/evm/transactions.d.ts +137 -0
  18. package/dist/coordination-game/evm/transactions.js +211 -0
  19. package/dist/coordination-game/index.d.ts +1 -0
  20. package/dist/coordination-game/index.js +1 -0
  21. package/dist/coordination-game/protocol.d.ts +58 -0
  22. package/dist/coordination-game/protocol.js +120 -0
  23. package/dist/coordination-game/solana.d.ts +84 -0
  24. package/dist/coordination-game/solana.js +262 -0
  25. package/dist/errors.d.ts +19 -0
  26. package/dist/errors.js +20 -0
  27. package/dist/http.d.ts +21 -0
  28. package/dist/http.js +101 -0
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +2 -1
  31. package/dist/shillbot/api.d.ts +72 -0
  32. package/dist/shillbot/api.js +91 -0
  33. package/dist/swarm/index.d.ts +84 -0
  34. package/dist/swarm/index.js +71 -0
  35. package/package.json +26 -1
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Pure decision rules for the same-chain EVM game, extracted from `useEvmGame`
3
+ * so the two paths that previously shipped bugs — reveal ordering and timeout
4
+ * recovery — are locked by deterministic unit tests instead of relying on a
5
+ * live e2e that only ever exercises the happy path.
6
+ *
7
+ * Mirrors CoordinationGame.sol exactly; keep in lockstep with the contract.
8
+ */
9
+ /** games().matchupType before any reveal binds it. */
10
+ export const MATCHUP_UNSET = 255;
11
+ /** games().pNGuess before that player reveals. */
12
+ export const UNREVEALED = 255;
13
+ /** The 32-byte zero sentinel the SECOND revealer must pass for rMatchup. */
14
+ export const ZERO_BYTES32 = `0x${"00".repeat(32)}`;
15
+ /**
16
+ * The `rMatchup` argument for revealGuess, chosen from ON-CHAIN state (never the
17
+ * creator/joiner role). The FIRST revealer binds the matchup with the real
18
+ * r_matchup; the SECOND — matchup already bound on-chain — MUST pass the zero
19
+ * sentinel, or the contract reverts CertMismatch (the reveal-order bug). Returns
20
+ * null only when we're the first revealer but don't yet hold r_matchup (both
21
+ * players haven't committed), which the caller surfaces as "not ready".
22
+ */
23
+ export function revealMatchupArg(onChainMatchupType, rMatchup) {
24
+ const bound = onChainMatchupType !== MATCHUP_UNSET;
25
+ return bound ? ZERO_BYTES32 : rMatchup;
26
+ }
27
+ // CoordinationGame.Status values resolveTimeout can crank (opponent stalled).
28
+ export const STATUS_ACTIVE = 2;
29
+ export const STATUS_COMMITTING = 3;
30
+ export const STATUS_REVEALING = 4;
31
+ /**
32
+ * The unix second at which an opponent's stall becomes resolvable via
33
+ * resolveTimeout, or null when the status has no timeout (not Active/Committing/
34
+ * Revealing). Anchors mirror CoordinationGame.resolveTimeout: Active/Committing
35
+ * time out on the commit window (from join / first-commit); Revealing on the
36
+ * reveal window (from both-commit).
37
+ */
38
+ export function timeoutDeadline(g) {
39
+ if (g.status === STATUS_ACTIVE)
40
+ return g.activatedAt + g.commitWindowSecs;
41
+ if (g.status === STATUS_COMMITTING)
42
+ return g.firstCommitAt + g.commitWindowSecs;
43
+ if (g.status === STATUS_REVEALING)
44
+ return g.bothCommitAt + g.revealWindowSecs;
45
+ return null;
46
+ }
47
+ /** Whether the opponent's stall is resolvable at `nowSecs`. */
48
+ export function timeoutClaimableAt(g, nowSecs) {
49
+ const deadline = timeoutDeadline(g);
50
+ return deadline !== null && nowSecs >= deadline;
51
+ }
52
+ function sameAddr(a, b) {
53
+ return a.toLowerCase() === b.toLowerCase();
54
+ }
55
+ /**
56
+ * Is this match on the chain the wallet is currently connected to?
57
+ *
58
+ * The resume path takes the contract address from the MATCH record but reads it
59
+ * through the CONNECTED chain's client. Those are independent, and when they
60
+ * disagree it queries an address that means nothing on that chain. Observed on
61
+ * both mainnet e2e runs (2026-08-13): connected to eip155:1, it read the Base
62
+ * Sepolia proxy 0x4fbbceb9… and logged `"games" returned no data ("0x")`.
63
+ *
64
+ * Exact string equality on the CAIP-2 id, deliberately: no prefix matching (so
65
+ * `eip155:1` can never satisfy chain 11), and a missing/malformed/non-EVM value
66
+ * returns false rather than defaulting to allow — the boundary rejects rather
67
+ * than guesses.
68
+ */
69
+ export function matchIsOnConnectedChain(matchChain, connectedChainId) {
70
+ if (!matchChain)
71
+ return false;
72
+ return matchChain === `eip155:${connectedChainId}`;
73
+ }
74
+ /**
75
+ * The local phase to resume into after a page reload, derived purely from the
76
+ * on-chain game + our session address — EVM's `/evmplay` has no gameId in the
77
+ * URL, so a refresh otherwise strands a committed player who can no longer
78
+ * reveal. Returns null (stay idle) when there is nothing to resume: no
79
+ * in-progress game (Pending/Resolved/None), not a participant, or we already
80
+ * revealed (the game will resolve and auto-sweep on its own).
81
+ */
82
+ export function deriveResumeState(g, myAddr) {
83
+ if (g.status !== STATUS_ACTIVE &&
84
+ g.status !== STATUS_COMMITTING &&
85
+ g.status !== STATUS_REVEALING) {
86
+ return null;
87
+ }
88
+ const isP1 = sameAddr(myAddr, g.player1);
89
+ const isP2 = sameAddr(myAddr, g.player2);
90
+ if (!isP1 && !isP2)
91
+ return null;
92
+ const myGuess = isP1 ? g.p1Guess : g.p2Guess;
93
+ if (myGuess !== UNREVEALED)
94
+ return null; // already revealed
95
+ const iCommitted = (isP1 ? g.p1Commit : g.p2Commit) !== ZERO_BYTES32;
96
+ const bothCommitted = g.p1Commit !== ZERO_BYTES32 && g.p2Commit !== ZERO_BYTES32;
97
+ return { phase: iCommitted ? "committed" : "chat", bothCommitted };
98
+ }
99
+ /**
100
+ * Turn a resolved on-chain EVM game into the same player-relative shape the
101
+ * Solana side uses, so `outcomeLabel`/`shareText` can describe it identically.
102
+ *
103
+ * The EVM end-screen said only "Revealed — the game settles on-chain and your
104
+ * winnings return to your wallet automatically": no win/loss, no explorer link,
105
+ * no share. Solana has told players the outcome all along. The payoff matrix is
106
+ * the same on both chains (homogeneous by design), so the difference was purely
107
+ * that nobody built the panel — not a missing concept.
108
+ *
109
+ * Returns null when the game has no bound matchup or an unrevealed guess, i.e.
110
+ * there is not yet a truthful outcome to state.
111
+ */
112
+ export function evmGameResult(g, myAddr) {
113
+ const iAmP1 = sameAddr(myAddr, g.player1);
114
+ if (!iAmP1 && !sameAddr(myAddr, g.player2))
115
+ return null;
116
+ const myGuess = iAmP1 ? g.p1Guess : g.p2Guess;
117
+ const oppGuess = iAmP1 ? g.p2Guess : g.p1Guess;
118
+ // 255 is the unrevealed sentinel and MATCHUP_UNSET means nobody bound it.
119
+ if (myGuess > 1 || oppGuess > 1 || g.matchupType > 1)
120
+ return null;
121
+ return {
122
+ myGuess: myGuess,
123
+ opponentGuess: oppGuess,
124
+ matchupType: g.matchupType,
125
+ iAmP1,
126
+ firstCommitter: g.firstCommitter,
127
+ };
128
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * EVM session setup (Level 2, wallet-as-player) — the Base-game analog of
3
+ * `matchmaking-tx.ts` `setupSessionIfNeeded`. The main wallet's ONE popup is a
4
+ * single `openSession(sessionKey, expiry)` contract call that BOTH registers the
5
+ * session key as the wallet's on-chain delegate AND forwards `stake + gas` to it.
6
+ * Afterwards the gas-only session key stakes/plays AS the wallet with zero
7
+ * further popups, while the wallet stays the on-chain player and payout
8
+ * recipient. No session→wallet binding is needed — the contract records the
9
+ * wallet natively (see CoordinationGame v3 createGame/joinGame `player`).
10
+ * Dependencies are injectable for tests.
11
+ */
12
+ import { type Address, type Chain, type Hex, type WalletClient } from "viem";
13
+ import { evmReadClient } from "./session.js";
14
+ /**
15
+ * Gas headroom funded on top of the stake: covers create/join + commit +
16
+ * reveal + withdrawFor. CHAIN-AWARE: Ethereum L1 gas is ~10-100x Base L2's and
17
+ * the session runs ~4 txs, so 0.0003 ETH — fine on Base — can't cover L1 when
18
+ * gas ticks up. Budget 0.003 ETH on L1, keep 0.0003 on L2.
19
+ */
20
+ export declare const EVM_SESSION_GAS_BUFFER_L2_WEI = 300000000000000n;
21
+ export declare const EVM_SESSION_GAS_BUFFER_L1_WEI = 3000000000000000n;
22
+ /** The session gas buffer for a chain (L1 needs far more than L2). */
23
+ export declare function evmSessionGasBufferWei(caip2: string): bigint;
24
+ /**
25
+ * How much validity a session must have LEFT to be reusable.
26
+ *
27
+ * A session that expires mid-game is as bad as one already expired: the
28
+ * commit or reveal reverts `BadSession` with the stake already locked. The
29
+ * contract's own commit + reveal windows bound a game, so require comfortably
30
+ * more than that rather than a token margin.
31
+ */
32
+ export declare const SESSION_MIN_REMAINING_SECS: number;
33
+ /**
34
+ * Does an on-chain `sessions(wallet)` record authorize `sessionAddr` for long
35
+ * enough to play? Pure, so the rule is testable without a chain.
36
+ *
37
+ * Mirrors the contract's `_actsFor` (`s.sessionKey == actor && now < s.expiry`)
38
+ * and adds the remaining-life margin — the contract only has to answer "valid
39
+ * right now", the client has to answer "valid for the whole game".
40
+ *
41
+ * Address comparison is case-insensitive: these are checksummed hex from two
42
+ * different sources, and a raw `===` on checksummed addresses is exactly the
43
+ * bug that made the e2e harness assert against the wrong player.
44
+ */
45
+ export declare function evmSessionRecordIsLive(record: {
46
+ sessionKey: string;
47
+ expiry: bigint;
48
+ }, sessionAddr: string, nowSecs: number, minRemainingSecs?: number): boolean;
49
+ export interface EvmSessionFundDeps {
50
+ /** Read client for the SELECTED chain (its RPCs/chain must match the chain
51
+ * being funded). The caller builds it via `evmReadClient(chain, rpcs)`. */
52
+ readClient: Pick<ReturnType<typeof evmReadClient>, "getBalance" | "waitForTransactionReceipt" | "readContract">;
53
+ /** The SELECTED chain the tx must land on. Passed explicitly because the
54
+ * wagmi wallet client is bound to the chain the wallet was on at CONNECT
55
+ * time — after fund()'s switchChainAsync, `mainWalletClient.chain` is stale
56
+ * (observed live: wallet connected on Ethereum, switched to Base, tx then
57
+ * built with chain=Ethereum and rejected by viem's chain-mismatch guard). */
58
+ chain: Chain;
59
+ /** Injectable "now" (unix secs) for deterministic expiry in tests. */
60
+ nowSecs?: () => number;
61
+ }
62
+ /**
63
+ * Ensure the session key is registered on `contract` as `mainAddress`'s delegate
64
+ * and holds at least `stakeWei + gas buffer`, via a single wallet-signed
65
+ * `openSession` call (the one popup). Returns the openSession tx hash, or null
66
+ * when the session is genuinely reusable.
67
+ *
68
+ * "Reusable" means BOTH funded and still-valid on-chain, and it used to mean
69
+ * only the first. The old rule was `balance >= need -> return null`, justified
70
+ * as "funding always goes through openSession, so a funded session is a
71
+ * registered one". That inference held until `openSession` gained an expiry:
72
+ * `_actsFor` requires `block.timestamp < s.expiry`, and balance says nothing
73
+ * about time. A wallet whose session key kept its balance past 24h — funded but
74
+ * never spent, or a WINNER, since winnings credit the wallet rather than the
75
+ * session key — had every createGame/joinGame revert `BadSession`, and could
76
+ * never recover, because the balance check kept reporting "already set up".
77
+ * Observed live on Base Sepolia: session expired 17.7h earlier, key still
78
+ * holding 0.0064 ETH, every game creation reverting.
79
+ *
80
+ * So the on-chain registration is now read directly. Balance evidences funding;
81
+ * only `sessions(mainAddress)` evidences authority.
82
+ */
83
+ export declare function setupEvmSessionIfNeeded(mainWalletClient: WalletClient, sessionKey: Hex, contract: Address, stakeWei: bigint, caip2: string, deps: EvmSessionFundDeps): Promise<Hex | null>;
84
+ /**
85
+ * Block until the session key's balance actually reflects `need`.
86
+ *
87
+ * Uses the same conservative (minimum-of-two) read as the skip decision, so a
88
+ * lagging provider delays us rather than waving us through.
89
+ */
90
+ export declare function waitForSessionFunding(read: Pick<ReturnType<typeof evmReadClient>, "getBalance">, sessionAddr: `0x${string}`, need: bigint): Promise<void>;
91
+ /**
92
+ * The balance to trust when deciding whether to skip the funding tx.
93
+ *
94
+ * The read client is a viem `fallback` over several RPC providers, so
95
+ * consecutive reads are NOT monotonic: one can land on a node that has not yet
96
+ * seen the block carrying the previous game's stake spend. A stale-HIGH balance
97
+ * makes the skip fire, the session goes unfunded, and the next createGame /
98
+ * joinGame dies with "total cost exceeds the balance of the account" while the
99
+ * funding wallet sits flush. Observed live: session holding exactly the gas
100
+ * buffer (0.0003) while sending value 0.0032.
101
+ *
102
+ * Taking the MINIMUM makes the error one-directional. A lagging node can now
103
+ * only cause a redundant top-up — cheap, and self-correcting on the next game —
104
+ * never a wrongly skipped one, which strands the whole cell.
105
+ *
106
+ * Deliberately NOT solved with a safety margin on the threshold: `topUp` is
107
+ * `need - balance`, so a balance sitting between the margin and `need` would
108
+ * send an openSession worth ZERO wei — a wallet popup, for a real user, that
109
+ * funds nothing.
110
+ */
111
+ export declare function conservativeBalance(reads: readonly bigint[]): bigint;
@@ -0,0 +1,407 @@
1
+ /**
2
+ * EVM session setup (Level 2, wallet-as-player) — the Base-game analog of
3
+ * `matchmaking-tx.ts` `setupSessionIfNeeded`. The main wallet's ONE popup is a
4
+ * single `openSession(sessionKey, expiry)` contract call that BOTH registers the
5
+ * session key as the wallet's on-chain delegate AND forwards `stake + gas` to it.
6
+ * Afterwards the gas-only session key stakes/plays AS the wallet with zero
7
+ * further popups, while the wallet stays the on-chain player and payout
8
+ * recipient. No session→wallet binding is needed — the contract records the
9
+ * wallet natively (see CoordinationGame v3 createGame/joinGame `player`).
10
+ * Dependencies are injectable for tests.
11
+ */
12
+ import { encodeFunctionData, } from "viem";
13
+ import { evmSessionAddress } from "./session.js";
14
+ import { COORDINATION_GAME_ABI } from "./abi.js";
15
+ /**
16
+ * Gas headroom funded on top of the stake: covers create/join + commit +
17
+ * reveal + withdrawFor. CHAIN-AWARE: Ethereum L1 gas is ~10-100x Base L2's and
18
+ * the session runs ~4 txs, so 0.0003 ETH — fine on Base — can't cover L1 when
19
+ * gas ticks up. Budget 0.003 ETH on L1, keep 0.0003 on L2.
20
+ */
21
+ export const EVM_SESSION_GAS_BUFFER_L2_WEI = 300000000000000n; // 0.0003 ETH
22
+ export const EVM_SESSION_GAS_BUFFER_L1_WEI = 3000000000000000n; // 0.003 ETH
23
+ /** Ethereum L1 chains (higher gas) — everything else here is an L2 (Base).
24
+ * Ethereum Sepolia (eip155:11155111) was removed as a supported testnet; only
25
+ * Ethereum mainnet remains an L1 target. */
26
+ const EVM_L1_CAIP2 = new Set(["eip155:1"]);
27
+ /** The session gas buffer for a chain (L1 needs far more than L2). */
28
+ export function evmSessionGasBufferWei(caip2) {
29
+ return EVM_L1_CAIP2.has(caip2)
30
+ ? EVM_SESSION_GAS_BUFFER_L1_WEI
31
+ : EVM_SESSION_GAS_BUFFER_L2_WEI;
32
+ }
33
+ /** Session lifetime — matches the 24h sessionStorage TTL of the key itself. */
34
+ const SESSION_DURATION_SECS = 24 * 60 * 60;
35
+ /**
36
+ * How much validity a session must have LEFT to be reusable.
37
+ *
38
+ * A session that expires mid-game is as bad as one already expired: the
39
+ * commit or reveal reverts `BadSession` with the stake already locked. The
40
+ * contract's own commit + reveal windows bound a game, so require comfortably
41
+ * more than that rather than a token margin.
42
+ */
43
+ export const SESSION_MIN_REMAINING_SECS = 60 * 60;
44
+ /**
45
+ * Does an on-chain `sessions(wallet)` record authorize `sessionAddr` for long
46
+ * enough to play? Pure, so the rule is testable without a chain.
47
+ *
48
+ * Mirrors the contract's `_actsFor` (`s.sessionKey == actor && now < s.expiry`)
49
+ * and adds the remaining-life margin — the contract only has to answer "valid
50
+ * right now", the client has to answer "valid for the whole game".
51
+ *
52
+ * Address comparison is case-insensitive: these are checksummed hex from two
53
+ * different sources, and a raw `===` on checksummed addresses is exactly the
54
+ * bug that made the e2e harness assert against the wrong player.
55
+ */
56
+ export function evmSessionRecordIsLive(record, sessionAddr, nowSecs, minRemainingSecs = SESSION_MIN_REMAINING_SECS) {
57
+ if (record.sessionKey.toLowerCase() !== sessionAddr.toLowerCase())
58
+ return false;
59
+ return record.expiry > BigInt(nowSecs + minRemainingSecs);
60
+ }
61
+ /** Read `sessions(wallet)` and apply {@link evmSessionRecordIsLive}. */
62
+ async function evmSessionIsLive(read, contract, wallet, sessionAddr, nowSecs) {
63
+ try {
64
+ const res = (await read.readContract({
65
+ address: contract,
66
+ abi: COORDINATION_GAME_ABI,
67
+ functionName: "sessions",
68
+ args: [wallet],
69
+ }));
70
+ return evmSessionRecordIsLive({ sessionKey: res[0], expiry: res[1] }, sessionAddr, nowSecs);
71
+ }
72
+ catch (e) {
73
+ // An unreadable registration is NOT proof of a live session. Fall through
74
+ // to re-opening: the cost is one extra popup, whereas assuming "live" is
75
+ // the lockout this whole change exists to remove. Logged, not swallowed.
76
+ console.warn("[evm-session-tx] could not read sessions(); assuming NOT live and reopening", e);
77
+ return false;
78
+ }
79
+ }
80
+ /**
81
+ * Ensure the session key is registered on `contract` as `mainAddress`'s delegate
82
+ * and holds at least `stakeWei + gas buffer`, via a single wallet-signed
83
+ * `openSession` call (the one popup). Returns the openSession tx hash, or null
84
+ * when the session is genuinely reusable.
85
+ *
86
+ * "Reusable" means BOTH funded and still-valid on-chain, and it used to mean
87
+ * only the first. The old rule was `balance >= need -> return null`, justified
88
+ * as "funding always goes through openSession, so a funded session is a
89
+ * registered one". That inference held until `openSession` gained an expiry:
90
+ * `_actsFor` requires `block.timestamp < s.expiry`, and balance says nothing
91
+ * about time. A wallet whose session key kept its balance past 24h — funded but
92
+ * never spent, or a WINNER, since winnings credit the wallet rather than the
93
+ * session key — had every createGame/joinGame revert `BadSession`, and could
94
+ * never recover, because the balance check kept reporting "already set up".
95
+ * Observed live on Base Sepolia: session expired 17.7h earlier, key still
96
+ * holding 0.0064 ETH, every game creation reverting.
97
+ *
98
+ * So the on-chain registration is now read directly. Balance evidences funding;
99
+ * only `sessions(mainAddress)` evidences authority.
100
+ */
101
+ export async function setupEvmSessionIfNeeded(mainWalletClient, sessionKey, contract, stakeWei, caip2, deps) {
102
+ const read = deps.readClient;
103
+ const now = deps.nowSecs ? deps.nowSecs() : Math.floor(Date.now() / 1000);
104
+ const sessionAddr = evmSessionAddress(sessionKey);
105
+ // GAS ONLY. `need` is what the SESSION KEY must hold, and since v6 the stake
106
+ // no longer lives there — it is escrowed to the player's `withdrawable`
107
+ // ledger by openSessionAndDeposit and debited by createGame/joinGame with
108
+ // msg.value == 0. Every skip/top-up/visibility check below keys off `need`,
109
+ // so they all follow. Leaving the stake here was the custody bug the escrow
110
+ // work exists to close: a lost or expired session key took it, unrecoverably.
111
+ const need = evmSessionGasBufferWei(caip2);
112
+ // `pending`, not the default `latest`, and the reason is the read CLIENT: it
113
+ // is a viem `fallback` across several RPC providers, so consecutive reads are
114
+ // NOT monotonic — one can land on a node that has not yet seen the block
115
+ // carrying the previous game's stake spend. A stale-HIGH balance makes the
116
+ // skip below fire, the session goes unfunded, and the next createGame/joinGame
117
+ // dies with "total cost exceeds the balance of the account" while the wallet
118
+ // that should have funded it sits flush. Observed live: session holding
119
+ // exactly the gas buffer (0.0003) while sending value 0.0032.
120
+ // TWO reads, take the lower: see conservativeBalance. `pending` rather than
121
+ // the default `latest` for the node's most current view.
122
+ const balance = conservativeBalance(await Promise.all([
123
+ read.getBalance({ address: sessionAddr, blockTag: "pending" }),
124
+ read.getBalance({ address: sessionAddr, blockTag: "pending" }),
125
+ ]));
126
+ const mainAddress = mainWalletClient.account.address;
127
+ if (balance >= need) {
128
+ const registered = await evmSessionIsLive(read, contract, mainAddress, sessionAddr, now);
129
+ if (registered) {
130
+ // CONFIRM AT `latest` BEFORE TRUSTING THE SKIP. `conservativeBalance`
131
+ // takes the lower of two reads, but both come from the SAME viem
132
+ // `fallback` client — so when it routes to a lagging node twice, the two
133
+ // reads agree and the minimum is still stale-HIGH. `pending` is the
134
+ // stale-prone view; `latest` on a lagging node errs LOW, which is the
135
+ // safe direction here: it costs a redundant top-up, never a skipped one.
136
+ //
137
+ // Skipping wrongly is the expensive mistake, and it has no recovery path:
138
+ // this branch returns before waitForSessionFunding, so nothing re-checks
139
+ // the balance before the stake goes out. Observed live on Base Sepolia —
140
+ // session holding exactly the 0.0003 gas buffer while createGame sent
141
+ // value 0.0032, failing with "the total cost of executing this
142
+ // transaction exceeds the balance of the account" while the funding
143
+ // wallet sat on 0.0239.
144
+ const settled = await read.getBalance({
145
+ address: sessionAddr,
146
+ blockTag: "latest",
147
+ });
148
+ if (settled >= need) {
149
+ // Gas + registration are not enough to skip: the ESCROW must also still
150
+ // hold this game's stake. openSessionAndDeposit credits
151
+ // withdrawable[wallet] by exactly stakeWei; once a game consumes it via
152
+ // _takeStake the ledger drops to ~zero, so a session that is gas-funded
153
+ // AND registered can still be UNSTAKED for the next game. Skipping here
154
+ // would fire createGame/joinGame against an empty escrow → revert. This
155
+ // is what makes stake-before-queue correct across games: re-deposit (one
156
+ // popup) whenever the escrow no longer covers the stake — and topUp is 0
157
+ // when gas already suffices, so the popup escrows only the stake.
158
+ //
159
+ // CONSERVATIVE READ, same discipline as conservativeBalance for gas: the
160
+ // read CLIENT is a viem `fallback` across several RPCs, so a single read
161
+ // can land on a lagging node that still reports the PREVIOUS game's
162
+ // withdrawable — a stale-HIGH value that would wrongly skip the deposit
163
+ // and leave createGame/joinGame to revert `BadStake` on an empty escrow
164
+ // (observed: game 2's createGame reverting while withdrawable was
165
+ // actually 0). Take the LOWER of two reads: erring low costs a redundant
166
+ // deposit (over-funds the reclaimable escrow), erring high strands the
167
+ // game — so the min is the safe direction.
168
+ const escrowed = conservativeBalance(await Promise.all([
169
+ read.readContract({
170
+ address: contract,
171
+ abi: COORDINATION_GAME_ABI,
172
+ functionName: "withdrawable",
173
+ args: [mainAddress],
174
+ }),
175
+ read.readContract({
176
+ address: contract,
177
+ abi: COORDINATION_GAME_ABI,
178
+ functionName: "withdrawable",
179
+ args: [mainAddress],
180
+ }),
181
+ ]));
182
+ if (escrowed >= stakeWei)
183
+ return null;
184
+ console.info("[evm-session-tx] session funded+registered but escrow below stake — re-depositing", { wallet: mainAddress, escrowed: escrowed.toString(), stakeWei: stakeWei.toString() });
185
+ }
186
+ else {
187
+ console.info("[evm-session-tx] pending balance looked sufficient but settled does not — funding", { session: sessionAddr, pending: balance.toString(), settled: settled.toString() });
188
+ }
189
+ }
190
+ else {
191
+ console.info("[evm-session-tx] session key is funded but NOT live on-chain — reopening", { session: sessionAddr, wallet: mainAddress });
192
+ }
193
+ }
194
+ const expiry = BigInt(now + SESSION_DURATION_SECS);
195
+ // Clamp at zero. Re-opening an EXPIRED-but-funded session is now a reachable
196
+ // path, and there `balance > need`, so the old `need - balance` was negative —
197
+ // viem rejects a negative value, so the very case this fix exists to handle
198
+ // would have thrown instead of recovering. The re-open still needs to happen;
199
+ // it just needs no money.
200
+ // Fund from the SETTLED balance, not the pending one that just proved
201
+ // unreliable: `need - balance` with a stale-high `balance` under-funds by
202
+ // exactly the amount the stale read overstated, which is how a session ends
203
+ // up holding the gas buffer and nothing else.
204
+ const settledForTopUp = await read.getBalance({ address: sessionAddr, blockTag: "latest" });
205
+ const fundingBase = settledForTopUp < balance ? settledForTopUp : balance;
206
+ const topUp = fundingBase >= need ? 0n : need - fundingBase;
207
+ // FULL funding decision, in one line. The bug this exists to expose: the
208
+ // wallet spent exactly 3 x the stake while `sessions(player)` still reported a
209
+ // session key holding only the gas buffer — meaning either openSession funded
210
+ // an address other than the one the client later signs with, or the stake left
211
+ // immediately. Those imply opposite fixes and cannot be told apart from the
212
+ // outside, so every input to the decision is logged rather than inferred.
213
+ console.info("[evm-session-tx] openSessionAndDeposit — funding decision", {
214
+ sessionAddrDerivedFromKey: sessionAddr,
215
+ contract,
216
+ caip2,
217
+ stakeWei: stakeWei.toString(),
218
+ gasBufferWei: evmSessionGasBufferWei(caip2).toString(),
219
+ needWei: need.toString(),
220
+ balancePendingWei: balance.toString(),
221
+ balanceSettledWei: settledForTopUp.toString(),
222
+ fundingBaseWei: fundingBase.toString(),
223
+ topUpWei: topUp.toString(),
224
+ // Where each half of msg.value lands. `gasToSessionWei` is forwarded to the
225
+ // session EOA; `stakeToEscrowWei` is credited to withdrawable[wallet].
226
+ gasToSessionWei: topUp.toString(),
227
+ stakeToEscrowWei: stakeWei.toString(),
228
+ expiry: expiry.toString(),
229
+ });
230
+ // ONE popup for both halves. `deposit()` credits msg.sender and `_takeStake`
231
+ // debits withdrawable[player] — the WALLET — so an escrowed stake cannot be
232
+ // sent by the session key. A separate deposit would therefore have cost a
233
+ // second wallet signature on every first game; openSessionAndDeposit splits
234
+ // gas from stake inside the one transaction instead.
235
+ const data = encodeFunctionData({
236
+ abi: COORDINATION_GAME_ABI,
237
+ functionName: "openSessionAndDeposit",
238
+ args: [sessionAddr, expiry, topUp],
239
+ });
240
+ const txHash = await mainWalletClient.sendTransaction({
241
+ account: mainWalletClient.account,
242
+ chain: deps.chain,
243
+ to: contract,
244
+ data,
245
+ value: topUp + stakeWei,
246
+ });
247
+ // viem resolves waitForTransactionReceipt for REVERTED transactions too, so
248
+ // the status must be checked. Without this a reverted openSession returned its
249
+ // hash as a successful setup and play continued with a session key that was
250
+ // never registered on-chain and never funded — every later session-signed call
251
+ // then failed with a confusing downstream error.
252
+ //
253
+ // A receipt-fetch TIMEOUT is NOT a revert: on mainnet the read fallback's
254
+ // getTransactionReceipt lags even though the tx mined, and this raw wait would
255
+ // throw WaitForTransactionReceiptTimeoutError — falsely failing setup so the
256
+ // player could never enqueue (observed: two-browser mainnet stuck on "finding"
257
+ // because both deposits "timed out" though they landed). Tolerate the timeout
258
+ // and let the AUTHORITATIVE effect check below (waitForSessionFunding — a
259
+ // bounded poll of the actual session+escrow funding, which openSessionAndDeposit
260
+ // credits atomically) decide: it succeeds if the deposit landed and times out
261
+ // if it truly did not. A returned receipt with a non-success status is a real
262
+ // revert and still throws.
263
+ let receipt = null;
264
+ try {
265
+ receipt = await read.waitForTransactionReceipt({ hash: txHash });
266
+ }
267
+ catch (e) {
268
+ console.warn("[evm-session-tx] openSessionAndDeposit receipt fetch timed out — verifying funding on-chain instead of failing setup", { txHash, error: String(e) });
269
+ }
270
+ if (receipt && receipt.status !== "success") {
271
+ throw new Error(`openSessionAndDeposit reverted (tx ${txHash}) — session key not registered, or the stake was not escrowed`);
272
+ }
273
+ // A RECEIPT PROVES INCLUSION, NOT VISIBILITY. The read client is a viem
274
+ // `fallback` over several providers whose consecutive reads are not monotonic
275
+ // (see conservativeBalance below), so the very next call can be routed to a
276
+ // node that has not yet seen the block this receipt came from. createGame is
277
+ // then estimated against a session key that still reads as empty and dies
278
+ // with "the total cost ... exceeds the balance of the account: have 0".
279
+ //
280
+ // Observed live on Base mainnet: openSession landed and funded the session
281
+ // key with 0.003 ETH, and createGame failed with `have 0` moments later. The
282
+ // funding was never the problem; the barrier was missing.
283
+ try {
284
+ await waitForSessionFunding(read, sessionAddr, need);
285
+ }
286
+ catch (fundingErr) {
287
+ // SELF-CORRECT an under-provisioned session. openSessionAndDeposit's `topUp`
288
+ // is sized from a pre-deposit balance read; when that read comes back
289
+ // stale-HIGH — a deterministic session key that carried a residual balance
290
+ // across games, or a fallback node lagging a prior spend — topUp is computed
291
+ // too low and the session lands below `need`. Hard-failing here stranded the
292
+ // player on "finding" (observed on Base mainnet with a reused session). Top up
293
+ // the exact shortfall with a plain wallet→session transfer and re-verify,
294
+ // rather than abandoning a game whose stake is already escrowed. Off the happy
295
+ // path (a fresh session funds correctly in one shot), so one-popup still holds
296
+ // for a normal first game.
297
+ const have = await read.getBalance({ address: sessionAddr, blockTag: "latest" });
298
+ const shortfall = need > have ? need - have : 0n;
299
+ if (shortfall === 0n)
300
+ throw fundingErr; // funded after all — a different fault
301
+ console.warn("[evm-session-tx] session under-funded after deposit — topping up the shortfall directly", { sessionAddr, have: have.toString(), need: need.toString(), shortfall: shortfall.toString() });
302
+ const topUpHash = await mainWalletClient.sendTransaction({
303
+ account: mainWalletClient.account,
304
+ chain: deps.chain,
305
+ to: sessionAddr,
306
+ value: shortfall,
307
+ });
308
+ try {
309
+ await read.waitForTransactionReceipt({ hash: topUpHash });
310
+ }
311
+ catch {
312
+ // receipt-fetch lag — the funding poll below is the authoritative check
313
+ }
314
+ await waitForSessionFunding(read, sessionAddr, need);
315
+ }
316
+ // What the chain ACTUALLY holds for this session after funding, and what the
317
+ // contract believes the session key is. If these disagree, the client is
318
+ // signing with a key the contract does not recognise — the hypothesis that
319
+ // cannot be tested without this line.
320
+ try {
321
+ const [after, onChain] = await Promise.all([
322
+ read.getBalance({ address: sessionAddr, blockTag: "latest" }),
323
+ read.readContract({
324
+ address: contract,
325
+ abi: COORDINATION_GAME_ABI,
326
+ functionName: "sessions",
327
+ args: [mainAddress],
328
+ }),
329
+ ]);
330
+ console.info("[evm-session-tx] post-funding state", {
331
+ txHash,
332
+ sessionAddrDerivedFromKey: sessionAddr,
333
+ sessionAddrOnChain: onChain[0],
334
+ addressesMatch: onChain[0].toLowerCase() === sessionAddr.toLowerCase(),
335
+ sessionBalanceWei: after.toString(),
336
+ needWei: need.toString(),
337
+ coversStake: after >= need,
338
+ });
339
+ }
340
+ catch (e) {
341
+ console.warn("[evm-session-tx] could not read post-funding state", e);
342
+ }
343
+ return txHash;
344
+ }
345
+ /** How long to wait for a landed funding to become readable before giving up. */
346
+ const FUNDING_VISIBLE_ATTEMPTS = 20;
347
+ const FUNDING_VISIBLE_DELAY_MS = 500;
348
+ /**
349
+ * Block until the session key's balance actually reflects `need`.
350
+ *
351
+ * Uses the same conservative (minimum-of-two) read as the skip decision, so a
352
+ * lagging provider delays us rather than waving us through.
353
+ */
354
+ export async function waitForSessionFunding(read, sessionAddr, need) {
355
+ // `need` is a GENEROUS gas BUFFER (~0.0003), an order of magnitude over the
356
+ // ~0.00002 a full game actually spends. Fund-then-check aims for exactly `need`
357
+ // but the session routinely lands a few WEI under it — a reused/deterministic
358
+ // session carrying sub-need dust, or plain rounding — and a strict `>= need`
359
+ // then hard-failed a session holding 99.5% of the buffer, stranding the player
360
+ // on "finding" over microether (observed on Base mainnet: have 298523179693112
361
+ // vs need 300000000000000). Accept within 5%: still ~13x the real gas need.
362
+ const floor = need - need / 20n;
363
+ for (let i = 0; i < FUNDING_VISIBLE_ATTEMPTS; i++) {
364
+ // `latest`, NOT `pending`. Two `pending` reads from the same viem fallback
365
+ // client are not independent: routed to a lagging node twice they agree, and
366
+ // conservativeBalance's minimum is still stale-HIGH. This barrier then
367
+ // returned "funded" for a session holding only the gas buffer, createGame
368
+ // died with "the total cost ... exceeds the balance of the account", and the
369
+ // UI advanced to phase "funded" for a game that was never created. `latest`
370
+ // on a lagging node errs LOW, which only costs another poll.
371
+ const seen = conservativeBalance(await Promise.all([
372
+ read.getBalance({ address: sessionAddr, blockTag: "latest" }),
373
+ read.getBalance({ address: sessionAddr, blockTag: "latest" }),
374
+ ]));
375
+ if (seen >= floor)
376
+ return;
377
+ await new Promise((r) => setTimeout(r, FUNDING_VISIBLE_DELAY_MS));
378
+ }
379
+ throw new Error(`session ${sessionAddr} funded on-chain but its balance is still below ${need} wei ` +
380
+ `after ${(FUNDING_VISIBLE_ATTEMPTS * FUNDING_VISIBLE_DELAY_MS) / 1000}s — ` +
381
+ `RPC providers are lagging; retry rather than sending a transaction that will revert`);
382
+ }
383
+ /**
384
+ * The balance to trust when deciding whether to skip the funding tx.
385
+ *
386
+ * The read client is a viem `fallback` over several RPC providers, so
387
+ * consecutive reads are NOT monotonic: one can land on a node that has not yet
388
+ * seen the block carrying the previous game's stake spend. A stale-HIGH balance
389
+ * makes the skip fire, the session goes unfunded, and the next createGame /
390
+ * joinGame dies with "total cost exceeds the balance of the account" while the
391
+ * funding wallet sits flush. Observed live: session holding exactly the gas
392
+ * buffer (0.0003) while sending value 0.0032.
393
+ *
394
+ * Taking the MINIMUM makes the error one-directional. A lagging node can now
395
+ * only cause a redundant top-up — cheap, and self-correcting on the next game —
396
+ * never a wrongly skipped one, which strands the whole cell.
397
+ *
398
+ * Deliberately NOT solved with a safety margin on the threshold: `topUp` is
399
+ * `need - balance`, so a balance sitting between the margin and `need` would
400
+ * send an openSession worth ZERO wei — a wallet popup, for a real user, that
401
+ * funds nothing.
402
+ */
403
+ export function conservativeBalance(reads) {
404
+ if (reads.length === 0)
405
+ throw new Error("conservativeBalance needs at least one read");
406
+ return reads.reduce((lo, b) => (b < lo ? b : lo));
407
+ }