@fastxyz/allset-sdk 0.1.11 → 0.1.12

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.
@@ -2,11 +2,12 @@
2
2
  * eip7702.ts — EIP-7702 smartDeposit via AllSet Portal relay
3
3
  *
4
4
  * Flow:
5
- * 1. Poll ERC-20 balance until >= minAmount
6
- * 2. POST /userop/prepare → backend assembles UserOp + paymasterData
7
- * 3. Sign EIP-7702 authorization (re-delegate EOA to v0.8 impl)
8
- * 4. Sign UserOperation (EIP-712, v0.8)
9
- * 5. POST /userop/submit → backend calls Pimlico eth_sendUserOperation
5
+ * 1. Check ERC-20 balance; throw InsufficientBalanceError if < amount
6
+ * 2. POST /userop/prepare → backend assembles UserOp + paymasterData (3 retries)
7
+ * 3. Pin delegate address against TRUSTED_DELEGATES allowlist
8
+ * 4. Sign EIP-7702 authorization (re-delegate EOA to v0.8 impl)
9
+ * 5. Sign UserOperation (EIP-712, v0.8)
10
+ * 6. POST /userop/submit → backend calls Pimlico eth_sendUserOperation
10
11
  *
11
12
  * Private key never leaves the SDK.
12
13
  * Pimlico API key never touches the SDK.
@@ -15,33 +16,39 @@
15
16
  */
16
17
  import { type Address, type Hash, type Hex } from 'viem';
17
18
  export interface SmartDepositParams {
18
- /** EOA private key — stays local, never sent to backend */
19
+ /**
20
+ * EOA private key — stays local, never sent to backend.
21
+ * The EOA should be quiescent during this call: do not send other
22
+ * transactions from this key concurrently. EIP-7702 authorization
23
+ * signing binds to the account nonce, and a concurrent tx from
24
+ * another process will silently invalidate the delegation.
25
+ */
19
26
  privateKey: Hex;
20
- /** EVM JSON-RPC URL — used for balance polling and forwarded to backend for chainId detection */
27
+ /** EVM JSON-RPC URL — used for balance check and forwarded to backend for chainId detection */
21
28
  rpcUrl: string;
22
29
  /** AllSet Portal backend base URL, e.g. https://api.allset.xyz */
23
30
  allsetApiUrl: string;
24
- /** ERC-20 token to watch (e.g. USDC on Base) */
31
+ /** ERC-20 token to deposit (e.g. USDC on Base) */
25
32
  tokenAddress: Address;
26
- /** Minimum token balance (raw, with decimals) that triggers deposit */
27
- minAmount: bigint;
33
+ /** Exact token amount to deposit (raw, with decimals); throws if balance is insufficient */
34
+ amount: bigint;
28
35
  /** AllSet bridge contract address */
29
36
  bridgeAddress: Address;
30
37
  /** Encoded bridge.deposit(...) calldata from encodeDepositCalldata() */
31
38
  depositCalldata: Hex;
32
- /** Balance poll interval in ms (default: 3000) */
33
- pollIntervalMs?: number;
34
- /** Total timeout in ms waiting for balance (default: no timeout) */
35
- timeoutMs?: number;
36
- /** Called on each balance check */
37
- onBalanceCheck?: (balance: bigint) => void;
39
+ /** Per-request HTTP timeout in ms for backend POSTs (default: 60000) */
40
+ requestTimeoutMs?: number;
38
41
  }
39
42
  export interface SmartDepositResult {
40
43
  txHash: Hash;
41
44
  userOpHash: Hash;
42
45
  userAddress: Address;
43
- /** Token balance at the time of deposit */
44
- tokenBalance: bigint;
46
+ }
47
+ export declare class InsufficientBalanceError extends Error {
48
+ readonly balance: bigint;
49
+ readonly required: bigint;
50
+ readonly tokenAddress: Address;
51
+ constructor(balance: bigint, required: bigint, tokenAddress: Address);
45
52
  }
46
53
  export declare function smartDeposit(params: SmartDepositParams): Promise<SmartDepositResult>;
