@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.
- 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 +84 -0
- package/dist/coordination-game/solana.js +262 -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 +72 -0
- package/dist/shillbot/api.js +91 -0
- package/dist/swarm/index.d.ts +84 -0
- package/dist/swarm/index.js +71 -0
- 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
|
+
}
|
|
@@ -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;
|