@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.
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 +100 -0
  24. package/dist/coordination-game/solana.js +309 -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 +76 -0
  32. package/dist/shillbot/api.js +114 -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,58 @@
1
+ /**
2
+ * Ephemeral EVM session key — the Base-game analog of the Solana session
3
+ * keypair (`session.ts`). One main-wallet popup funds the session address with
4
+ * stake + gas; the session key then signs createGame/joinGame/commitGuess/
5
+ * revealGuess locally.
6
+ *
7
+ * Under the shipped v3 WALLET-AS-PLAYER design the session key is a gas-only
8
+ * DELEGATE, not the player: the wallet is `game.player1/2` on-chain, and the
9
+ * payout is credited to the wallet and realized with `withdrawFor`. There is
10
+ * therefore NO sweep, and no `evm-sweep.ts` — that file never existed under v3.
11
+ *
12
+ * The private key lives in sessionStorage (cleared on tab close) bound to the
13
+ * main wallet, with a 24h TTL — same guards as the Solana session. Only gas
14
+ * dust ever sits on the session address.
15
+ */
16
+ import { type Chain, type Hex, type PublicClient, type WalletClient } from "viem";
17
+ export interface SessionStorageLike {
18
+ getItem(key: string): string | null;
19
+ setItem(key: string, value: string): void;
20
+ removeItem(key: string): void;
21
+ }
22
+ /** Generate a fresh ephemeral secp256k1 private key for session signing. */
23
+ export declare function generateEvmSessionKey(): Hex;
24
+ /**
25
+ * Read the session key from sessionStorage. Returns null if no session
26
+ * exists, the session has expired (24h TTL), or it belongs to a different
27
+ * main wallet. Any invalid stored value is removed.
28
+ */
29
+ export declare function getStoredEvmSession(storage: SessionStorageLike, walletAddress?: string, now?: () => number): Hex | null;
30
+ /** Persist the session key in sessionStorage, bound to the main wallet. */
31
+ export declare function storeEvmSession(storage: SessionStorageLike, privateKey: Hex, walletAddress: string, now?: () => number): void;
32
+ /** Remove the session key from sessionStorage. */
33
+ export declare function clearEvmSession(storage: SessionStorageLike): void;
34
+ /** Persist the committed guess preimage for `gameId` so a reload can still reveal. */
35
+ export declare function persistEvmGuess(storage: SessionStorageLike, gameId: string, r: Uint8Array): void;
36
+ /** The persisted preimage for `gameId`, or null (missing / different game /
37
+ * unreadable). A mismatched gameId guards against revealing a stale guess. */
38
+ export declare function loadEvmGuess(storage: SessionStorageLike, gameId: string): Uint8Array | null;
39
+ /** Drop the persisted guess once the game is done. */
40
+ export declare function clearEvmGuess(storage: SessionStorageLike): void;
41
+ /** 0x address of a session private key. */
42
+ export declare function evmSessionAddress(privateKey: Hex): Hex;
43
+ /**
44
+ * A viem WalletClient signing with the session key against the SELECTED chain's
45
+ * failover RPCs. Structurally a `CallSender`, so it drops into the existing
46
+ * `sendUnsignedCall` / `sendContractCall` exactly where the wagmi wallet
47
+ * client used to go — the EVM analog of Solana's `getSessionProgram`. The chain
48
+ * is passed in (from the picker's selectedChain) so game txs land on the chain
49
+ * the player actually chose, not a hardcoded default.
50
+ */
51
+ export declare function evmSessionClient(privateKey: Hex, chain: Chain, rpcUrls: string[]): WalletClient;
52
+ /** Read-only client for the selected chain (balances, receipts). */
53
+ export declare function evmReadClient(chain: Chain, rpcUrls: string[]): PublicClient;
54
+ /**
55
+ * Sign a message with the session key locally (EIP-191 personal_sign) — used
56
+ * for the silent auth handshake and the funding-tx binding proof. No popup.
57
+ */
58
+ export declare function signWithEvmSession(privateKey: Hex, message: string): Promise<Hex>;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Ephemeral EVM session key — the Base-game analog of the Solana session
3
+ * keypair (`session.ts`). One main-wallet popup funds the session address with
4
+ * stake + gas; the session key then signs createGame/joinGame/commitGuess/
5
+ * revealGuess locally.
6
+ *
7
+ * Under the shipped v3 WALLET-AS-PLAYER design the session key is a gas-only
8
+ * DELEGATE, not the player: the wallet is `game.player1/2` on-chain, and the
9
+ * payout is credited to the wallet and realized with `withdrawFor`. There is
10
+ * therefore NO sweep, and no `evm-sweep.ts` — that file never existed under v3.
11
+ *
12
+ * The private key lives in sessionStorage (cleared on tab close) bound to the
13
+ * main wallet, with a 24h TTL — same guards as the Solana session. Only gas
14
+ * dust ever sits on the session address.
15
+ */
16
+ import { bytesToHex, createPublicClient, createWalletClient, fallback, hexToBytes, http, } from "viem";
17
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
18
+ const SESSION_KEY = "coordination-evm-session";
19
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
20
+ const PRIVATE_KEY_RE = /^0x[0-9a-fA-F]{64}$/;
21
+ /** Generate a fresh ephemeral secp256k1 private key for session signing. */
22
+ export function generateEvmSessionKey() {
23
+ return generatePrivateKey();
24
+ }
25
+ /**
26
+ * Read the session key from sessionStorage. Returns null if no session
27
+ * exists, the session has expired (24h TTL), or it belongs to a different
28
+ * main wallet. Any invalid stored value is removed.
29
+ */
30
+ export function getStoredEvmSession(storage, walletAddress, now = Date.now) {
31
+ const raw = storage.getItem(SESSION_KEY);
32
+ if (raw === null)
33
+ return null;
34
+ let stored;
35
+ try {
36
+ stored = JSON.parse(raw);
37
+ }
38
+ catch {
39
+ storage.removeItem(SESSION_KEY);
40
+ return null;
41
+ }
42
+ if (typeof stored.privateKey !== "string" ||
43
+ !PRIVATE_KEY_RE.test(stored.privateKey) ||
44
+ typeof stored.createdAt !== "number" ||
45
+ typeof stored.wallet !== "string") {
46
+ storage.removeItem(SESSION_KEY);
47
+ return null;
48
+ }
49
+ if (walletAddress &&
50
+ stored.wallet.toLowerCase() !== walletAddress.toLowerCase()) {
51
+ storage.removeItem(SESSION_KEY);
52
+ return null;
53
+ }
54
+ if (now() - stored.createdAt > SESSION_TTL_MS) {
55
+ storage.removeItem(SESSION_KEY);
56
+ return null;
57
+ }
58
+ return stored.privateKey;
59
+ }
60
+ /** Persist the session key in sessionStorage, bound to the main wallet. */
61
+ export function storeEvmSession(storage, privateKey, walletAddress, now = Date.now) {
62
+ const stored = {
63
+ privateKey,
64
+ createdAt: now(),
65
+ wallet: walletAddress,
66
+ };
67
+ storage.setItem(SESSION_KEY, JSON.stringify(stored));
68
+ }
69
+ /** Remove the session key from sessionStorage. */
70
+ export function clearEvmSession(storage) {
71
+ storage.removeItem(SESSION_KEY);
72
+ }
73
+ // --- Committed-guess preimage persistence (the EVM analog of the Solana
74
+ // pendingR store). The preimage R is generated at commit and needed again at
75
+ // reveal; keeping it only in a React ref means a page reload mid-game strands
76
+ // the reveal. Persist it (keyed by gameId so a stale one from a prior game is
77
+ // never reused) in sessionStorage — same tab lifetime as the session key,
78
+ // and it never leaves the browser, so the commit-reveal anonymity barrier
79
+ // holds. ---
80
+ const PENDING_GUESS_KEY = "coordination-evm-pending-guess";
81
+ /** Persist the committed guess preimage for `gameId` so a reload can still reveal. */
82
+ export function persistEvmGuess(storage, gameId, r) {
83
+ try {
84
+ storage.setItem(PENDING_GUESS_KEY, JSON.stringify({ gameId, r: bytesToHex(r) }));
85
+ }
86
+ catch (e) {
87
+ console.warn("[evm-session] guess persist failed (in-memory only)", e);
88
+ }
89
+ }
90
+ /** The persisted preimage for `gameId`, or null (missing / different game /
91
+ * unreadable). A mismatched gameId guards against revealing a stale guess. */
92
+ export function loadEvmGuess(storage, gameId) {
93
+ try {
94
+ const raw = storage.getItem(PENDING_GUESS_KEY);
95
+ if (raw === null)
96
+ return null;
97
+ const stored = JSON.parse(raw);
98
+ if (stored.gameId !== gameId || typeof stored.r !== "string")
99
+ return null;
100
+ return hexToBytes(stored.r);
101
+ }
102
+ catch (e) {
103
+ console.warn("[evm-session] guess load failed", e);
104
+ return null;
105
+ }
106
+ }
107
+ /** Drop the persisted guess once the game is done. */
108
+ export function clearEvmGuess(storage) {
109
+ try {
110
+ storage.removeItem(PENDING_GUESS_KEY);
111
+ }
112
+ catch (e) {
113
+ console.debug("[evm-session] guess clear failed", e);
114
+ }
115
+ }
116
+ /** 0x address of a session private key. */
117
+ export function evmSessionAddress(privateKey) {
118
+ return privateKeyToAccount(privateKey).address;
119
+ }
120
+ /**
121
+ * A viem WalletClient signing with the session key against the SELECTED chain's
122
+ * failover RPCs. Structurally a `CallSender`, so it drops into the existing
123
+ * `sendUnsignedCall` / `sendContractCall` exactly where the wagmi wallet
124
+ * client used to go — the EVM analog of Solana's `getSessionProgram`. The chain
125
+ * is passed in (from the picker's selectedChain) so game txs land on the chain
126
+ * the player actually chose, not a hardcoded default.
127
+ */
128
+ export function evmSessionClient(privateKey, chain, rpcUrls) {
129
+ // No viem nonceManager: its counter advances optimistically and never rolls
130
+ // back, so a DROPPED Base Sepolia tx leaves a nonce GAP that stalls every
131
+ // later send ("creator's createGame timed out"). Nonce consistency + drop
132
+ // recovery are handled at the send site instead — see sendConfirmedTx: it
133
+ // reads the nonce from a single PINNED RPC (consistent, no stale-low race) and
134
+ // resubmits with the freed nonce if a tx drops.
135
+ const account = privateKeyToAccount(privateKey);
136
+ // Name the SIGNER. Game failures surface as "the total cost ... exceeds the
137
+ // balance of the account" labelled with the PLAYER's address, which is not the
138
+ // account that pays — the session key is. Without this line the two are
139
+ // indistinguishable in a log, and a session funded at one address while
140
+ // signing from another looks identical to a session that was never funded.
141
+ console.info("[evm-session] signing client", {
142
+ sessionAddr: account.address,
143
+ chainId: chain.id,
144
+ rpcCount: rpcUrls.length,
145
+ });
146
+ return createWalletClient({
147
+ account,
148
+ chain,
149
+ transport: fallback(rpcUrls.map((u) => http(u))),
150
+ });
151
+ }
152
+ /** Read-only client for the selected chain (balances, receipts). */
153
+ export function evmReadClient(chain, rpcUrls) {
154
+ return createPublicClient({
155
+ chain,
156
+ transport: fallback(rpcUrls.map((u) => http(u))),
157
+ });
158
+ }
159
+ /**
160
+ * Sign a message with the session key locally (EIP-191 personal_sign) — used
161
+ * for the silent auth handshake and the funding-tx binding proof. No popup.
162
+ */
163
+ export function signWithEvmSession(privateKey, message) {
164
+ return privateKeyToAccount(privateKey).signMessage({ message });
165
+ }
@@ -0,0 +1,137 @@
1
+ import { type Abi, type Chain, type Hex, type PublicClient, type TransactionReceipt } from "viem";
2
+ /** What sendConfirmedTx needs from a signer — the session wallet client satisfies
3
+ * it; tests pass a mock. */
4
+ export interface TxSender {
5
+ account: {
6
+ address: Hex;
7
+ };
8
+ sendTransaction: (args: Record<string, unknown>) => Promise<Hex>;
9
+ }
10
+ /** What sendConfirmedTx needs from a read client PINNED to one RPC (so nonce
11
+ * reads are monotonic — the fallback set's pending-nonce views disagree). */
12
+ export interface PinnedReader {
13
+ getTransactionCount: (args: {
14
+ address: Hex;
15
+ blockTag: "pending";
16
+ }) => Promise<number>;
17
+ getTransactionReceipt: (args: {
18
+ hash: Hex;
19
+ }) => Promise<TransactionReceipt>;
20
+ estimateFeesPerGas: () => Promise<{
21
+ maxFeePerGas?: bigint;
22
+ maxPriorityFeePerGas?: bigint;
23
+ }>;
24
+ }
25
+ /** A read client over ALL rpcUrls (viem `fallback`) used ONLY for an authoritative
26
+ * receipt re-check when the pinned poll lags — the receipt is chain state, so any
27
+ * RPC that has it is correct (unlike the nonce, which must stay pinned). */
28
+ export interface FallbackReader {
29
+ getTransactionReceipt: (args: {
30
+ hash: Hex;
31
+ }) => Promise<TransactionReceipt>;
32
+ }
33
+ /** A read client pinned to a SINGLE rpc — never the fallback set, whose rotation
34
+ * between nodes with disagreeing pending-nonce views is what produces stale-low
35
+ * "nonce lower than current" reverts. */
36
+ export declare function pinnedGameReader(chain: Chain, rpcUrls: string[]): PublicClient;
37
+ /**
38
+ * Send a game tx (session-key signed) and CONFIRM it, resilient to Base Sepolia's
39
+ * two testnet flakes with NO test-level retry — the real system handling the load:
40
+ *
41
+ * - INCONSISTENT NONCE: the session wallet's `fallback` transport reads the nonce
42
+ * from whichever RPC it lands on, and their pending-nonce views disagree → a
43
+ * stale-LOW read reverts "nonce lower than the current nonce". We read the
44
+ * nonce ONCE from a single pinned reader and reuse it for every resubmit, so it
45
+ * is consistent and every resubmit REPLACES the same intent (never a second
46
+ * game). (viem's nonceManager can't be used: its counter advances optimistically
47
+ * and never rolls back, so a dropped tx leaves a permanent gap.)
48
+ * - DROPPED TX: Base Sepolia silently drops txs; a single send then never mines
49
+ * and the game stalls ("creator's createGame timed out"). If the tx is not
50
+ * included within `confirmMs`, resubmit at the SAME nonce with bumped fees — a
51
+ * replacement that fills the freed slot (dropped) or supersedes it (stuck).
52
+ * - LAGGY PINNED RECEIPT RPC (mainnet): the pinned nonce reader is a single RPC
53
+ * (`rpcUrls[0]`), and on mainnet that one endpoint rate-limits / lags on
54
+ * `getTransactionReceipt` — so a tx that MINED (createGame/commit/reveal all
55
+ * succeeded on-chain) still times out the pinned poll and the game stalls. The
56
+ * receipt is chain state, not nonce state, so before declaring failure we do
57
+ * ONE authoritative re-check over `fallbackReader` (all rpcUrls); a success
58
+ * there means the tx landed and we return it instead of a false failure.
59
+ *
60
+ * Returns the successful receipt; throws on a real revert or after `attempts`.
61
+ */
62
+ export declare function sendConfirmedTx(wallet: TxSender, pinned: PinnedReader, chain: Chain, call: {
63
+ to: Hex;
64
+ data: Hex;
65
+ value: bigint;
66
+ }, label: string, { attempts, confirmMs, pollMs, }?: {
67
+ attempts?: number;
68
+ confirmMs?: number;
69
+ pollMs?: number;
70
+ }, fallbackReader?: FallbackReader): Promise<TransactionReceipt>;
71
+ /** Convenience: build the session wallet + pinned reader for a game and confirm a
72
+ * raw {to,data,value} call. */
73
+ export declare function sendConfirmedGameTx(sessionKey: Hex, chain: Chain, rpcUrls: string[], call: {
74
+ to: Hex;
75
+ data: Hex;
76
+ value: bigint;
77
+ }, label: string): Promise<TransactionReceipt>;
78
+ /** CoordinationGame.sol Status enum: None=0, Pending=1, Active=2, Committing=3,
79
+ * Revealing=4, Resolved=5. Any value >= 1 means the game EXISTS on-chain. */
80
+ export declare function gameStatusExists(status: number): boolean;
81
+ /**
82
+ * Whether the "matched" phase should auto-fire `fund()` (create/join) with no
83
+ * manual button. The stake is ALREADY escrowed at the queue's one popup, so
84
+ * create/join is popup-less — there is nothing for the player to confirm and a
85
+ * "Create game"/"Join game" button is pure friction (mirrors Solana, where P1
86
+ * now creates silently via the session key). Fires exactly once per game:
87
+ * - only at "matched" (before it, there is no game to fund; after, it is done),
88
+ * - only once every precondition the tx needs is present (wallet/session ready),
89
+ * - only if this game_id hasn't already been auto-funded (guards React
90
+ * re-renders + strict-mode double-invoke; a NEW match re-arms it).
91
+ * Pure so the gating is unit-testable without a wallet or chain.
92
+ */
93
+ export declare function shouldAutoFund(args: {
94
+ phase: string;
95
+ gameId: string | undefined;
96
+ ready: boolean;
97
+ lastFundedGameId: string | null;
98
+ }): boolean;
99
+ /**
100
+ * Whether the game already EXISTS on-chain — reads `games(gameId).status` over
101
+ * the chain's RPC fallbacks (the same read path the hook uses elsewhere) and
102
+ * decodes status >= 1. This verifies the on-chain EFFECT of createGame, which is
103
+ * authoritative when the receipt poll is not: on mainnet the createGame tx can
104
+ * MINE (game created + joinable) while the RPC lags on returning its receipt, so
105
+ * a receipt-poll timeout does NOT mean the game wasn't created.
106
+ */
107
+ export declare function gameExistsOnChain(contract: Hex, gameId: Hex, chain: Chain, rpcUrls: string[]): Promise<boolean>;
108
+ /**
109
+ * Send createGame and CONFIRM its on-chain EFFECT, not just its receipt.
110
+ *
111
+ * WHY: mainnet RPC receipt-fetch lag makes sendConfirmedTx's receipt poll time
112
+ * out ("createGame did not confirm after N attempts") even though the tx MINED
113
+ * and the game is created + joinable on-chain. Trusting only the receipt strands
114
+ * the browser before chat (chat-input never enables). Verifying the on-chain
115
+ * effect is authoritative. This supersedes the reverted selector-matching
116
+ * approach, which couldn't see the selector once sendConfirmedTx flattened the
117
+ * error to a message string.
118
+ *
119
+ * Both deps are injected so the send/detect decision is unit-testable with no
120
+ * chain: `send` performs the tx (throws on a receipt-poll timeout OR a real
121
+ * failure), `gameExists` reads games(gameId).status. Returns "sent" on a normal
122
+ * confirm, "recovered" when the send threw but the game exists on-chain (proceed
123
+ * anyway); re-throws only when the send threw AND the game is not on-chain.
124
+ */
125
+ export declare function sendCreateGameConfirmingEffect(send: () => Promise<unknown>, gameExists: () => Promise<boolean>): Promise<"sent" | "recovered">;
126
+ /**
127
+ * Send a game CONTRACT call (session-key signed) and CONFIRM it. Encodes the call
128
+ * and routes through sendConfirmedTx, so every game tx — createGame, joinGame,
129
+ * commitGuess, revealGuess, resolveTimeout — gets the same confirm-or-replace
130
+ * resilience instead of a fire-and-wait that stranded games on Base Sepolia.
131
+ */
132
+ export declare function sendAndConfirm(sessionKey: Hex, chain: Chain, rpcUrls: string[], call: {
133
+ abi: Abi;
134
+ functionName: string;
135
+ args: readonly unknown[];
136
+ to: Hex;
137
+ }, label: string): Promise<void>;
@@ -0,0 +1,211 @@
1
+ import { createPublicClient, encodeFunctionData, http, } from "viem";
2
+ import { evmReadClient, evmSessionClient } from "./session.js";
3
+ import { COORDINATION_GAME_ABI } from "./abi.js";
4
+ /** A read client pinned to a SINGLE rpc — never the fallback set, whose rotation
5
+ * between nodes with disagreeing pending-nonce views is what produces stale-low
6
+ * "nonce lower than current" reverts. */
7
+ export function pinnedGameReader(chain, rpcUrls) {
8
+ return createPublicClient({ chain, transport: http(rpcUrls[0]) });
9
+ }
10
+ async function bumpedFees(pinned, attempt) {
11
+ const { maxFeePerGas, maxPriorityFeePerGas } = await pinned.estimateFeesPerGas();
12
+ // +25% per resubmit — viem rejects a same-nonce replacement at equal fees.
13
+ const mul = BigInt(100 + 25 * attempt);
14
+ return {
15
+ maxFeePerGas: maxFeePerGas ? (maxFeePerGas * mul) / 100n : undefined,
16
+ maxPriorityFeePerGas: maxPriorityFeePerGas
17
+ ? (maxPriorityFeePerGas * mul) / 100n
18
+ : undefined,
19
+ };
20
+ }
21
+ async function pollReceipt(pinned, hash, confirmMs, pollMs) {
22
+ for (let i = 0; i < Math.ceil(confirmMs / pollMs); i++) {
23
+ try {
24
+ return await pinned.getTransactionReceipt({ hash });
25
+ }
26
+ catch {
27
+ // not mined yet
28
+ }
29
+ await new Promise((r) => setTimeout(r, pollMs));
30
+ }
31
+ return null;
32
+ }
33
+ /**
34
+ * Send a game tx (session-key signed) and CONFIRM it, resilient to Base Sepolia's
35
+ * two testnet flakes with NO test-level retry — the real system handling the load:
36
+ *
37
+ * - INCONSISTENT NONCE: the session wallet's `fallback` transport reads the nonce
38
+ * from whichever RPC it lands on, and their pending-nonce views disagree → a
39
+ * stale-LOW read reverts "nonce lower than the current nonce". We read the
40
+ * nonce ONCE from a single pinned reader and reuse it for every resubmit, so it
41
+ * is consistent and every resubmit REPLACES the same intent (never a second
42
+ * game). (viem's nonceManager can't be used: its counter advances optimistically
43
+ * and never rolls back, so a dropped tx leaves a permanent gap.)
44
+ * - DROPPED TX: Base Sepolia silently drops txs; a single send then never mines
45
+ * and the game stalls ("creator's createGame timed out"). If the tx is not
46
+ * included within `confirmMs`, resubmit at the SAME nonce with bumped fees — a
47
+ * replacement that fills the freed slot (dropped) or supersedes it (stuck).
48
+ * - LAGGY PINNED RECEIPT RPC (mainnet): the pinned nonce reader is a single RPC
49
+ * (`rpcUrls[0]`), and on mainnet that one endpoint rate-limits / lags on
50
+ * `getTransactionReceipt` — so a tx that MINED (createGame/commit/reveal all
51
+ * succeeded on-chain) still times out the pinned poll and the game stalls. The
52
+ * receipt is chain state, not nonce state, so before declaring failure we do
53
+ * ONE authoritative re-check over `fallbackReader` (all rpcUrls); a success
54
+ * there means the tx landed and we return it instead of a false failure.
55
+ *
56
+ * Returns the successful receipt; throws on a real revert or after `attempts`.
57
+ */
58
+ export async function sendConfirmedTx(wallet, pinned, chain, call, label, { attempts = 4, confirmMs = 30_000, pollMs = 3_000, } = {}, fallbackReader) {
59
+ const address = wallet.account.address;
60
+ // ONE nonce for all attempts — reusing it makes every resubmit a replacement of
61
+ // the same intent, never a second on-chain action.
62
+ const nonce = await pinned.getTransactionCount({ address, blockTag: "pending" });
63
+ let lastHash;
64
+ let lastErr;
65
+ for (let i = 0; i < attempts; i++) {
66
+ try {
67
+ lastHash = await wallet.sendTransaction({
68
+ ...call,
69
+ chain,
70
+ nonce,
71
+ ...(i > 0 ? await bumpedFees(pinned, i) : {}),
72
+ });
73
+ }
74
+ catch (e) {
75
+ // A resubmit can bounce with "nonce too low" (the prior send already mined)
76
+ // or "already known" / "replacement underpriced" (in flight). None is
77
+ // terminal — poll the hash we already have instead of failing.
78
+ lastErr = e;
79
+ if (!lastHash)
80
+ throw e;
81
+ }
82
+ const receipt = await pollReceipt(pinned, lastHash, confirmMs, pollMs);
83
+ if (receipt) {
84
+ if (receipt.status !== "success") {
85
+ throw new Error(`${label} tx ${lastHash} reverted on-chain`);
86
+ }
87
+ return receipt;
88
+ }
89
+ console.warn(`[evm-tx] ${label} not mined in ${confirmMs}ms (attempt ${i + 1}/${attempts}) — resubmitting at nonce ${nonce} with bumped fees`, { hash: lastHash });
90
+ }
91
+ // The pinned poll (single rpcUrls[0]) exhausted every attempt — but on mainnet
92
+ // that one RPC lags on receipts, so the tx may have MINED. Do one authoritative
93
+ // re-check over the fallback set before declaring failure.
94
+ if (lastHash && fallbackReader) {
95
+ const receipt = await fallbackReader
96
+ .getTransactionReceipt({ hash: lastHash })
97
+ .catch(() => null);
98
+ if (receipt) {
99
+ if (receipt.status !== "success") {
100
+ throw new Error(`${label} tx ${lastHash} reverted on-chain`);
101
+ }
102
+ console.info("[evm-tx] " + label + " confirmed via fallback receipt read after pinned poll lag", {
103
+ hash: lastHash,
104
+ });
105
+ return receipt;
106
+ }
107
+ }
108
+ throw new Error(`${label} did not confirm after ${attempts} attempts (last tx ${lastHash ?? "none"})` +
109
+ (lastErr ? `: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}` : ""));
110
+ }
111
+ /** Convenience: build the session wallet + pinned reader for a game and confirm a
112
+ * raw {to,data,value} call. */
113
+ export function sendConfirmedGameTx(sessionKey, chain, rpcUrls, call, label) {
114
+ return sendConfirmedTx(evmSessionClient(sessionKey, chain, rpcUrls), pinnedGameReader(chain, rpcUrls), chain, call, label, undefined,
115
+ // Fallback over ALL rpcUrls — authoritative receipt re-check when the single
116
+ // pinned rpc lags. The nonce stays pinned; only the final receipt is fallback.
117
+ evmReadClient(chain, rpcUrls));
118
+ }
119
+ /** CoordinationGame.sol Status enum: None=0, Pending=1, Active=2, Committing=3,
120
+ * Revealing=4, Resolved=5. Any value >= 1 means the game EXISTS on-chain. */
121
+ export function gameStatusExists(status) {
122
+ return status >= 1;
123
+ }
124
+ /**
125
+ * Whether the "matched" phase should auto-fire `fund()` (create/join) with no
126
+ * manual button. The stake is ALREADY escrowed at the queue's one popup, so
127
+ * create/join is popup-less — there is nothing for the player to confirm and a
128
+ * "Create game"/"Join game" button is pure friction (mirrors Solana, where P1
129
+ * now creates silently via the session key). Fires exactly once per game:
130
+ * - only at "matched" (before it, there is no game to fund; after, it is done),
131
+ * - only once every precondition the tx needs is present (wallet/session ready),
132
+ * - only if this game_id hasn't already been auto-funded (guards React
133
+ * re-renders + strict-mode double-invoke; a NEW match re-arms it).
134
+ * Pure so the gating is unit-testable without a wallet or chain.
135
+ */
136
+ export function shouldAutoFund(args) {
137
+ return (args.phase === "matched" &&
138
+ !!args.gameId &&
139
+ args.ready &&
140
+ args.lastFundedGameId !== args.gameId);
141
+ }
142
+ /**
143
+ * Whether the game already EXISTS on-chain — reads `games(gameId).status` over
144
+ * the chain's RPC fallbacks (the same read path the hook uses elsewhere) and
145
+ * decodes status >= 1. This verifies the on-chain EFFECT of createGame, which is
146
+ * authoritative when the receipt poll is not: on mainnet the createGame tx can
147
+ * MINE (game created + joinable) while the RPC lags on returning its receipt, so
148
+ * a receipt-poll timeout does NOT mean the game wasn't created.
149
+ */
150
+ export async function gameExistsOnChain(contract, gameId, chain, rpcUrls) {
151
+ const game = (await evmReadClient(chain, rpcUrls).readContract({
152
+ address: contract,
153
+ abi: COORDINATION_GAME_ABI,
154
+ functionName: "games",
155
+ args: [gameId],
156
+ }));
157
+ return gameStatusExists(Number(game[0]));
158
+ }
159
+ /**
160
+ * Send createGame and CONFIRM its on-chain EFFECT, not just its receipt.
161
+ *
162
+ * WHY: mainnet RPC receipt-fetch lag makes sendConfirmedTx's receipt poll time
163
+ * out ("createGame did not confirm after N attempts") even though the tx MINED
164
+ * and the game is created + joinable on-chain. Trusting only the receipt strands
165
+ * the browser before chat (chat-input never enables). Verifying the on-chain
166
+ * effect is authoritative. This supersedes the reverted selector-matching
167
+ * approach, which couldn't see the selector once sendConfirmedTx flattened the
168
+ * error to a message string.
169
+ *
170
+ * Both deps are injected so the send/detect decision is unit-testable with no
171
+ * chain: `send` performs the tx (throws on a receipt-poll timeout OR a real
172
+ * failure), `gameExists` reads games(gameId).status. Returns "sent" on a normal
173
+ * confirm, "recovered" when the send threw but the game exists on-chain (proceed
174
+ * anyway); re-throws only when the send threw AND the game is not on-chain.
175
+ */
176
+ export async function sendCreateGameConfirmingEffect(send, gameExists) {
177
+ try {
178
+ await send();
179
+ return "sent";
180
+ }
181
+ catch (e) {
182
+ // gameExists() reads games() over the RPC fallback, which can transiently
183
+ // revert on a lagging node. A read failure must NOT mask the REAL createGame
184
+ // error (that turned a plain revert into a confusing "games() reverted" that
185
+ // hid the actual cause): default to "not confirmed" and re-throw the original.
186
+ let exists = false;
187
+ try {
188
+ exists = await gameExists();
189
+ }
190
+ catch {
191
+ exists = false;
192
+ }
193
+ if (exists)
194
+ return "recovered";
195
+ throw e;
196
+ }
197
+ }
198
+ /**
199
+ * Send a game CONTRACT call (session-key signed) and CONFIRM it. Encodes the call
200
+ * and routes through sendConfirmedTx, so every game tx — createGame, joinGame,
201
+ * commitGuess, revealGuess, resolveTimeout — gets the same confirm-or-replace
202
+ * resilience instead of a fire-and-wait that stranded games on Base Sepolia.
203
+ */
204
+ export async function sendAndConfirm(sessionKey, chain, rpcUrls, call, label) {
205
+ const data = encodeFunctionData({
206
+ abi: call.abi,
207
+ functionName: call.functionName,
208
+ args: call.args,
209
+ });
210
+ await sendConfirmedGameTx(sessionKey, chain, rpcUrls, { to: call.to, data, value: 0n }, label);
211
+ }
@@ -2,3 +2,4 @@ export { COORDINATION_GAME_IDL, COORDINATION_GAME_IDL as IDL, COORDINATION_GAME_
2
2
  export type { CoordinationGame } from "../contracts/index.js";
3
3
  export * from "./outcome-oracle.js";
4
4
  export * from "./battery.js";
5
+ export * from "./protocol.js";
@@ -1,3 +1,4 @@
1
1
  export { COORDINATION_GAME_IDL, COORDINATION_GAME_IDL as IDL, COORDINATION_GAME_PROGRAM_ID, } from "../contracts/index.js";
2
2
  export * from "./outcome-oracle.js";
3
3
  export * from "./battery.js";
4
+ export * from "./protocol.js";
@@ -0,0 +1,58 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ export declare const COORDINATION_GAME_TOURNAMENT_ID = 1n;
3
+ export declare const GUESS_UNREVEALED = 255;
4
+ export declare const REVEAL_TIMEOUT_SLOTS = 14400n;
5
+ export declare function toGuessBit(value: number): 0 | 1;
6
+ export declare function u64LE(value: bigint): Uint8Array;
7
+ export declare function globalConfigPda(id?: PublicKey | string): [PublicKey, number];
8
+ export declare function gameCounterPda(id?: PublicKey | string): [PublicKey, number];
9
+ export declare function gamePda(gameId: bigint, id?: PublicKey | string): [PublicKey, number];
10
+ export declare function tournamentPda(tournamentId: bigint, id?: PublicKey | string): [PublicKey, number];
11
+ export declare function playerProfilePda(tournamentId: bigint, wallet: PublicKey, id?: PublicKey | string): [PublicKey, number];
12
+ export declare function escrowPda(tournamentId: bigint, wallet: PublicKey, id?: PublicKey | string): [PublicKey, number];
13
+ export declare function playerSessionPda(wallet: PublicKey, session: PublicKey, id?: PublicKey | string): [PublicKey, number];
14
+ export interface GlobalConfigData {
15
+ authority: PublicKey;
16
+ matchmaker: PublicKey;
17
+ treasury: PublicKey;
18
+ treasurySplitBps: number;
19
+ stakeLamports: bigint;
20
+ }
21
+ export interface TournamentData {
22
+ tournamentId: bigint;
23
+ startTime: bigint;
24
+ endTime: bigint;
25
+ prizeLamports: bigint;
26
+ gameCount: bigint;
27
+ finalized: boolean;
28
+ }
29
+ export declare function decodeGlobalConfig(data: Uint8Array): GlobalConfigData;
30
+ export declare function decodeTournament(data: Uint8Array): TournamentData;
31
+ export declare function decodeTournamentStats(data: Uint8Array): {
32
+ gameCount: number;
33
+ prizeLamports: bigint;
34
+ };
35
+ export interface SolanaResumeGame {
36
+ state: string;
37
+ playerOne: string;
38
+ playerTwo: string;
39
+ p1Commit: readonly number[] | Uint8Array;
40
+ p2Commit: readonly number[] | Uint8Array;
41
+ p1Guess: number;
42
+ p2Guess: number;
43
+ }
44
+ export interface SolanaResumeState {
45
+ phase: "active" | "committing" | "revealing";
46
+ iAmP1: boolean;
47
+ bothCommitted: boolean;
48
+ }
49
+ export declare function deriveSolanaResumeState(game: SolanaResumeGame, wallet: string): SolanaResumeState | null;
50
+ export interface SolanaGameTimes {
51
+ state: string;
52
+ activatedAtSlot: bigint;
53
+ p1CommitSlot: bigint;
54
+ p2CommitSlot: bigint;
55
+ commitTimeoutSlots: bigint;
56
+ }
57
+ export declare function timeoutDeadlineSlot(game: SolanaGameTimes): bigint | null;
58
+ export declare function timeoutClaimableAtSlot(game: SolanaGameTimes, currentSlot: bigint): boolean;