@theoo/sdk 0.4.2 → 0.4.4
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/dist/abi.d.ts +28 -0
- package/dist/abi.d.ts.map +1 -0
- package/dist/abi.js +86 -0
- package/dist/abi.js.map +1 -0
- package/dist/client.d.ts +155 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +153 -25
- package/dist/client.js.map +1 -1
- package/dist/eip712.d.ts +17 -0
- package/dist/eip712.d.ts.map +1 -0
- package/dist/eip712.js +127 -0
- package/dist/eip712.js.map +1 -0
- package/dist/hash.d.ts +27 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +249 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/solana-vault.d.ts +140 -0
- package/dist/solana-vault.d.ts.map +1 -0
- package/dist/solana-vault.js +242 -0
- package/dist/solana-vault.js.map +1 -0
- package/dist/types.d.ts +275 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vault.d.ts +92 -0
- package/dist/vault.d.ts.map +1 -0
- package/dist/vault.js +165 -0
- package/dist/vault.js.map +1 -0
- package/dist/verify.d.ts +82 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +669 -0
- package/dist/verify.js.map +1 -0
- package/package.json +1 -1
package/dist/verify.js
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the passkey signs, checked before it signs.
|
|
3
|
+
*
|
|
4
|
+
* Theoo holds no key that can move an account, but its server prepares what
|
|
5
|
+
* the passkey signs, and a 32-byte challenge says nothing on its face. So the
|
|
6
|
+
* server sends, with every challenge, the whole of what it is the hash of. The
|
|
7
|
+
* client rebuilds the hash itself, refuses if it differs, and reads the calls
|
|
8
|
+
* inside: they must be the ones this app asked for, or, for an operation the
|
|
9
|
+
* server builds from a quote (a swap, a desk order), they may move no more
|
|
10
|
+
* than the quote said, of no other token, and must leave nothing approved.
|
|
11
|
+
*
|
|
12
|
+
* A server that lies is therefore refused, not obeyed: the worst it can do is
|
|
13
|
+
* fail an operation, never redirect one.
|
|
14
|
+
*/
|
|
15
|
+
import { addressWord, padded, Reader, selector, splitCall, toBig, word } from "./abi.js";
|
|
16
|
+
import { hashMessage, hashTypedData } from "./eip712.js";
|
|
17
|
+
import { base58Encode, base64Decode, bytesToHex, concatBytes, equalBytes, hexToBytes, keccak256, pubkeyBytes, sha256, utf8 } from "./hash.js";
|
|
18
|
+
import { decode as base64urlDecode } from "./base64url.js";
|
|
19
|
+
import { callsHash, decodePropose, VAULT_FACTORY, VAULT_SEL } from "./vault.js";
|
|
20
|
+
import { encodeSolanaCalls, INSTRUCTIONS_SYSVAR, isSolanaVault, predictSolanaVault, SOLANA_VAULT_PROGRAM, SOLANA_VAULT_TAG, solanaProposalAddress, SYSTEM_PROGRAM, u64le } from "./solana-vault.js";
|
|
21
|
+
/** Thrown before the passkey is asked: what the server sent isn't what was asked for. */
|
|
22
|
+
export class SigningRefused extends Error {
|
|
23
|
+
constructor(reason) {
|
|
24
|
+
super(`Not signed: ${reason}`);
|
|
25
|
+
this.code = "SIGNING_REFUSED";
|
|
26
|
+
this.name = "SigningRefused";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function refuse(why) {
|
|
30
|
+
throw new SigningRefused(why);
|
|
31
|
+
}
|
|
32
|
+
/* ── addresses Theoo's quotes may route through ───────────────────────── */
|
|
33
|
+
/** 0x's AllowanceHolder: the only spender a token swap approves. Same address on every chain. */
|
|
34
|
+
export const ALLOWANCE_HOLDER = "0x0000000000001ff3684f28c67538d4d072c22734";
|
|
35
|
+
/** LI.FI's Diamond: the only spender a cross-chain swap approves. Same address on every chain. */
|
|
36
|
+
export const LIFI_DIAMOND = "0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae";
|
|
37
|
+
/**
|
|
38
|
+
* Account implementations an `upgrade_account` may move to: the canonical
|
|
39
|
+
* set's, the same address on every chain. The first is live today; the
|
|
40
|
+
* second is the reviewed build that replaces it (contracts/canonical.env).
|
|
41
|
+
* Drop the first once the chains have moved.
|
|
42
|
+
*/
|
|
43
|
+
export const ACCOUNT_IMPLEMENTATIONS = ["0xf122b33015796a277227983d86412b2434755482", "0x137a9240690af9c37f817e44c49cbfec70ec594a"];
|
|
44
|
+
const NATIVE = ["native", "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "0x0000000000000000000000000000000000000000"];
|
|
45
|
+
const EIP7702_MARKER = "0x7702000000000000000000000000000000000000";
|
|
46
|
+
const lower = (a) => a.toLowerCase();
|
|
47
|
+
const u128 = (v) => {
|
|
48
|
+
if (v < 0n || v >= 1n << 128n)
|
|
49
|
+
refuse("a gas value does not fit 128 bits");
|
|
50
|
+
return word(v).subarray(16);
|
|
51
|
+
};
|
|
52
|
+
const EIP712_DOMAIN = keccak256(utf8("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
|
|
53
|
+
const PACKED_USEROP = keccak256(utf8("PackedUserOperation(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData)"));
|
|
54
|
+
/** The EntryPoint v0.8 hash of an operation: what its passkey signs. */
|
|
55
|
+
export function userOpHash(op, entryPoint, chainId) {
|
|
56
|
+
if (op.factory && lower(op.factory) === EIP7702_MARKER)
|
|
57
|
+
refuse("EIP-7702 operations are not Theoo's");
|
|
58
|
+
const initCode = op.factory ? concatBytes(hexToBytes(op.factory), hexToBytes(op.factoryData ?? "0x")) : new Uint8Array();
|
|
59
|
+
const paymasterAndData = op.paymaster
|
|
60
|
+
? concatBytes(hexToBytes(op.paymaster), u128(toBig(op.paymasterVerificationGasLimit ?? 0)), u128(toBig(op.paymasterPostOpGasLimit ?? 0)), hexToBytes(op.paymasterData ?? "0x"))
|
|
61
|
+
: new Uint8Array();
|
|
62
|
+
const struct = keccak256(concatBytes(PACKED_USEROP, addressWord(op.sender), word(toBig(op.nonce)), keccak256(initCode), keccak256(hexToBytes(op.callData)), concatBytes(u128(toBig(op.verificationGasLimit)), u128(toBig(op.callGasLimit))), word(toBig(op.preVerificationGas)), concatBytes(u128(toBig(op.maxPriorityFeePerGas)), u128(toBig(op.maxFeePerGas))), keccak256(paymasterAndData)));
|
|
63
|
+
const domain = keccak256(concatBytes(EIP712_DOMAIN, keccak256(utf8("ERC4337")), keccak256(utf8("1")), word(BigInt(chainId)), addressWord(entryPoint)));
|
|
64
|
+
return keccak256(concatBytes(new Uint8Array([0x19, 0x01]), domain, struct));
|
|
65
|
+
}
|
|
66
|
+
/** The relayer-model account's own challenge: `keccak256(abi.encode(account, chainid, nonce, to, value, data))`. */
|
|
67
|
+
export function legacyChallenge(account, chainId, nonce, call) {
|
|
68
|
+
const data = hexToBytes(call.data);
|
|
69
|
+
return keccak256(concatBytes(addressWord(account), word(BigInt(chainId)), word(nonce), addressWord(call.to), word(call.value), word(6n * 32n), word(BigInt(data.length)), padded(data)));
|
|
70
|
+
}
|
|
71
|
+
/** What the account's ERC-1271 check expects the passkey to have signed for `hash`. */
|
|
72
|
+
export function replaySafeHash(account, chainId, hash) {
|
|
73
|
+
const domain = keccak256(concatBytes(EIP712_DOMAIN, keccak256(utf8("Theoo Account")), keccak256(utf8("1")), word(BigInt(chainId)), addressWord(account)));
|
|
74
|
+
const message = keccak256(concatBytes(keccak256(utf8("TheooMessage(bytes32 hash)")), hash));
|
|
75
|
+
return keccak256(concatBytes(new Uint8Array([0x19, 0x01]), domain, message));
|
|
76
|
+
}
|
|
77
|
+
/* ── reading the calls ────────────────────────────────────────────────── */
|
|
78
|
+
const SEL = {
|
|
79
|
+
execute: selector("execute(address,uint256,bytes)"),
|
|
80
|
+
executeBatch: selector("executeBatch((address,uint256,bytes)[])"),
|
|
81
|
+
approve: selector("approve(address,uint256)"),
|
|
82
|
+
transfer: selector("transfer(address,uint256)"),
|
|
83
|
+
setGuardian: selector("setGuardian(address,bool,uint256)"),
|
|
84
|
+
cancelRecovery: selector("cancelRecovery()"),
|
|
85
|
+
grantSession: selector("grantSession(address,uint48,uint256,address)"),
|
|
86
|
+
grantSessionWithSelector: selector("grantSessionWithSelector(address,uint48,uint256,address,bytes4)"),
|
|
87
|
+
revokeSession: selector("revokeSession(address)"),
|
|
88
|
+
upgradeToAndCall: selector("upgradeToAndCall(address,bytes)"),
|
|
89
|
+
open: selector("open(bytes32,address,uint256,uint64)"),
|
|
90
|
+
release: selector("release(bytes32)"),
|
|
91
|
+
dispute: selector("dispute(bytes32)"),
|
|
92
|
+
markDelivered: selector("markDelivered(bytes32,bytes32)"),
|
|
93
|
+
fund: selector("fund(bytes32,address,uint256,address,uint64)"),
|
|
94
|
+
markSent: selector("markSent(bytes32,bytes32)"),
|
|
95
|
+
};
|
|
96
|
+
/** The calls an ERC-4337 operation's callData makes, as the account will run them. */
|
|
97
|
+
export function decodeAccountCalls(callData) {
|
|
98
|
+
const { selector: sel, args } = splitCall(callData);
|
|
99
|
+
if (sel === SEL.execute)
|
|
100
|
+
return [{ to: args.address(0), value: args.uint(32), data: bytesToHex(args.bytes(64)) }];
|
|
101
|
+
if (sel === SEL.executeBatch) {
|
|
102
|
+
const arr = args.sub(0);
|
|
103
|
+
const n = Number(arr.uint(0));
|
|
104
|
+
if (n < 1 || n > 32)
|
|
105
|
+
refuse("the batch is empty or too long");
|
|
106
|
+
const items = new Reader(arr.data, arr.base + 32);
|
|
107
|
+
return Array.from({ length: n }, (_, i) => {
|
|
108
|
+
const t = items.sub(i * 32);
|
|
109
|
+
return { to: t.address(0), value: t.uint(32), data: bytesToHex(t.bytes(64)) };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return refuse("the operation does not call the account's execute or executeBatch");
|
|
113
|
+
}
|
|
114
|
+
const DEFAULT_MAX_FEE_WEI = 2n * 10n ** 16n;
|
|
115
|
+
const same = (a, b) => lower(a) === lower(b);
|
|
116
|
+
const isNative = (t) => NATIVE.includes(lower(t));
|
|
117
|
+
const normData = (d) => lower(d && d !== "" ? d : "0x");
|
|
118
|
+
function sameCalls(got, want) {
|
|
119
|
+
return (got.length === want.length &&
|
|
120
|
+
got.every((c, i) => same(c.to, want[i].to) && c.value === toBig(want[i].value ?? 0) && normData(c.data) === normData(want[i].data)));
|
|
121
|
+
}
|
|
122
|
+
function only(calls, n, what) {
|
|
123
|
+
if (calls.length !== n)
|
|
124
|
+
refuse(`${what} should be ${n === 1 ? "one call" : `${n} calls`}, not ${calls.length}`);
|
|
125
|
+
}
|
|
126
|
+
function selfCall(c, account, sel, what) {
|
|
127
|
+
if (!same(c.to, account))
|
|
128
|
+
refuse(`${what} must call the account itself`);
|
|
129
|
+
if (c.value !== 0n)
|
|
130
|
+
refuse(`${what} sends value`);
|
|
131
|
+
const s = splitCall(c.data);
|
|
132
|
+
if (s.selector !== sel)
|
|
133
|
+
refuse(`${what} calls something else`);
|
|
134
|
+
return s.args;
|
|
135
|
+
}
|
|
136
|
+
function call(c, sel, what) {
|
|
137
|
+
if (c.value !== 0n)
|
|
138
|
+
refuse(`${what} sends value`);
|
|
139
|
+
const s = splitCall(c.data);
|
|
140
|
+
if (s.selector !== sel)
|
|
141
|
+
refuse(`${what} calls something else`);
|
|
142
|
+
return s.args;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A quote's calls: approve exactly one spender for at most what the quote
|
|
146
|
+
* sells, use it, and leave nothing approved; or, selling the chain's own
|
|
147
|
+
* coin, one call carrying at most that much.
|
|
148
|
+
*/
|
|
149
|
+
function quoteSpend(calls, account, budget, spender, what) {
|
|
150
|
+
for (const c of calls)
|
|
151
|
+
if (same(c.to, account))
|
|
152
|
+
refuse(`${what} calls the account itself`);
|
|
153
|
+
if (calls.length === 1) {
|
|
154
|
+
const [c] = calls;
|
|
155
|
+
if (budget && !isNative(budget.token))
|
|
156
|
+
refuse(`${what} sells a token but approves nothing`);
|
|
157
|
+
if (budget && c.value > budget.amount + (budget.extraNative ?? 0n))
|
|
158
|
+
refuse(`${what} sends more than the quote`);
|
|
159
|
+
if (spender && !same(c.to, spender) && !budget)
|
|
160
|
+
refuse(`${what} goes somewhere unexpected`);
|
|
161
|
+
const s = splitCall(c.data).selector;
|
|
162
|
+
if (s === SEL.approve || s === SEL.transfer)
|
|
163
|
+
refuse(`${what} moves a token it shouldn't`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
only(calls, 3, what);
|
|
167
|
+
const [first, use, last] = calls;
|
|
168
|
+
const a = call(first, SEL.approve, `${what}'s approval`);
|
|
169
|
+
const r = call(last, SEL.approve, `${what}'s reset`);
|
|
170
|
+
if (!same(first.to, last.to) || !same(a.address(0), r.address(0)))
|
|
171
|
+
refuse(`${what} resets a different approval`);
|
|
172
|
+
if (r.uint(32) !== 0n)
|
|
173
|
+
refuse(`${what} leaves an approval behind`);
|
|
174
|
+
if (spender && !same(a.address(0), spender))
|
|
175
|
+
refuse(`${what} approves an unexpected spender`);
|
|
176
|
+
if (!same(use.to, a.address(0)))
|
|
177
|
+
refuse(`${what} uses a different contract than it approved`);
|
|
178
|
+
if (budget) {
|
|
179
|
+
if (isNative(budget.token) || !same(first.to, budget.token))
|
|
180
|
+
refuse(`${what} approves another token`);
|
|
181
|
+
if (a.uint(32) > budget.amount)
|
|
182
|
+
refuse(`${what} approves more than the quote`);
|
|
183
|
+
}
|
|
184
|
+
if (use.value > (budget?.extraNative ?? 0n))
|
|
185
|
+
refuse(`${what} sends more of the chain's coin than the quote`);
|
|
186
|
+
const s = splitCall(use.data).selector;
|
|
187
|
+
if (s === SEL.approve || s === SEL.transfer)
|
|
188
|
+
refuse(`${what} moves a token it shouldn't`);
|
|
189
|
+
}
|
|
190
|
+
const keccakRef = (reference) => bytesToHex(keccak256(utf8(reference.trim())));
|
|
191
|
+
// Operations the server builds from a quote or an order: what leaves the
|
|
192
|
+
// account is bounded by its amount, so it has to be known.
|
|
193
|
+
const NEEDS_BUDGET = new Set(["swap", "bridge", "espees_buy", "cash_out", "espees_sell_fund"]);
|
|
194
|
+
/** The calls, against what was asked for. */
|
|
195
|
+
export function checkCalls(kind, calls, account, o) {
|
|
196
|
+
if (NEEDS_BUDGET.has(kind.kind) && !o.budget)
|
|
197
|
+
refuse("pass the quote or order itself, not its id, so its amount can be checked");
|
|
198
|
+
switch (kind.kind) {
|
|
199
|
+
case "call":
|
|
200
|
+
if (!sameCalls(calls, [kind.call]))
|
|
201
|
+
refuse("the call isn't the one asked for");
|
|
202
|
+
return;
|
|
203
|
+
case "batch":
|
|
204
|
+
if (!sameCalls(calls, kind.calls))
|
|
205
|
+
refuse("the calls aren't the ones asked for");
|
|
206
|
+
return;
|
|
207
|
+
case "enable_recovery": {
|
|
208
|
+
only(calls, 1, "turning on recovery");
|
|
209
|
+
const a = selfCall(calls[0], account, SEL.setGuardian, "turning on recovery");
|
|
210
|
+
if (!a.bool(32))
|
|
211
|
+
refuse("turning on recovery removes a guardian");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
case "cancel_recovery":
|
|
215
|
+
only(calls, 1, "cancelling recovery");
|
|
216
|
+
selfCall(calls[0], account, SEL.cancelRecovery, "cancelling recovery");
|
|
217
|
+
return;
|
|
218
|
+
case "grant_session":
|
|
219
|
+
case "renew_session": {
|
|
220
|
+
only(calls, 1, "a session grant");
|
|
221
|
+
const c = calls[0];
|
|
222
|
+
const sel = splitCall(c.data).selector;
|
|
223
|
+
const narrowed = sel === SEL.grantSessionWithSelector;
|
|
224
|
+
const a = selfCall(c, account, narrowed ? SEL.grantSessionWithSelector : SEL.grantSession, "a session grant");
|
|
225
|
+
const s = kind.session;
|
|
226
|
+
if (a.uint(32) !== BigInt(s.expiresAt))
|
|
227
|
+
refuse("the session's expiry isn't the one asked for");
|
|
228
|
+
// A grant with no cap is a cap of zero. A renewal with none keeps the
|
|
229
|
+
// cap it had, which only the server knows.
|
|
230
|
+
const cap = kind.kind === "grant_session" ? toBig(s.spendCap ?? 0) : s.spendCap != null ? toBig(s.spendCap) : null;
|
|
231
|
+
if (cap !== null && a.uint(64) !== cap)
|
|
232
|
+
refuse("the session's spend cap isn't the one asked for");
|
|
233
|
+
if (kind.kind === "grant_session") {
|
|
234
|
+
if (!same(a.address(96), kind.session.target))
|
|
235
|
+
refuse("the session's target isn't the one asked for");
|
|
236
|
+
const want = kind.session.selector ?? null;
|
|
237
|
+
if (want ? !narrowed || lower(a.bytes32(128).slice(0, 10)) !== lower(want) : narrowed)
|
|
238
|
+
refuse("the session's function isn't the one asked for");
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
case "revoke_session":
|
|
243
|
+
only(calls, 1, "revoking a session");
|
|
244
|
+
selfCall(calls[0], account, SEL.revokeSession, "revoking a session");
|
|
245
|
+
return;
|
|
246
|
+
case "upgrade_account": {
|
|
247
|
+
only(calls, 1, "an upgrade");
|
|
248
|
+
const a = selfCall(calls[0], account, SEL.upgradeToAndCall, "an upgrade");
|
|
249
|
+
if (!ACCOUNT_IMPLEMENTATIONS.includes(lower(a.address(0))))
|
|
250
|
+
refuse("the upgrade's target isn't a known Theoo account");
|
|
251
|
+
if (a.bytes(32).length !== 0)
|
|
252
|
+
refuse("the upgrade runs code as it lands");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
case "swap":
|
|
256
|
+
return quoteSpend(calls, account, o.budget, calls.length === 1 ? null : ALLOWANCE_HOLDER, "the swap");
|
|
257
|
+
case "bridge":
|
|
258
|
+
return quoteSpend(calls, account, o.budget, calls.length === 1 ? null : LIFI_DIAMOND, "the cross-chain swap");
|
|
259
|
+
case "espees_buy": {
|
|
260
|
+
only(calls, 2, "paying the desk");
|
|
261
|
+
const [approve, open] = calls;
|
|
262
|
+
const a = call(approve, SEL.approve, "paying the desk");
|
|
263
|
+
const p = call(open, SEL.open, "paying the desk");
|
|
264
|
+
if (!same(a.address(0), open.to))
|
|
265
|
+
refuse("paying the desk approves a different contract");
|
|
266
|
+
if (!same(p.address(32), approve.to) || p.uint(64) !== a.uint(32))
|
|
267
|
+
refuse("paying the desk pays a different amount or token");
|
|
268
|
+
if (o.budget && (!same(approve.to, o.budget.token) || a.uint(32) > o.budget.amount))
|
|
269
|
+
refuse("paying the desk pays more than the quote");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
case "espees_release":
|
|
273
|
+
only(calls, 1, "releasing the payment");
|
|
274
|
+
call(calls[0], SEL.release, "releasing the payment");
|
|
275
|
+
return;
|
|
276
|
+
case "espees_dispute":
|
|
277
|
+
case "espees_sell_dispute":
|
|
278
|
+
only(calls, 1, "a dispute");
|
|
279
|
+
call(calls[0], SEL.dispute, "a dispute");
|
|
280
|
+
return;
|
|
281
|
+
case "espees_sell_release":
|
|
282
|
+
only(calls, 1, "paying the seller");
|
|
283
|
+
call(calls[0], SEL.release, "paying the seller");
|
|
284
|
+
return;
|
|
285
|
+
case "desk_deliver": {
|
|
286
|
+
only(calls, 1, "marking it delivered");
|
|
287
|
+
const a = call(calls[0], SEL.markDelivered, "marking it delivered");
|
|
288
|
+
if (lower(a.bytes32(32)) !== keccakRef(kind.reference))
|
|
289
|
+
refuse("the delivery reference isn't the one given");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
case "espees_sell_sent": {
|
|
293
|
+
only(calls, 1, "saying it was sent");
|
|
294
|
+
const a = call(calls[0], SEL.markSent, "saying it was sent");
|
|
295
|
+
if (lower(a.bytes32(32)) !== keccakRef(kind.reference))
|
|
296
|
+
refuse("the reference isn't the one given");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
case "espees_sell_fund": {
|
|
300
|
+
only(calls, 2, "locking the payout");
|
|
301
|
+
const [approve, fund] = calls;
|
|
302
|
+
const a = call(approve, SEL.approve, "locking the payout");
|
|
303
|
+
const f = call(fund, SEL.fund, "locking the payout");
|
|
304
|
+
if (!same(a.address(0), fund.to))
|
|
305
|
+
refuse("locking the payout approves a different contract");
|
|
306
|
+
if (!same(f.address(32), approve.to) || f.uint(64) !== a.uint(32))
|
|
307
|
+
refuse("locking the payout locks a different amount or token");
|
|
308
|
+
if (o.budget && (!same(approve.to, o.budget.token) || a.uint(32) > o.budget.amount))
|
|
309
|
+
refuse("locking the payout locks more than the order");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
case "cash_out": {
|
|
313
|
+
only(calls, 1, "cashing out");
|
|
314
|
+
const t = call(calls[0], SEL.transfer, "cashing out");
|
|
315
|
+
if (o.budget && (!same(calls[0].to, o.budget.token) || t.uint(32) > o.budget.amount))
|
|
316
|
+
refuse("cashing out sends more than the quote");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
case "vault_create": {
|
|
320
|
+
only(calls, 1, "making a multisig");
|
|
321
|
+
const c = calls[0];
|
|
322
|
+
if (!same(c.to, VAULT_FACTORY))
|
|
323
|
+
refuse("making a multisig calls something other than the vault factory");
|
|
324
|
+
const a = call(c, VAULT_SEL.create, "making a multisig");
|
|
325
|
+
const owners = a.sub(0);
|
|
326
|
+
const n = Number(owners.uint(0));
|
|
327
|
+
const want = kind.vault.owners;
|
|
328
|
+
if (n !== want.length)
|
|
329
|
+
refuse("the multisig's owners aren't the ones asked for");
|
|
330
|
+
for (let i = 0; i < n; i++)
|
|
331
|
+
if (!same(owners.address(32 + 32 * i), want[i]))
|
|
332
|
+
refuse("the multisig's owners aren't the ones asked for");
|
|
333
|
+
if (Number(a.uint(32)) !== kind.vault.threshold)
|
|
334
|
+
refuse("the multisig's threshold isn't the one asked for");
|
|
335
|
+
if (Number(a.uint(64)) !== (kind.vault.delay ?? 0))
|
|
336
|
+
refuse("the multisig's wait isn't the one asked for");
|
|
337
|
+
if (kind.vault.salt && lower(a.bytes32(96)) !== lower(kind.vault.salt))
|
|
338
|
+
refuse("the multisig's salt isn't the one asked for");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
case "vault_propose": {
|
|
342
|
+
const [first, ...rest] = calls;
|
|
343
|
+
if (!first)
|
|
344
|
+
refuse("a proposal makes no call");
|
|
345
|
+
if (!same(first.to, kind.vault) || first.value !== 0n)
|
|
346
|
+
refuse("the proposal goes to another vault");
|
|
347
|
+
let proposed = [];
|
|
348
|
+
try {
|
|
349
|
+
proposed = decodePropose(first.data);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
refuse("the proposal isn't a proposal");
|
|
353
|
+
}
|
|
354
|
+
if (callsHash(proposed) !== callsHash(kind.calls))
|
|
355
|
+
refuse("the proposal's calls aren't the ones asked for");
|
|
356
|
+
vaultRun(rest, kind.vault, kind.run, null);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
case "vault_approve": {
|
|
360
|
+
const [first, ...rest] = calls;
|
|
361
|
+
if (!first || !same(first.to, kind.vault))
|
|
362
|
+
refuse("the approval goes to another vault");
|
|
363
|
+
const a = call(first, VAULT_SEL.approve, "an approval");
|
|
364
|
+
if (a.uint(0) !== BigInt(kind.proposal))
|
|
365
|
+
refuse("the approval is for another proposal");
|
|
366
|
+
if (lower(a.bytes32(32)) !== lower(kind.callsHash))
|
|
367
|
+
refuse("the approval names other calls than the ones shown");
|
|
368
|
+
vaultRun(rest, kind.vault, kind.run, kind.proposal);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
case "vault_revoke":
|
|
372
|
+
case "vault_reject":
|
|
373
|
+
case "vault_cancel":
|
|
374
|
+
case "vault_execute": {
|
|
375
|
+
const what = { vault_revoke: "revoke", vault_reject: "reject", vault_cancel: "cancel", vault_execute: "execute" };
|
|
376
|
+
only(calls, 1, `a vault ${what[kind.kind]}`);
|
|
377
|
+
const c = calls[0];
|
|
378
|
+
if (!same(c.to, kind.vault))
|
|
379
|
+
refuse("it goes to another vault");
|
|
380
|
+
const a = call(c, VAULT_SEL[what[kind.kind]], `a vault ${what[kind.kind]}`);
|
|
381
|
+
if (a.uint(0) !== BigInt(kind.proposal))
|
|
382
|
+
refuse("it's for another proposal");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
default:
|
|
386
|
+
return refuse(`this client doesn't know how to check a ${kind.kind}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The run a vault operation may carry after proposing or approving: nothing,
|
|
391
|
+
* or, when `run` isn't off, one `execute` on the same vault — of the very
|
|
392
|
+
* proposal approved, when that is known. A run can only ever run what its
|
|
393
|
+
* owners approved, but it should still be the one the user meant.
|
|
394
|
+
*/
|
|
395
|
+
function vaultRun(rest, vault, run, proposal) {
|
|
396
|
+
if (rest.length === 0)
|
|
397
|
+
return;
|
|
398
|
+
if (run === false || rest.length !== 1)
|
|
399
|
+
refuse("it does more than was asked");
|
|
400
|
+
const c = rest[0];
|
|
401
|
+
if (!same(c.to, vault))
|
|
402
|
+
refuse("it runs something in another vault");
|
|
403
|
+
const a = call(c, VAULT_SEL.execute, "running it");
|
|
404
|
+
if (proposal !== null && a.uint(0) !== BigInt(proposal))
|
|
405
|
+
refuse("it runs another proposal");
|
|
406
|
+
}
|
|
407
|
+
function challengeOf(prepared) {
|
|
408
|
+
const c = hexToBytes(prepared.challenge);
|
|
409
|
+
const fromOptions = prepared.webauthnOptions?.publicKey?.challenge;
|
|
410
|
+
if (typeof fromOptions !== "string" || !equalBytes(base64urlDecode(fromOptions), c)) {
|
|
411
|
+
refuse("the passkey would be asked to sign something other than the stated challenge");
|
|
412
|
+
}
|
|
413
|
+
return c;
|
|
414
|
+
}
|
|
415
|
+
/** Check a prepared EVM operation against what was asked for. Throws `SigningRefused`. */
|
|
416
|
+
export function checkPrepared(kind, prepared, o = {}) {
|
|
417
|
+
if (o.chain !== undefined && prepared.chain !== o.chain)
|
|
418
|
+
refuse("it is for another chain");
|
|
419
|
+
const challenge = challengeOf(prepared);
|
|
420
|
+
const payload = prepared.payload;
|
|
421
|
+
let calls;
|
|
422
|
+
if (payload?.type === "userOp") {
|
|
423
|
+
const op = payload.userOp;
|
|
424
|
+
if (!same(op.sender, prepared.account))
|
|
425
|
+
refuse("it is for another account");
|
|
426
|
+
if (!equalBytes(userOpHash(op, payload.entryPoint, prepared.chain), challenge))
|
|
427
|
+
refuse("the challenge isn't the hash of the operation shown");
|
|
428
|
+
if (prepared.sponsored && !op.paymaster)
|
|
429
|
+
refuse("it says gas is paid for but nothing pays it");
|
|
430
|
+
if (!op.paymaster) {
|
|
431
|
+
const gas = toBig(op.callGasLimit) + toBig(op.verificationGasLimit) + toBig(op.preVerificationGas);
|
|
432
|
+
if (gas * toBig(op.maxFeePerGas) > (o.maxFeeWei ?? DEFAULT_MAX_FEE_WEI))
|
|
433
|
+
refuse("its network fee could be more than allowed");
|
|
434
|
+
}
|
|
435
|
+
calls = decodeAccountCalls(op.callData);
|
|
436
|
+
// The EntryPoint holds the account's gas deposit: a session that may call it may withdraw it.
|
|
437
|
+
if (kind.kind === "grant_session" && same(kind.session.target, payload.entryPoint))
|
|
438
|
+
refuse("a session may not target the EntryPoint");
|
|
439
|
+
}
|
|
440
|
+
else if (payload?.type === "legacy") {
|
|
441
|
+
const c = { to: payload.call.to, value: toBig(payload.call.value), data: payload.call.data };
|
|
442
|
+
if (!equalBytes(legacyChallenge(prepared.account, prepared.chain, toBig(payload.nonce), c), challenge)) {
|
|
443
|
+
refuse("the challenge isn't the hash of the call shown");
|
|
444
|
+
}
|
|
445
|
+
calls = [c];
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
return refuse("the server didn't say what the passkey would sign");
|
|
449
|
+
}
|
|
450
|
+
checkCalls(kind, calls, prepared.account, o);
|
|
451
|
+
}
|
|
452
|
+
/** Check a message-signing request: the hash is the message's, and the challenge is what the account expects for it. */
|
|
453
|
+
export function checkSignBegin(req, begun) {
|
|
454
|
+
let want;
|
|
455
|
+
if (req.message !== undefined)
|
|
456
|
+
want = hashMessage(req.message);
|
|
457
|
+
else if (req.typedData !== undefined) {
|
|
458
|
+
try {
|
|
459
|
+
want = hashTypedData(req.typedData);
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
return refuse(`the typed data can't be checked here (${e instanceof Error ? e.message : e})`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
else if (req.hash !== undefined)
|
|
466
|
+
want = hexToBytes(req.hash);
|
|
467
|
+
else
|
|
468
|
+
return refuse("nothing to sign");
|
|
469
|
+
if (!equalBytes(hexToBytes(begun.hash), want))
|
|
470
|
+
refuse("the hash isn't the message's");
|
|
471
|
+
const challenge = challengeOf(begun);
|
|
472
|
+
if (!equalBytes(replaySafeHash(begun.account, begun.chain, want), challenge))
|
|
473
|
+
refuse("the challenge isn't for this account and message");
|
|
474
|
+
}
|
|
475
|
+
const le = (b, off, n) => {
|
|
476
|
+
let v = 0n;
|
|
477
|
+
for (let i = n - 1; i >= 0; i--)
|
|
478
|
+
v = (v << 8n) | BigInt(b[off + i]);
|
|
479
|
+
return v;
|
|
480
|
+
};
|
|
481
|
+
function decodeAction(b, at = 0) {
|
|
482
|
+
const need = (n) => {
|
|
483
|
+
if (at + n > b.length)
|
|
484
|
+
refuse("the Solana action ends early");
|
|
485
|
+
};
|
|
486
|
+
need(1);
|
|
487
|
+
const tag = b[at];
|
|
488
|
+
switch (tag) {
|
|
489
|
+
case 1:
|
|
490
|
+
need(41);
|
|
491
|
+
return [{ tag, to: b.slice(at + 1, at + 33), lamports: le(b, at + 33, 8) }, 41];
|
|
492
|
+
case 2: {
|
|
493
|
+
need(34);
|
|
494
|
+
const program = b.slice(at + 1, at + 33);
|
|
495
|
+
const n = b[at + 33];
|
|
496
|
+
let p = at + 34;
|
|
497
|
+
const accounts = [];
|
|
498
|
+
for (let i = 0; i < n; i++) {
|
|
499
|
+
need(p - at + 34);
|
|
500
|
+
accounts.push({ key: b.slice(p, p + 32), signer: b[p + 32] === 1, writable: b[p + 33] === 1 });
|
|
501
|
+
p += 34;
|
|
502
|
+
}
|
|
503
|
+
need(p - at + 2);
|
|
504
|
+
const len = Number(le(b, p, 2));
|
|
505
|
+
need(p - at + 2 + len);
|
|
506
|
+
return [{ tag, program, accounts, data: b.slice(p + 2, p + 2 + len) }, p + 2 + len - at];
|
|
507
|
+
}
|
|
508
|
+
case 3:
|
|
509
|
+
need(35);
|
|
510
|
+
return [{ tag, guardian: b.slice(at + 1, at + 33), on: b[at + 33] === 1, threshold: b[at + 34] }, 35];
|
|
511
|
+
case 4:
|
|
512
|
+
return [{ tag }, 1];
|
|
513
|
+
case 5:
|
|
514
|
+
need(81);
|
|
515
|
+
return [{ tag, key: b.slice(at + 1, at + 33), target: b.slice(at + 33, at + 65), expiresAt: le(b, at + 65, 8), spendCap: le(b, at + 73, 8) }, 81];
|
|
516
|
+
case 6:
|
|
517
|
+
need(33);
|
|
518
|
+
return [{ tag, key: b.slice(at + 1, at + 33) }, 33];
|
|
519
|
+
case 7: {
|
|
520
|
+
need(2);
|
|
521
|
+
const n = b[at + 1];
|
|
522
|
+
let p = at + 2;
|
|
523
|
+
const actions = [];
|
|
524
|
+
for (let i = 0; i < n; i++) {
|
|
525
|
+
const [a, used] = decodeAction(b, p);
|
|
526
|
+
actions.push(a);
|
|
527
|
+
p += used;
|
|
528
|
+
}
|
|
529
|
+
return [{ tag, actions }, p - at];
|
|
530
|
+
}
|
|
531
|
+
default:
|
|
532
|
+
return refuse("the Solana action is of an unknown kind");
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const is = (m, key, signer, writable) => !!m && equalBytes(m.key, typeof key === "string" ? pubkeyBytes(key) : key) && m.signer === signer && m.writable === writable;
|
|
536
|
+
/**
|
|
537
|
+
* A vault action the server wrote, checked as exactly what was asked: the
|
|
538
|
+
* vault program, the vault, the proposal, the hash, and the user's own
|
|
539
|
+
* account as the owner. The only other signer allowed is a payer for the
|
|
540
|
+
* rent of what it creates — never the user's account, whose funds the
|
|
541
|
+
* program cannot take for rent anyway.
|
|
542
|
+
*/
|
|
543
|
+
function sameVaultAction(got, want, account) {
|
|
544
|
+
if (got.tag !== 2 || !equalBytes(got.program, pubkeyBytes(SOLANA_VAULT_PROGRAM)))
|
|
545
|
+
return false;
|
|
546
|
+
const [a0, a1, a2, a3, a4, a5] = got.accounts;
|
|
547
|
+
const d = got.data;
|
|
548
|
+
const me = pubkeyBytes(account);
|
|
549
|
+
const payer = (m) => !!m && m.signer && m.writable && !equalBytes(m.key, me);
|
|
550
|
+
switch (want.kind) {
|
|
551
|
+
case "vault_create": {
|
|
552
|
+
const v = want.vault;
|
|
553
|
+
const n = v.owners.length;
|
|
554
|
+
if (got.accounts.length !== 3 || !payer(a0) || !is(a2, SYSTEM_PROGRAM, false, false))
|
|
555
|
+
return false;
|
|
556
|
+
if (d.length !== 1 + 1 + 32 * n + 1 + 8 + 32 || d[0] !== SOLANA_VAULT_TAG.create || d[1] !== n)
|
|
557
|
+
return false;
|
|
558
|
+
const owners = concatBytes(...v.owners.map(pubkeyBytes));
|
|
559
|
+
const salt = d.slice(2 + 32 * n + 9);
|
|
560
|
+
if (!equalBytes(d.slice(2, 2 + 32 * n), owners) || d[2 + 32 * n] !== v.threshold)
|
|
561
|
+
return false;
|
|
562
|
+
if (!equalBytes(d.slice(3 + 32 * n, 11 + 32 * n), u64le(v.delay ?? 0)))
|
|
563
|
+
return false;
|
|
564
|
+
if (v.salt && !equalBytes(salt, hexToBytes(v.salt)))
|
|
565
|
+
return false;
|
|
566
|
+
// The vault it makes is where these settings put it.
|
|
567
|
+
const at = predictSolanaVault(v.owners, v.threshold, v.delay ?? 0, bytesToHex(salt));
|
|
568
|
+
return is(a1, at.config, false, true);
|
|
569
|
+
}
|
|
570
|
+
case "vault_propose":
|
|
571
|
+
return (got.accounts.length === 6 &&
|
|
572
|
+
!!a0 && isSolanaVault(a0.key, want.vault) && !a0.signer && a0.writable &&
|
|
573
|
+
!!a1 && !a1.signer && a1.writable &&
|
|
574
|
+
is(a2, me, true, false) &&
|
|
575
|
+
payer(a3) &&
|
|
576
|
+
is(a4, SYSTEM_PROGRAM, false, false) &&
|
|
577
|
+
is(a5, INSTRUCTIONS_SYSVAR, false, false) &&
|
|
578
|
+
equalBytes(d, concatBytes(new Uint8Array([SOLANA_VAULT_TAG.propose]), encodeSolanaCalls(want.calls))));
|
|
579
|
+
case "vault_approve":
|
|
580
|
+
case "vault_revoke":
|
|
581
|
+
case "vault_reject":
|
|
582
|
+
case "vault_cancel": {
|
|
583
|
+
if (got.accounts.length !== 4 || !a0 || !isSolanaVault(a0.key, want.vault) || a0.signer || a0.writable)
|
|
584
|
+
return false;
|
|
585
|
+
if (!is(a1, solanaProposalAddress(base58Encode(a0.key), want.proposal), false, true) || !is(a2, me, true, false))
|
|
586
|
+
return false;
|
|
587
|
+
if (!is(a3, INSTRUCTIONS_SYSVAR, false, false))
|
|
588
|
+
return false;
|
|
589
|
+
const head = concatBytes(new Uint8Array([SOLANA_VAULT_TAG[want.kind.slice(6)]]), u64le(want.proposal));
|
|
590
|
+
const tail = want.kind === "vault_approve" ? hexToBytes(want.callsHash) : new Uint8Array();
|
|
591
|
+
return tail.length === (want.kind === "vault_approve" ? 32 : 0) && equalBytes(d, concatBytes(head, tail));
|
|
592
|
+
}
|
|
593
|
+
default:
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function sameAction(got, want, account) {
|
|
598
|
+
switch (want.kind) {
|
|
599
|
+
case "transfer":
|
|
600
|
+
return got.tag === 1 && equalBytes(got.to, pubkeyBytes(want.to)) && got.lamports === BigInt(want.lamports);
|
|
601
|
+
case "invoke":
|
|
602
|
+
return (got.tag === 2 &&
|
|
603
|
+
equalBytes(got.program, pubkeyBytes(want.programId)) &&
|
|
604
|
+
got.accounts.length === want.accounts.length &&
|
|
605
|
+
got.accounts.every((a, i) => equalBytes(a.key, pubkeyBytes(want.accounts[i].pubkey)) &&
|
|
606
|
+
a.signer === !!want.accounts[i].isSigner &&
|
|
607
|
+
a.writable === !!want.accounts[i].isWritable) &&
|
|
608
|
+
equalBytes(got.data, base64Decode(want.data)));
|
|
609
|
+
case "batch":
|
|
610
|
+
return got.tag === 7 && got.actions.length === want.actions.length && got.actions.every((a, i) => sameAction(a, want.actions[i], account));
|
|
611
|
+
case "enable_recovery":
|
|
612
|
+
return got.tag === 3 && got.on;
|
|
613
|
+
case "cancel_recovery":
|
|
614
|
+
return got.tag === 4;
|
|
615
|
+
case "grant_session":
|
|
616
|
+
return (got.tag === 5 &&
|
|
617
|
+
equalBytes(got.target, pubkeyBytes(want.session.target)) &&
|
|
618
|
+
got.expiresAt === BigInt(want.session.expiresAt) &&
|
|
619
|
+
got.spendCap === BigInt(want.session.spendCap ?? 0));
|
|
620
|
+
case "renew_session":
|
|
621
|
+
return got.tag === 5 && got.expiresAt === BigInt(want.session.expiresAt) && (want.session.spendCap == null || got.spendCap === BigInt(want.session.spendCap));
|
|
622
|
+
case "revoke_session":
|
|
623
|
+
return got.tag === 6;
|
|
624
|
+
case "vault_create":
|
|
625
|
+
case "vault_propose":
|
|
626
|
+
case "vault_approve":
|
|
627
|
+
case "vault_revoke":
|
|
628
|
+
case "vault_reject":
|
|
629
|
+
case "vault_cancel":
|
|
630
|
+
return sameVaultAction(got, want, account);
|
|
631
|
+
default:
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/** The Solana program's challenge: `sha256("theoo/solana/v1" ‖ program ‖ account ‖ nonce ‖ sha256(action))`. */
|
|
636
|
+
export function solanaChallenge(programId, account, nonce, action) {
|
|
637
|
+
return sha256(concatBytes(utf8("theoo/solana/v1"), pubkeyBytes(programId), pubkeyBytes(account), (() => {
|
|
638
|
+
const b = new Uint8Array(8);
|
|
639
|
+
let n = nonce;
|
|
640
|
+
for (let i = 0; i < 8; i++) {
|
|
641
|
+
b[i] = Number(n & 0xffn);
|
|
642
|
+
n >>= 8n;
|
|
643
|
+
}
|
|
644
|
+
return b;
|
|
645
|
+
})(), sha256(action)));
|
|
646
|
+
}
|
|
647
|
+
/** Check a prepared Solana operation against the action asked for. Throws `SigningRefused`. */
|
|
648
|
+
export function checkPreparedSolana(action, prepared) {
|
|
649
|
+
if (!prepared.programId || !prepared.action)
|
|
650
|
+
refuse("the server didn't say what the passkey would sign");
|
|
651
|
+
const challenge = challengeOf(prepared);
|
|
652
|
+
const bytes = hexToBytes(prepared.action);
|
|
653
|
+
const [decoded, used] = decodeAction(bytes);
|
|
654
|
+
if (used !== bytes.length)
|
|
655
|
+
refuse("the Solana action has trailing bytes");
|
|
656
|
+
let same = false;
|
|
657
|
+
try {
|
|
658
|
+
same = sameAction(decoded, action, prepared.account);
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
// An address or hex string that doesn't parse is not what was asked for.
|
|
662
|
+
}
|
|
663
|
+
if (!same)
|
|
664
|
+
refuse("the Solana action isn't the one asked for");
|
|
665
|
+
if (!equalBytes(solanaChallenge(prepared.programId, prepared.account, BigInt(prepared.nonce), bytes), challenge)) {
|
|
666
|
+
refuse("the challenge isn't the hash of the action shown");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=verify.js.map
|