@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.
- package/README.md +264 -0
- package/dist/accountKit.d.ts +265 -0
- package/dist/accountKit.js +638 -0
- package/dist/approve.d.ts +46 -0
- package/dist/approve.js +69 -0
- package/dist/auth.d.ts +77 -0
- package/dist/auth.js +76 -0
- package/dist/eip712.d.ts +216 -0
- package/dist/eip712.js +229 -0
- package/dist/http.d.ts +46 -0
- package/dist/http.js +120 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +15 -0
- package/dist/types.d.ts +235 -0
- package/dist/types.js +20 -0
- package/dist/ws.d.ts +118 -0
- package/dist/ws.js +224 -0
- package/package.json +62 -0
|
@@ -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>;
|