@swarm-tips/client 0.1.1 → 0.2.1
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.
- package/README.md +5 -0
- package/SBOM.spdx.json +5 -5
- package/dist/coordination-game/api.d.ts +276 -0
- package/dist/coordination-game/api.js +146 -0
- package/dist/coordination-game/evm/abi.d.ts +409 -0
- package/dist/coordination-game/evm/abi.js +371 -0
- package/dist/coordination-game/evm/cross-chain.d.ts +33 -0
- package/dist/coordination-game/evm/cross-chain.js +76 -0
- package/dist/coordination-game/evm/index.d.ts +6 -0
- package/dist/coordination-game/evm/index.js +6 -0
- package/dist/coordination-game/evm/rules.d.ts +112 -0
- package/dist/coordination-game/evm/rules.js +128 -0
- package/dist/coordination-game/evm/session-funding.d.ts +111 -0
- package/dist/coordination-game/evm/session-funding.js +407 -0
- package/dist/coordination-game/evm/session.d.ts +58 -0
- package/dist/coordination-game/evm/session.js +165 -0
- package/dist/coordination-game/evm/transactions.d.ts +137 -0
- package/dist/coordination-game/evm/transactions.js +211 -0
- package/dist/coordination-game/index.d.ts +1 -0
- package/dist/coordination-game/index.js +1 -0
- package/dist/coordination-game/protocol.d.ts +58 -0
- package/dist/coordination-game/protocol.js +120 -0
- package/dist/coordination-game/solana.d.ts +100 -0
- package/dist/coordination-game/solana.js +309 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +20 -0
- package/dist/http.d.ts +21 -0
- package/dist/http.js +101 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/shillbot/api.d.ts +76 -0
- package/dist/shillbot/api.js +114 -0
- package/dist/swarm/index.d.ts +84 -0
- package/dist/swarm/index.js +71 -0
- 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
|
+
}
|