@pulsepairs/sdk 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.
@@ -0,0 +1,638 @@
1
+ /**
2
+ * Account Kit (Alchemy smart-account) signing for the UpDown satellite.
3
+ *
4
+ * ─── Why this exists ─────────────────────────────────────────────────────
5
+ * rain.trade custody is Alchemy Account Kit (a smart-contract account, "SCA").
6
+ * The UpDown settlement verifies order signatures on-chain with OpenZeppelin
7
+ * `SignatureChecker.isValidSignatureNow(maker, digest, sig)` (OZ 5.6.1). When
8
+ * `maker` is an SCA, that routes to `IERC1271(maker).isValidSignature(...)`.
9
+ *
10
+ * To make one wallet span BOTH products, this module builds its own Alchemy
11
+ * smart-wallet client using the EXACT same config rain.trade's `RainAA` uses
12
+ * (same `chain` + `alchemyApiKey` + `paymasterPolicyId` + owner EOA). Account
13
+ * Kit derives the SCA address deterministically from (owner, factory), so the
14
+ * same owner + same config ⇒ the SAME account address ⇒ one shared wallet.
15
+ * We deliberately do NOT import `RainAA` — it is a published, unpatchable
16
+ * package that exposes no `signTypedData`, and the meeting locked UpDown as a
17
+ * *segregated* package. We mirror its config, not its code.
18
+ *
19
+ * ─── The two hard constraints (see UPDOWN_SDK_MEETING_PREP_OPTION_A) ─────
20
+ * 1. BARE ERC-1271, not EIP-6492. OZ `SignatureChecker` v5.6.1 is NOT
21
+ * 6492-aware. `signTypedData` on an *undeployed* SCA can return a
22
+ * 6492-wrapped blob that the settlement cannot parse → `enterPosition`
23
+ * reverts. We therefore (a) enforce deploy-before-fill and (b) defensively
24
+ * `stripErc6492Wrapper(...)` every signature so a wrapped blob never
25
+ * reaches the matcher.
26
+ * 2. Two DIFFERENT session-key mechanisms — don't conflate them:
27
+ * a. RainAA-style "send session" (`grantSession`, Alchemy
28
+ * `grantPermissions({type:'root'})`): authorizes UserOp EXECUTION only.
29
+ * Its signatures pack the OWNER validation entity, so it is verified
30
+ * INCAPABLE of ERC-1271 order signing (isValidSignature → 0xffffffff;
31
+ * see updown-demo/POC_SESSION_KEY_ORDERS_2026-07-06.md). Used only for
32
+ * gasless *sends* (onboard / approve / withdraw), mirroring RainAA.
33
+ * b. "ORDER session" (2026-07-06, PoC-validated on-chain + live API): a
34
+ * locally-generated key installed on the MA-v2 SCA as a
35
+ * SingleSignerValidationModule entity with isSignatureValidation=true
36
+ * and isUserOpValidation=false — it can ONLY answer `isValidSignature`
37
+ * (orders / cancels / ws-auth, popup-less) and can never execute a
38
+ * UserOp. Installed inside the onboarding UserOp (one owner popup
39
+ * total) or via `ensureOrderSession()`. Orders sign with it when
40
+ * active; the OWNER client is the automatic fallback. Client-side 24h
41
+ * expiry only (on-chain expiry needs a time-range hook — follow-up);
42
+ * `revokeOrderSession()` drops the local key.
43
+ *
44
+ * ─── MUST be confirmed with a fork test before trusting on mainnet ───────
45
+ * deploy a real Alchemy SCA on the target chain → `approve(settlement,USDT)`
46
+ * → sign a real `Order` via `signTypedDataBare(...)` → call `enterPosition`
47
+ * as the relayer → assert NO revert. Negative control: an *undeployed* SCA
48
+ * and/or a 6492-wrapped signature MUST revert. This resolves the open
49
+ * Light-Account-v2-vs-Modular-Account-v2 (ERC-7739) question: for MA v2 only
50
+ * the account-level `signTypedData` over the FULL typed-data validates 1271
51
+ * (handing the SCA a bare `bytes32` silently fails). `signTypedDataBare`
52
+ * below signs the full typed-data, which is the correct path for both.
53
+ */
54
+ import { decodeAbiParameters, } from "viem";
55
+ import { buildWsAuthTypedData, freshSessionId } from "./eip712.js";
56
+ /* ───────────────────────── EIP-6492 unwrap ───────────────────────── */
57
+ /** The 32-byte EIP-6492 magic suffix (hex, no `0x`). */
58
+ const ERC6492_MAGIC = "6492649264926492649264926492649264926492649264926492649264926492";
59
+ /** True if `signature` is an EIP-6492-wrapped blob (ends with the magic suffix). */
60
+ export function isErc6492Signature(signature) {
61
+ return signature.length >= 66 && signature.slice(-64).toLowerCase() === ERC6492_MAGIC;
62
+ }
63
+ /**
64
+ * Return the BARE inner signature from an EIP-6492-wrapped blob, or the input
65
+ * unchanged if it is not wrapped. The 6492 layout is
66
+ * `abi.encode(address factory, bytes factoryCalldata, bytes signature)` with
67
+ * the 32-byte magic suffix appended. Once the SCA is deployed the bare inner
68
+ * signature is exactly what OZ `SignatureChecker` needs.
69
+ */
70
+ export function stripErc6492Wrapper(signature) {
71
+ if (!isErc6492Signature(signature))
72
+ return signature;
73
+ const body = ("0x" + signature.slice(2, signature.length - 64));
74
+ const [, , inner] = decodeAbiParameters([{ type: "address" }, { type: "bytes" }, { type: "bytes" }], body);
75
+ return inner;
76
+ }
77
+ /**
78
+ * Wrap ANY raw typed-data signer so its output is a BARE ERC-1271 signature
79
+ * the UpDown settlement can verify. Use this when the host app already owns an
80
+ * Alchemy smart-wallet client (e.g. rain.trade's shared session) and just
81
+ * wants UpDown-correct order signatures without a second signer:
82
+ *
83
+ * const signOrder = bareErc1271Signer((td) => rainSmartWalletClient.signTypedData(td));
84
+ * const sig = await signOrder(buildOrderTypedData({ cfg, settlementAddress, message }));
85
+ *
86
+ * The SCA MUST be deployed on-chain before the signature is used in a fill.
87
+ */
88
+ export function bareErc1271Signer(raw) {
89
+ return async (typedData) => stripErc6492Wrapper(await raw(typedData));
90
+ }
91
+ const SESSION_DURATION_SEC = 60 * 60 * 24; // 24h — matches RainAA
92
+ /* ─────────────── ORDER-session (header §2b) storage ─────────────── */
93
+ const ORDER_SESSION_TTL_SEC = 60 * 60 * 24; // 24h, client-side only
94
+ function orderSessionKey(sca) {
95
+ return `updown:oskey:${sca.toLowerCase()}`;
96
+ }
97
+ const memoryStore = new Map();
98
+ function defaultOrderSessionStorage() {
99
+ const ls = globalThis.localStorage;
100
+ if (ls) {
101
+ return {
102
+ getItem: (k) => {
103
+ try {
104
+ return ls.getItem(k);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ },
110
+ setItem: (k, v) => {
111
+ try {
112
+ ls.setItem(k, v);
113
+ }
114
+ catch { /* private mode — session lives this tab only */ }
115
+ },
116
+ removeItem: (k) => {
117
+ try {
118
+ ls.removeItem(k);
119
+ }
120
+ catch { /* best effort */ }
121
+ },
122
+ };
123
+ }
124
+ return {
125
+ getItem: (k) => memoryStore.get(k) ?? null,
126
+ setItem: (k, v) => void memoryStore.set(k, v),
127
+ removeItem: (k) => void memoryStore.delete(k),
128
+ };
129
+ }
130
+ /** Shared SCA-address cache key (identical to rain.trade's `useRain`). */
131
+ function saCacheKey(eoa) {
132
+ return `rain:sa:${eoa.toLowerCase()}`;
133
+ }
134
+ function readCachedSA(eoa) {
135
+ try {
136
+ const ls = globalThis.localStorage;
137
+ const v = ls?.getItem(saCacheKey(eoa)) ?? null;
138
+ return v && /^0x[0-9a-fA-F]{40}$/.test(v) ? v : null;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ function writeCachedSA(eoa, addr) {
145
+ try {
146
+ globalThis.localStorage?.setItem(saCacheKey(eoa), addr);
147
+ }
148
+ catch {
149
+ /* private mode / SSR — best effort */
150
+ }
151
+ }
152
+ /**
153
+ * Owner-key ERC-1271 order signer + gasless custody sends on an Alchemy SCA.
154
+ *
155
+ * Lifecycle:
156
+ * const ak = new UpDownAccountKitSigner({ walletClient, alchemyApiKey, paymasterPolicyId, chain });
157
+ * const sca = await ak.connect(); // SCA address (order.maker)
158
+ * await ak.onboard({ usdt, settlement }); // deploy SCA + approve USDT (one UserOp)
159
+ * const sig = await ak.signTypedDataBare(orderTypedData); // owner ERC-1271, bare
160
+ *
161
+ * All Account Kit packages are lazy-imported peer deps (mirrors RainAA), so
162
+ * importing this module never pulls `@account-kit/*` unless you call `connect`.
163
+ */
164
+ export class UpDownAccountKitSigner {
165
+ config;
166
+ _client = null; // owner smart-wallet client
167
+ _sessionClient = null; // session-key smart-wallet client
168
+ _account = null;
169
+ _address = null;
170
+ _ownerEoa = null;
171
+ _sessionContext = null;
172
+ _sessionKeyAddress = null;
173
+ _sessionPrivateKey = null;
174
+ _sessionExpirySec = null;
175
+ // ORDER session (header §2b) — distinct from the RainAA send-session above.
176
+ _orderSession = null;
177
+ _orderSessionClient = null;
178
+ _mods = null;
179
+ constructor(config) {
180
+ if (!config.walletClient)
181
+ throw new Error("walletClient (owner EIP-1193 provider) is required");
182
+ if (!config.alchemyApiKey)
183
+ throw new Error("alchemyApiKey is required");
184
+ if (!config.chain)
185
+ throw new Error("chain is required");
186
+ this.config = config;
187
+ }
188
+ /**
189
+ * Create the owner smart-wallet client and resolve the SCA address. Does NOT
190
+ * prompt for a session — call `grantSession()` (or `onboard`, which grants
191
+ * as needed) before gasless sends. Order signing works immediately (owner key).
192
+ */
193
+ async connect() {
194
+ if (this._address && this._client)
195
+ return this._address;
196
+ const [aaCore, infraMod, walletClientLib, viemAccounts] = await Promise.all([
197
+ // @ts-ignore peer dep, provided by the host app (rain.trade already ships it)
198
+ import("@aa-sdk/core"),
199
+ // @ts-ignore peer dep
200
+ import("@account-kit/infra"),
201
+ // @ts-ignore peer dep
202
+ import("@account-kit/wallet-client"),
203
+ // @ts-ignore viem subpath
204
+ import("viem/accounts"),
205
+ ]);
206
+ const { WalletClientSigner, LocalAccountSigner } = aaCore;
207
+ const { alchemy, defineAlchemyChain } = infraMod;
208
+ const { createSmartWalletClient } = walletClientLib;
209
+ // @ts-ignore viem subpath
210
+ const { createWalletClient, custom } = await import("viem");
211
+ const alchemyChain = defineAlchemyChain({
212
+ chain: this.config.chain,
213
+ rpcBaseUrl: `https://${this.config.chain.id === 42161 ? "arb-mainnet" : "arb-sepolia"}.g.alchemy.com/v2`,
214
+ });
215
+ const eoaSigner = new WalletClientSigner(createWalletClient({ transport: custom(this.config.walletClient) }), "wallet");
216
+ const eoaClient = createSmartWalletClient({
217
+ chain: alchemyChain,
218
+ signer: eoaSigner,
219
+ ...(this.config.paymasterPolicyId ? { policyId: this.config.paymasterPolicyId } : {}),
220
+ transport: alchemy({ apiKey: this.config.alchemyApiKey, nodeRpcUrl: this.config.rpcUrl }),
221
+ });
222
+ const account = await eoaClient.requestAccount();
223
+ if (!account?.address)
224
+ throw new Error("Failed to resolve Alchemy smart account");
225
+ this._client = eoaClient;
226
+ this._account = account;
227
+ this._address = account.address;
228
+ this._ownerEoa = (await eoaSigner.getAddress());
229
+ this._mods = {
230
+ createSmartWalletClient,
231
+ alchemy,
232
+ defineAlchemyChain,
233
+ WalletClientSigner,
234
+ LocalAccountSigner,
235
+ viemAccounts,
236
+ alchemyChain,
237
+ };
238
+ writeCachedSA(this._ownerEoa, this._address);
239
+ return this._address;
240
+ }
241
+ /** The SCA address — this is `order.maker`, the deposit address, and the
242
+ * ERC-1271 signer. Identical to rain.trade's SCA for the same owner. */
243
+ get address() {
244
+ if (!this._address)
245
+ throw new Error("Not connected. Call connect() first.");
246
+ return this._address;
247
+ }
248
+ /** Owner EOA address (signs the ERC-1271 order signatures under the hood). */
249
+ get ownerEoa() {
250
+ if (!this._ownerEoa)
251
+ throw new Error("Not connected. Call connect() first.");
252
+ return this._ownerEoa;
253
+ }
254
+ /** The owner smart-wallet client. Exposes `signTypedData`, `sendCalls`, etc. */
255
+ get ownerClient() {
256
+ if (!this._client)
257
+ throw new Error("Not connected. Call connect() first.");
258
+ return this._client;
259
+ }
260
+ get hasActiveSession() {
261
+ return !!this._sessionExpirySec && this._sessionExpirySec > Math.floor(Date.now() / 1000);
262
+ }
263
+ /**
264
+ * EIP-712 typed data as a BARE ERC-1271 signature — the ONLY signing path
265
+ * for orders, cancels, and ws-auth. ORDER-session-signed (popup-less) when
266
+ * an order session is active (header §2b); owner-signed with the 6492
267
+ * wrapper stripped otherwise. Requires `connect()`; the SCA should be
268
+ * deployed (`onboard`) before the signature is used in a fill.
269
+ */
270
+ async signTypedDataBare(typedData) {
271
+ if (!this._client || !this._address)
272
+ throw new Error("Not connected. Call connect() first.");
273
+ const session = await this.orderSessionSign(typedData);
274
+ if (session)
275
+ return session;
276
+ const raw = (await this._client.signTypedData({
277
+ ...typedData,
278
+ account: this._address,
279
+ }));
280
+ return stripErc6492Wrapper(raw);
281
+ }
282
+ /* ─────────────── ORDER session (header §2b) ─────────────── */
283
+ get orderSessionsEnabled() {
284
+ return this.config.orderSessions !== false;
285
+ }
286
+ get orderSessionStore() {
287
+ return this.config.orderSessionStorage ?? defaultOrderSessionStorage();
288
+ }
289
+ /** True iff an unexpired order session exists for the connected SCA. */
290
+ get hasOrderSession() {
291
+ if (!this.orderSessionsEnabled || !this._address)
292
+ return false;
293
+ return !!(this._orderSession ?? this.readOrderSession());
294
+ }
295
+ readOrderSession() {
296
+ if (!this._address)
297
+ return null;
298
+ try {
299
+ const raw = this.orderSessionStore.getItem(orderSessionKey(this._address));
300
+ if (!raw)
301
+ return null;
302
+ const rec = JSON.parse(raw);
303
+ if (rec?.v !== 1 || typeof rec.privateKey !== "string" || !rec.entityId)
304
+ return null;
305
+ if (rec.expirySec <= Math.floor(Date.now() / 1000)) {
306
+ this.orderSessionStore.removeItem(orderSessionKey(this._address));
307
+ return null;
308
+ }
309
+ return rec;
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ }
315
+ persistOrderSession(record) {
316
+ if (!this._address)
317
+ return;
318
+ this.orderSessionStore.setItem(orderSessionKey(this._address), JSON.stringify(record));
319
+ this._orderSession = record;
320
+ this._orderSessionClient = null; // built lazily on first sign
321
+ }
322
+ /** Drop the local order-session key (no on-chain uninstall — follow-up). */
323
+ revokeOrderSession() {
324
+ if (this._address)
325
+ this.orderSessionStore.removeItem(orderSessionKey(this._address));
326
+ this._orderSession = null;
327
+ this._orderSessionClient = null;
328
+ }
329
+ /**
330
+ * Sign typed data with the order-session key (MA-v2 entity signature, bare
331
+ * by construction). Returns null when no session is active or anything
332
+ * fails — `signTypedDataBare` falls back to the owner path.
333
+ */
334
+ async orderSessionSign(typedData) {
335
+ try {
336
+ const account = await this.orderSessionAccount();
337
+ if (!account)
338
+ return null;
339
+ return (await account.signTypedData(typedData));
340
+ }
341
+ catch {
342
+ this._orderSessionClient = null; // rebuild lazily; don't wedge signing
343
+ return null;
344
+ }
345
+ }
346
+ /** Lazily build the MA-v2 client bound to the order-session key's entity. */
347
+ async orderSessionAccount() {
348
+ if (!this.orderSessionsEnabled || !this._address)
349
+ return null;
350
+ const rec = this._orderSession ?? this.readOrderSession();
351
+ if (!rec)
352
+ return null;
353
+ if (rec.expirySec <= Math.floor(Date.now() / 1000)) {
354
+ this.revokeOrderSession();
355
+ return null;
356
+ }
357
+ this._orderSession = rec;
358
+ if (!this._orderSessionClient) {
359
+ const [aaCore, scMod, infraMod] = await Promise.all([
360
+ // @ts-ignore peer dep
361
+ import("@aa-sdk/core"),
362
+ // @ts-ignore peer dep
363
+ import("@account-kit/smart-contracts"),
364
+ // @ts-ignore peer dep
365
+ import("@account-kit/infra"),
366
+ ]);
367
+ const { LocalAccountSigner } = aaCore;
368
+ const { createModularAccountV2Client } = scMod;
369
+ const { alchemy } = infraMod;
370
+ this._orderSessionClient = await createModularAccountV2Client({
371
+ mode: "default",
372
+ chain: await this.infraChain(),
373
+ transport: alchemy({ apiKey: this.config.alchemyApiKey }),
374
+ signer: LocalAccountSigner.privateKeyToAccountSigner(rec.privateKey),
375
+ accountAddress: this._address,
376
+ signerEntity: { entityId: rec.entityId, isGlobalValidation: false },
377
+ });
378
+ }
379
+ return this._orderSessionClient.account;
380
+ }
381
+ /**
382
+ * Make sure an order session exists: no-op when one is active (or the
383
+ * feature is off), otherwise install a fresh session key via a one-time
384
+ * owner UserOp. Fresh users get the install batched into `onboard()` and
385
+ * never hit the UserOp here. Throws if the owner rejects; callers should
386
+ * treat that as non-fatal (owner-key signing keeps working).
387
+ */
388
+ async ensureOrderSession() {
389
+ if (!this.orderSessionsEnabled)
390
+ return "disabled";
391
+ if (!this._client || !this._address)
392
+ throw new Error("Not connected. Call connect() first.");
393
+ if (await this.orderSessionAccount())
394
+ return "active";
395
+ const { call, record } = await this.buildOrderSessionInstallCall();
396
+ await this.sendCalls([call]);
397
+ this.persistOrderSession(record);
398
+ return "installed";
399
+ }
400
+ /**
401
+ * Build the `installValidation` self-call that registers a fresh session
402
+ * key on the SCA as a signature-validation-ONLY entity (validated flow:
403
+ * updown-frontend/scripts/poc-session-key-fe-flow.mjs).
404
+ */
405
+ async buildOrderSessionInstallCall() {
406
+ const [viemAccounts, expMod, viemMod] = await Promise.all([
407
+ // @ts-ignore viem subpath
408
+ import("viem/accounts"),
409
+ // @ts-ignore peer dep
410
+ import("@account-kit/smart-contracts/experimental"),
411
+ // @ts-ignore viem
412
+ import("viem"),
413
+ ]);
414
+ const { generatePrivateKey, privateKeyToAccount } = viemAccounts;
415
+ const { getDefaultSingleSignerValidationModuleAddress, SingleSignerValidationModule, serializeValidationConfig, semiModularAccountBytecodeAbi, } = expMod;
416
+ const privateKey = generatePrivateKey();
417
+ const sessionAddress = privateKeyToAccount(privateKey).address;
418
+ // Random 4-byte entity id (≥2): 0 is the owner entity, and installing an
419
+ // id that already exists on the account reverts — random keeps collisions
420
+ // with prior sessions (lost storage, other hosts) vanishingly unlikely.
421
+ const entityId = 2 + Math.floor(Math.random() * 0x7ffffff0);
422
+ const data = viemMod.encodeFunctionData({
423
+ abi: semiModularAccountBytecodeAbi,
424
+ functionName: "installValidation",
425
+ args: [
426
+ serializeValidationConfig({
427
+ moduleAddress: getDefaultSingleSignerValidationModuleAddress(await this.infraChain()),
428
+ entityId,
429
+ isGlobal: false,
430
+ isSignatureValidation: true, // can answer ERC-1271…
431
+ isUserOpValidation: false, // …but can never execute a UserOp
432
+ }),
433
+ [],
434
+ SingleSignerValidationModule.encodeOnInstallData({ entityId, signer: sessionAddress }),
435
+ [],
436
+ ],
437
+ });
438
+ return {
439
+ call: { to: this.address, data },
440
+ record: { v: 1, privateKey, entityId, expirySec: Math.floor(Date.now() / 1000) + ORDER_SESSION_TTL_SEC },
441
+ };
442
+ }
443
+ /** The @account-kit/infra chain (Alchemy RPC config baked in) for MA-v2 clients. */
444
+ async infraChain() {
445
+ // @ts-ignore peer dep
446
+ const infra = (await import("@account-kit/infra"));
447
+ return this.config.chain.id === 421614 ? infra.arbitrumSepolia : infra.arbitrum;
448
+ }
449
+ /**
450
+ * Build + owner-sign a WS-auth handshake and package it as `WsAuthCredentials`
451
+ * for `UpDownWsClient.connectAuthed({ signAuth })`. `wallet` is the SCA
452
+ * address (private channels are keyed by the SCA, the trading identity).
453
+ */
454
+ async signWsAuth(chainId) {
455
+ if (!this._address)
456
+ throw new Error("Not connected. Call connect() first.");
457
+ const timestamp = BigInt(Math.floor(Date.now() / 1000));
458
+ const sessionId = freshSessionId();
459
+ const typedData = buildWsAuthTypedData({
460
+ cfg: { chainId },
461
+ wallet: this._address,
462
+ timestamp,
463
+ sessionId,
464
+ });
465
+ const signature = await this.signTypedDataBare(typedData);
466
+ return { wallet: this._address, timestamp, sessionId, signature };
467
+ }
468
+ /** True iff the SCA has bytecode on-chain (deploy-before-fill precondition). */
469
+ async isDeployed(publicClient) {
470
+ if (!this._address)
471
+ throw new Error("Not connected. Call connect() first.");
472
+ const code = await publicClient.getBytecode({ address: this._address });
473
+ return !!code && code !== "0x";
474
+ }
475
+ /**
476
+ * Grant a 24h session key (one MetaMask popup) so subsequent sends are
477
+ * gasless + popup-less. Mirrors RainAA's `grantSession`; safe to call when a
478
+ * session is already active (returns the existing one without prompting).
479
+ */
480
+ async grantSession() {
481
+ if (!this._client || !this._address || !this._mods) {
482
+ throw new Error("Not connected. Call connect() first.");
483
+ }
484
+ if (this.hasActiveSession &&
485
+ this._sessionPrivateKey &&
486
+ this._sessionKeyAddress &&
487
+ this._sessionContext &&
488
+ this._sessionExpirySec) {
489
+ return {
490
+ privateKey: this._sessionPrivateKey,
491
+ sessionKeyAddress: this._sessionKeyAddress,
492
+ context: this._sessionContext,
493
+ expirySec: this._sessionExpirySec,
494
+ };
495
+ }
496
+ const { createSmartWalletClient, alchemy, alchemyChain, LocalAccountSigner, viemAccounts } = this._mods;
497
+ const { generatePrivateKey, privateKeyToAccount } = viemAccounts;
498
+ const privateKey = generatePrivateKey();
499
+ const sessionAccount = privateKeyToAccount(privateKey);
500
+ const expirySec = Math.floor(Date.now() / 1000) + SESSION_DURATION_SEC;
501
+ const result = await this._client.grantPermissions({
502
+ account: this._address,
503
+ expirySec,
504
+ key: { publicKey: sessionAccount.address, type: "secp256k1" },
505
+ permissions: [{ type: "root" }],
506
+ });
507
+ this._sessionPrivateKey = privateKey;
508
+ this._sessionKeyAddress = sessionAccount.address;
509
+ this._sessionExpirySec = expirySec;
510
+ this._sessionContext = result.context;
511
+ this._sessionClient = createSmartWalletClient({
512
+ chain: alchemyChain,
513
+ signer: new LocalAccountSigner(sessionAccount),
514
+ ...(this.config.paymasterPolicyId ? { policyId: this.config.paymasterPolicyId } : {}),
515
+ transport: alchemy({ apiKey: this.config.alchemyApiKey, nodeRpcUrl: this.config.rpcUrl }),
516
+ });
517
+ return {
518
+ privateKey,
519
+ sessionKeyAddress: this._sessionKeyAddress,
520
+ context: this._sessionContext,
521
+ expirySec,
522
+ };
523
+ }
524
+ /**
525
+ * One-time onboarding: DEPLOY the SCA, `approve(settlement, USDT, MAX)` AND
526
+ * install the order-session key (header §2b), all in a single UserOp —
527
+ * still exactly one owner signature. Satisfies deploy-before-fill + the
528
+ * allowance `enterPosition` needs, and makes subsequent order signatures
529
+ * popup-less. Order-session prep failures degrade to plain deploy+approve
530
+ * (owner-key signing per order). Uses the owner client so it works before
531
+ * any session exists; deployment is carried by the account init-code.
532
+ */
533
+ async onboard(args) {
534
+ const calls = [
535
+ { to: args.usdt, data: encodeApprove(args.settlement) },
536
+ ];
537
+ let record = null;
538
+ if (this.orderSessionsEnabled && !(await this.orderSessionAccount().catch(() => null))) {
539
+ try {
540
+ const built = await this.buildOrderSessionInstallCall();
541
+ calls.push(built.call);
542
+ record = built.record;
543
+ }
544
+ catch {
545
+ /* onboarding proceeds without a session — owner-key signing stays */
546
+ }
547
+ }
548
+ const txHash = await this.sendCalls(calls);
549
+ if (record)
550
+ this.persistOrderSession(record);
551
+ return txHash;
552
+ }
553
+ /** Idempotent USDT approve to the settlement (gasless UserOp). */
554
+ async approve(args) {
555
+ return this.sendCall({ to: args.usdt, data: encodeApprove(args.settlement) });
556
+ }
557
+ /** Transfer USDT out of the SCA to `to` (gasless UserOp). */
558
+ async withdraw(args) {
559
+ return this.sendCall({ to: args.usdt, data: encodeTransfer(args.to, args.amount) });
560
+ }
561
+ /**
562
+ * Send a single call from the SCA as a UserOp. Gas is paid per the config:
563
+ * sponsorship policy → app-sponsored; ERC-20 policy + `gasToken` → user pays
564
+ * in that token; no policy → SELF-PAID (SCA must hold ETH). Prefers the
565
+ * session client (no popup) when a session is active; otherwise falls back
566
+ * to the owner client (one signature). Returns the on-chain tx hash.
567
+ */
568
+ async sendCall(call) {
569
+ return this.sendCalls([call]);
570
+ }
571
+ /** Send one UserOp batching `calls` in order (same gas/session semantics as
572
+ * `sendCall`); returns the on-chain tx hash. */
573
+ async sendCalls(calls) {
574
+ if (!this._client || !this._account || !this._address) {
575
+ throw new Error("Not connected. Call connect() first.");
576
+ }
577
+ // @ts-ignore viem subpath
578
+ const { toHex } = await import("viem");
579
+ const useSession = this.hasActiveSession && this._sessionClient && this._sessionContext;
580
+ const client = useSession ? this._sessionClient : this._client;
581
+ const capabilities = {};
582
+ if (useSession)
583
+ capabilities.permissions = { context: this._sessionContext };
584
+ if (this.config.paymasterPolicyId && this.config.gasToken) {
585
+ // ERC-20-type Gas Manager policy: the request must carry the erc20
586
+ // capability naming the token the SCA pays gas in, else Alchemy rejects
587
+ // with "erc20 capability is missing". autoApprove injects the exact
588
+ // paymaster approval into the batch.
589
+ capabilities.paymasterService = {
590
+ policyId: this.config.paymasterPolicyId,
591
+ erc20: {
592
+ tokenAddress: this.config.gasToken.tokenAddress,
593
+ postOpSettings: { autoApprove: true },
594
+ },
595
+ };
596
+ }
597
+ const { id } = await client.sendCalls({
598
+ from: this._address,
599
+ calls: calls.map((c) => ({ to: c.to, data: c.data, value: toHex(c.value ?? 0n) })),
600
+ ...(Object.keys(capabilities).length ? { capabilities } : {}),
601
+ });
602
+ const status = await client.waitForCallsStatus({ id });
603
+ const txHash = status.receipts?.[0]?.transactionHash;
604
+ if (!txHash)
605
+ throw new Error(`UserOp ${id} returned no transaction hash`);
606
+ return txHash;
607
+ }
608
+ /** Clear in-memory state (persisted sessions, if any, are left intact). */
609
+ disconnect() {
610
+ this._client = null;
611
+ this._sessionClient = null;
612
+ this._account = null;
613
+ this._address = null;
614
+ this._ownerEoa = null;
615
+ this._sessionContext = null;
616
+ this._sessionKeyAddress = null;
617
+ this._sessionPrivateKey = null;
618
+ this._sessionExpirySec = null;
619
+ this._orderSession = null;
620
+ this._orderSessionClient = null;
621
+ this._mods = null;
622
+ }
623
+ }
624
+ /* ───────────────────────── calldata encoders ───────────────────────── */
625
+ const MAX_UINT256 = (1n << 256n) - 1n;
626
+ function encodeApprove(spender) {
627
+ // approve(address,uint256) selector 0x095ea7b3
628
+ return ("0x095ea7b3" + pad(spender) + pad(MAX_UINT256));
629
+ }
630
+ function encodeTransfer(to, amount) {
631
+ // transfer(address,uint256) selector 0xa9059cbb
632
+ return ("0xa9059cbb" + pad(to) + pad(amount));
633
+ }
634
+ /** Left-pad an address or bigint to a 32-byte (64-hex) ABI word. */
635
+ function pad(v) {
636
+ const hex = typeof v === "bigint" ? v.toString(16) : v.toLowerCase().replace(/^0x/, "");
637
+ return hex.padStart(64, "0");
638
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * One-time USDT.approve(settlement, MaxUint256) helper.
3
+ *
4
+ * Path-1 settlement pulls USDT directly from the maker via `transferFrom`
5
+ * inside `enterPosition`. Without this approval the first BUY reverts
6
+ * with `ERC20: insufficient allowance`. Idempotent: reads current
7
+ * allowance and only submits a tx when below threshold.
8
+ *
9
+ * `viem` is a peer dependency — pass in the public + wallet clients you
10
+ * already have so this helper doesn't bake in a transport choice.
11
+ */
12
+ import type { Address, PublicClient, WalletClient } from "viem";
13
+ export declare const MAX_UINT256: bigint;
14
+ export type EnsureAllowanceResult = {
15
+ status: "already_ok";
16
+ allowance: bigint;
17
+ } | {
18
+ status: "approved";
19
+ txHash: `0x${string}`;
20
+ allowance: bigint;
21
+ };
22
+ /**
23
+ * Ensure the maker has enough USDT allowance on the settlement contract.
24
+ *
25
+ * @param args.publicClient — viem `createPublicClient(...)` for reads.
26
+ * @param args.walletClient — viem `createWalletClient({ account, ... })`
27
+ * with the maker's account (must have ETH for gas).
28
+ * @param args.usdt — USDT token address (from `cfg.usdtAddress`).
29
+ * @param args.settlement — Settlement contract address (from
30
+ * `cfg.pairs[0].settlementAddress` for today's
31
+ * single-settlement deploy).
32
+ * @param args.threshold — Re-approve when current allowance is below this
33
+ * atomic-USDT amount. Default 10,000 USDT.
34
+ *
35
+ * Returns `already_ok` if nothing needed to be done, `approved` with the
36
+ * tx hash if a new approval was sent. Caller can `await
37
+ * publicClient.waitForTransactionReceipt({ hash })` if it wants to confirm
38
+ * before placing orders.
39
+ */
40
+ export declare function ensureSettlementAllowance(args: {
41
+ publicClient: PublicClient;
42
+ walletClient: WalletClient;
43
+ usdt: Address;
44
+ settlement: Address;
45
+ threshold?: bigint;
46
+ }): Promise<EnsureAllowanceResult>;