47
54
  //# sourceMappingURL=eip7702.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"eip7702.d.ts","sourceRoot":"","sources":["../../src/node/eip7702.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAML,KAAK,OAAO,EACZ,KAAK,IAAI,EACT,KAAK,GAAG,EACT,MAAM,MAAM,CAAC;AAgBd,MAAM,WAAW,kBAAkB;IACjC,2DAA2D;IAC3D,UAAU,EAAE,GAAG,CAAC;IAChB,iGAAiG;IACjG,MAAM,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,YAAY,EAAE,OAAO,CAAC;IACtB,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,aAAa,EAAE,OAAO,CAAC;IACvB,wEAAwE;IACxE,eAAe,EAAE,GAAG,CAAC;IACrB,kDAAkD;IAClD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mCAAmC;IACnC,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,IAAI,CAAC;IACb,UAAU,EAAE,IAAI,CAAC;IACjB,WAAW,EAAE,OAAO,CAAC;IACrB,2CAA2C;IAC3C,YAAY,EAAE,MAAM,CAAC;CACtB;AA2HD,wBAAsB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CA8I1F"}
1
+ {"version":3,"file":"eip7702.d.ts","sourceRoot":"","sources":["../../src/node/eip7702.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAML,KAAK,OAAO,EACZ,KAAK,IAAI,EACT,KAAK,GAAG,EACT,MAAM,MAAM,CAAC;AA4Dd,MAAM,WAAW,kBAAkB;IACjC;;;;;;OAMG;IACH,UAAU,EAAE,GAAG,CAAC;IAChB,+FAA+F;IAC/F,MAAM,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB,kDAAkD;IAClD,YAAY,EAAE,OAAO,CAAC;IACtB,4FAA4F;IAC5F,MAAM,EAAE,MAAM,CAAC;IACf,qCAAqC;IACrC,aAAa,EAAE,OAAO,CAAC;IACvB,wEAAwE;IACxE,eAAe,EAAE,GAAG,CAAC;IACrB,wEAAwE;IACxE,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,IAAI,CAAC;IACb,UAAU,EAAE,IAAI,CAAC;IACjB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,qBAAa,wBAAyB,SAAQ,KAAK;aAE/B,OAAO,EAAE,MAAM;aACf,QAAQ,EAAE,MAAM;aAChB,YAAY,EAAE,OAAO;gBAFrB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,OAAO;CAOxC;AA6HD,wBAAsB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CA2K1F"}
@@ -2,26 +2,84 @@
2
2
  * eip7702.ts — EIP-7702 smartDeposit via AllSet Portal relay
3
3
  *
4
4
  * Flow:
5
- * 1. Poll ERC-20 balance until >= minAmount
6
- * 2. POST /userop/prepare → backend assembles UserOp + paymasterData
7
- * 3. Sign EIP-7702 authorization (re-delegate EOA to v0.8 impl)
8
- * 4. Sign UserOperation (EIP-712, v0.8)
9
- * 5. POST /userop/submit → backend calls Pimlico eth_sendUserOperation
5
+ * 1. Check ERC-20 balance; throw InsufficientBalanceError if < amount
6
+ * 2. POST /userop/prepare → backend assembles UserOp + paymasterData (3 retries)
7
+ * 3. Pin delegate address against TRUSTED_DELEGATES allowlist
8
+ * 4. Sign EIP-7702 authorization (re-delegate EOA to v0.8 impl)
9
+ * 5. Sign UserOperation (EIP-712, v0.8)
10
+ * 6. POST /userop/submit → backend calls Pimlico eth_sendUserOperation
10
11
  *
11
12
  * Private key never leaves the SDK.
12
13
  * Pimlico API key never touches the SDK.
13
14
  * Gas is paid in USDC via ERC-20 Paymaster.
14
15
  * Chain is inferred from rpcUrl (backend calls eth_chainId) — no hardcoded chain list.
15
16
  */
16
- import { createPublicClient, encodePacked, http, keccak256, parseAbi, } from 'viem';
17
+ import { createPublicClient, encodeAbiParameters, http, keccak256, parseAbi, } from 'viem';
17
18
  import { privateKeyToAccount } from 'viem/accounts';
18
19
  import { getUserOperationTypedData } from 'viem/account-abstraction';
19
20
  const ENTRY_POINT_V08 = '0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108';
21
+ /**
22
+ * Allowlist of trusted EIP-7702 delegate addresses (lowercase).
23
+ * smartDeposit will throw if the backend returns a delegate not in this set,
24
+ * preventing a compromised backend from delegating EOAs to a malicious contract.
25
+ */
26
+ const TRUSTED_DELEGATES = new Set([
27
+ '0xe6cae83bde06e4c305530e199d7217f42808555b', // Simple7702Account v0.8
28
+ ]);
20
29
  const ERC20_BALANCEOF_ABI = parseAbi([
21
30
  'function balanceOf(address account) view returns (uint256)',
22
31
  ]);
23
- function sleep(ms) {
24
- return new Promise((resolve) => setTimeout(resolve, ms));
32
+ /**
33
+ * Encode a number/bigint as an even-length 0x-prefixed hex string.
34
+ * Some strict bundler parsers expect byte-aligned hex — pad to even length.
35
+ */
36
+ function toEvenHex(n) {
37
+ let h = n.toString(16);
38
+ if (h.length % 2 !== 0)
39
+ h = `0${h}`;
40
+ return `0x${h}`;
41
+ }
42
+ /**
43
+ * POST JSON with a hard timeout via AbortController.
44
+ * Node's global fetch has no default timeout.
45
+ */
46
+ async function postJson(url, body, timeoutMs) {
47
+ const ac = new AbortController();
48
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
49
+ try {
50
+ const res = await fetch(url, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify(body),
54
+ signal: ac.signal,
55
+ });
56
+ if (!res.ok) {
57
+ const err = await res.text();
58
+ throw new Error(`POST ${url} failed (${res.status}): ${err}`);
59
+ }
60
+ return (await res.json());
61
+ }
62
+ catch (e) {
63
+ if (e.name === 'AbortError') {
64
+ throw new Error(`POST ${url} timed out after ${timeoutMs}ms`);
65
+ }
66
+ throw e;
67
+ }
68
+ finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }
72
+ export class InsufficientBalanceError extends Error {
73
+ balance;
74
+ required;
75
+ tokenAddress;
76
+ constructor(balance, required, tokenAddress) {
77
+ super(`Insufficient token balance: have ${balance}, need ${required} (token ${tokenAddress})`);
78
+ this.balance = balance;
79
+ this.required = required;
80
+ this.tokenAddress = tokenAddress;
81
+ this.name = 'InsufficientBalanceError';
82
+ }
25
83
  }
26
84
  // ─── Helpers ──────────────────────────────────────────────────────────────────
27
85
  /**
@@ -80,62 +138,100 @@ function serializeUserOp(op) {
80
138
  }
81
139
  // ─── Main function ─────────────────────────────────────────────────────────────
82
140
  export async function smartDeposit(params) {
83
- const { privateKey, rpcUrl, allsetApiUrl, tokenAddress, minAmount, bridgeAddress, depositCalldata, pollIntervalMs = 3000, timeoutMs, onBalanceCheck, } = params;
141
+ const { privateKey, rpcUrl, allsetApiUrl, tokenAddress, amount, bridgeAddress, depositCalldata, requestTimeoutMs = 60_000, } = params;
84
142
  const eoa = privateKeyToAccount(privateKey);
85
143
  // No chain object needed — chainId is fetched dynamically from the RPC
86
144
  const publicClient = createPublicClient({ transport: http(rpcUrl) });
87
- // Step 1: Poll ERC-20 balance
88
- const startTime = Date.now();
89
- let tokenBalance = 0n;
90
- while (true) {
91
- if (timeoutMs && Date.now() - startTime > timeoutMs) {
92
- throw new Error('smartDeposit: timed out waiting for balance');
93
- }
94
- tokenBalance = (await publicClient.readContract({
95
- address: tokenAddress,
96
- abi: ERC20_BALANCEOF_ABI,
97
- functionName: 'balanceOf',
98
- args: [eoa.address],
99
- }));
100
- onBalanceCheck?.(tokenBalance);
101
- if (tokenBalance >= minAmount)
102
- break;
103
- await sleep(pollIntervalMs);
145
+ // Step 1: One-shot balance check — caller is responsible for funding the EOA first
146
+ const tokenBalance = (await publicClient.readContract({
147
+ address: tokenAddress,
148
+ abi: ERC20_BALANCEOF_ABI,
149
+ functionName: 'balanceOf',
150
+ args: [eoa.address],
151
+ }));
152
+ if (tokenBalance < amount) {
153
+ throw new InsufficientBalanceError(tokenBalance, amount, tokenAddress);
104
154
  }
105
155
  // Fetch chainId once — used for EIP-7702 auth and UserOp signing
106
156
  const chainId = await publicClient.getChainId();
107
- // Step 2: Build request auth signature (proves caller owns the private key)
108
- // Backend verifies: ecrecover(hash, authSig) == from
157
+ // Step 2: Build request auth signature (proves caller owns the private key).
158
+ // Preimage: abi.encode(domainTag, chainId, nonce, from, tokenAddress, amount, bridgeAddress, depositCalldata, timestamp)
159
+ // - Domain tag prevents cross-protocol signature collisions.
160
+ // - chainId prevents cross-chain replay.
161
+ // - nonce (random 32 bytes) prevents in-protocol replay; backend must track used nonces.
162
+ // - abi.encode (not encodePacked) eliminates dynamic-field collision ambiguity.
109
163
  const timestamp = Math.floor(Date.now() / 1000);
110
- const msgHash = keccak256(encodePacked(['address', 'address', 'uint256', 'address', 'bytes', 'uint256'], [eoa.address, tokenAddress, minAmount, bridgeAddress, depositCalldata, BigInt(timestamp)]));
164
+ const nonceBytes = crypto.getRandomValues(new Uint8Array(32));
165
+ const nonce = `0x${Array.from(nonceBytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;
166
+ const DOMAIN_TAG = 'AllSet Portal authSig v1';
167
+ const msgHash = keccak256(encodeAbiParameters([
168
+ { type: 'string' },
169
+ { type: 'uint256' },
170
+ { type: 'bytes32' },
171
+ { type: 'address' },
172
+ { type: 'address' },
173
+ { type: 'uint256' },
174
+ { type: 'address' },
175
+ { type: 'bytes' },
176
+ { type: 'uint256' },
177
+ ], [
178
+ DOMAIN_TAG,
179
+ BigInt(chainId),
180
+ nonce,
181
+ eoa.address,
182
+ tokenAddress,
183
+ amount,
184
+ bridgeAddress,
185
+ depositCalldata,
186
+ BigInt(timestamp),
187
+ ]));
111
188
  const authSig = await eoa.signMessage({ message: { raw: msgHash } });
112
- // Step 3: POST /userop/prepare
189
+ // Step 3: POST /userop/prepare (3 attempts with exponential backoff: 0, 500, 1500ms)
113
190
  const prepareReq = {
114
191
  rpcUrl,
115
192
  from: eoa.address,
116
193
  tokenAddress,
117
- amount: minAmount.toString(),
194
+ amount: amount.toString(),
118
195
  bridgeAddress,
119
196
  depositCalldata,
197
+ chainId,
198
+ nonce,
120
199
  timestamp,
121
200
  authSig,
122
201
  };
123
- const prepareRes = await fetch(`${allsetApiUrl}/userop/prepare`, {
124
- method: 'POST',
125
- headers: { 'Content-Type': 'application/json' },
126
- body: JSON.stringify(prepareReq),
127
- });
128
- if (!prepareRes.ok) {
129
- const err = await prepareRes.text();
130
- throw new Error(`smartDeposit prepare failed (${prepareRes.status}): ${err}`);
202
+ const PREPARE_DELAYS = [0, 500, 1500];
203
+ let prepared;
204
+ for (let attempt = 0; attempt < PREPARE_DELAYS.length; attempt++) {
205
+ if (PREPARE_DELAYS[attempt] > 0) {
206
+ await new Promise((r) => setTimeout(r, PREPARE_DELAYS[attempt]));
207
+ }
208
+ try {
209
+ prepared = await postJson(`${allsetApiUrl}/userop/prepare`, prepareReq, requestTimeoutMs);
210
+ break;
211
+ }
212
+ catch (e) {
213
+ const isLast = attempt === PREPARE_DELAYS.length - 1;
214
+ // Retry on network errors and 5xx/429 (message contains status code)
215
+ const msg = e.message ?? '';
216
+ const isRetryable = !msg.match(/POST .+ failed \([1-4][0-9]{2}\)/) || msg.includes('(429)') || msg.includes('(5');
217
+ if (isLast || !isRetryable)
218
+ throw e;
219
+ }
220
+ }
221
+ // Step 4: Pin delegate address against trusted allowlist
222
+ if (!TRUSTED_DELEGATES.has(prepared.delegate7702Address.toLowerCase())) {
223
+ throw new Error(`smartDeposit: untrusted delegate address returned by backend: ${prepared.delegate7702Address}`);
131
224
  }
132
- const prepared = (await prepareRes.json());
133
- // Step 4: Sign EIP-7702 authorization.
225
+ // Step 5: Sign EIP-7702 authorization.
134
226
  // We always re-sign to ensure the EOA is delegated to the correct v0.8 impl,
135
227
  // even if a prior (possibly outdated) delegation exists.
136
228
  let eip7702Auth;
137
229
  if (prepared.needsAuthorization) {
138
- const accountNonce = await publicClient.getTransactionCount({ address: eoa.address });
230
+ // Use 'pending' so mempool txs from this EOA are counted in the nonce.
231
+ const accountNonce = await publicClient.getTransactionCount({
232
+ address: eoa.address,
233
+ blockTag: 'pending',
234
+ });
139
235
  const signed = await eoa.signAuthorization({
140
236
  address: prepared.delegate7702Address,
141
237
  chainId,
@@ -144,14 +240,14 @@ export async function smartDeposit(params) {
144
240
  const yParity = signed.yParity ?? 0;
145
241
  eip7702Auth = {
146
242
  address: prepared.delegate7702Address,
147
- chainId: `0x${chainId.toString(16)}`,
148
- nonce: `0x${accountNonce.toString(16)}`,
149
- yParity: `0x${yParity.toString(16)}`,
243
+ chainId: toEvenHex(chainId),
244
+ nonce: toEvenHex(accountNonce),
245
+ yParity: toEvenHex(yParity),
150
246
  r: `0x${BigInt(signed.r).toString(16).padStart(64, '0')}`,
151
247
  s: `0x${BigInt(signed.s).toString(16).padStart(64, '0')}`,
152
248
  };
153
249
  }
154
- // Step 5: Parse backend response + sign UserOperation (v0.8 uses EIP-712 typed data)
250
+ // Step 6: Parse backend response + sign UserOperation (v0.8 uses EIP-712 typed data)
155
251
  const userOpToSign = parseUserOp(prepared.unsignedUserOp);
156
252
  // v0.8 requires EIP-712 signTypedData, NOT signMessage/personal_sign
157
253
  const typedData = getUserOperationTypedData({
@@ -161,7 +257,7 @@ export async function smartDeposit(params) {
161
257
  });
162
258
  const signature = await eoa.signTypedData(typedData);
163
259
  const signedUserOp = { ...userOpToSign, signature };
164
- // Step 6: POST /userop/submit
260
+ // Step 7: POST /userop/submit (single attempt — UserOps are not idempotent)
165
261
  const serialized = serializeUserOp(signedUserOp);
166
262
  if (eip7702Auth) {
167
263
  serialized.eip7702Auth = eip7702Auth;
@@ -170,20 +266,10 @@ export async function smartDeposit(params) {
170
266
  rpcUrl,
171
267
  signedUserOp: serialized,
172
268
  };
173
- const submitRes = await fetch(`${allsetApiUrl}/userop/submit`, {
174
- method: 'POST',
175
- headers: { 'Content-Type': 'application/json' },
176
- body: JSON.stringify(submitReq),
177
- });
178
- if (!submitRes.ok) {
179
- const err = await submitRes.text();
180
- throw new Error(`smartDeposit submit failed (${submitRes.status}): ${err}`);
181
- }
182
- const { txHash, userOpHash: returnedUserOpHash } = (await submitRes.json());
269
+ const { txHash, userOpHash: returnedUserOpHash } = await postJson(`${allsetApiUrl}/userop/submit`, submitReq, requestTimeoutMs);
183
270
  return {
184
271
  txHash,
185
272
  userOpHash: returnedUserOpHash,
186
273
  userAddress: eoa.address,
187
- tokenBalance,
188
274
  };
189
275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastxyz/allset-sdk",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "AllSet SDK for AllSet bridge flows between Fast and EVM testnets",
5
5
  "keywords": [
6
6
  "allset",