@usearete/automations 0.0.0-stage → 0.44.1

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/index.cjs ADDED
@@ -0,0 +1,3735 @@
1
+ 'use strict';
2
+
3
+ var base = require('@scure/base');
4
+ var ed25519 = require('@noble/curves/ed25519');
5
+ var utils = require('@noble/hashes/utils');
6
+ var sha2 = require('@noble/hashes/sha2');
7
+ var sdk = require('@usearete/sdk');
8
+
9
+ const LAMPORTS_PER_SOL = 1000000000n;
10
+ /** Lamports as a decimal SOL string. */
11
+ function lamportsToSol(lamports) {
12
+ const sign = lamports < 0n ? '-' : '';
13
+ const abs = lamports < 0n ? -lamports : lamports;
14
+ const whole = abs / LAMPORTS_PER_SOL;
15
+ const fraction = abs % LAMPORTS_PER_SOL;
16
+ if (fraction === 0n)
17
+ return `${sign}${whole}`;
18
+ return `${sign}${whole}.${fraction.toString().padStart(9, '0').replace(/0+$/, '')}`;
19
+ }
20
+ /**
21
+ * A JavaScript number in plain decimal notation, with the digits of its
22
+ * shortest round-trip form (what `String(value)` uses, without an
23
+ * exponent).
24
+ */
25
+ function plainDecimal(value) {
26
+ const text = String(value);
27
+ const match = /^(-?)(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(text);
28
+ if (!match)
29
+ return text;
30
+ const [, sign, whole, fraction = '', exponentText] = match;
31
+ const digits = `${whole}${fraction}`;
32
+ const point = whole.length + Number(exponentText);
33
+ if (point <= 0)
34
+ return `${sign}0.${'0'.repeat(-point)}${digits}`;
35
+ if (point >= digits.length)
36
+ return `${sign}${digits}${'0'.repeat(point - digits.length)}`;
37
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
38
+ }
39
+
40
+ function encodeBase58(bytes) {
41
+ return base.base58.encode(bytes);
42
+ }
43
+ /** The bytes of a base58 string, or `null` when it is not base58. */
44
+ function decodeBase58(text) {
45
+ try {
46
+ return base.base58.decode(text);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function encodeBase64(bytes) {
53
+ return base.base64.encode(bytes);
54
+ }
55
+ /** The bytes of a padded base64 string, or `null` when it is not base64. */
56
+ function decodeBase64(text) {
57
+ try {
58
+ return base.base64.decode(text);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /** Whether `value` is a base58 public key (32 bytes). */
65
+ function isPubkey(value) {
66
+ return decodeBase58(value)?.length === 32;
67
+ }
68
+ /** A JSON object's field; `undefined` when `value` is not an object. */
69
+ function field(value, key) {
70
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
71
+ return undefined;
72
+ }
73
+ return value[key];
74
+ }
75
+ /** A JSON value as a non-negative integer, as serde's `as_u64` reads it. */
76
+ function asU64(value) {
77
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
78
+ }
79
+ function asString(value) {
80
+ return typeof value === 'string' ? value : undefined;
81
+ }
82
+ function isObject(value) {
83
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
84
+ }
85
+
86
+ /**
87
+ * Instructions that hand control of the signer's funds to someone else
88
+ * without moving a balance.
89
+ *
90
+ * The policy's caps measure balance changes, and some instructions change no
91
+ * balance yet give the funds away: a token delegation (`approve`), a new
92
+ * owner or authority (`setAuthority`, System `assign`), or lamports leaving
93
+ * an account the signer controls that is not the signer's own (a nonce or
94
+ * stake withdrawal). This module names those instructions in the System, SPL
95
+ * Token, Token-2022, upgradeable loader and stake programs when the signer
96
+ * takes part in them (signs, or is one of their accounts).
97
+ *
98
+ * Top-level instructions are read from the wire bytes. Inner (CPI)
99
+ * instructions are read from a simulation when the RPC returns them.
100
+ *
101
+ * A token account closed to an account other than the signer is not refused;
102
+ * its lamports count as SOL leaving the signer.
103
+ */
104
+ /** Programs that own SPL token accounts. */
105
+ const TOKEN_PROGRAMS = [
106
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
107
+ 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb',
108
+ ];
109
+ const SYSTEM_PROGRAM = '11111111111111111111111111111111';
110
+ const UPGRADEABLE_LOADER = 'BPFLoaderUpgradeab1e11111111111111111111111';
111
+ const STAKE_PROGRAM = 'Stake11111111111111111111111111111111111111';
112
+ /** Wrapped SOL mints (SPL Token and Token-2022). */
113
+ const NATIVE_MINTS = [
114
+ 'So11111111111111111111111111111111111111112',
115
+ '9pan9bMn5HatX4EJdBwg9VgCa7Uz5HL8N1m5D3NdXejP',
116
+ ];
117
+ /**
118
+ * Token instructions the signer may take part in. Everything else that
119
+ * involves the signer is refused, including Token-2022 extension
120
+ * instructions this list does not name.
121
+ */
122
+ const TOKEN_ALLOWED = new Set([
123
+ 'initializeMint',
124
+ 'initializeMint2',
125
+ 'initializeAccount',
126
+ 'initializeAccount2',
127
+ 'initializeAccount3',
128
+ 'initializeMultisig',
129
+ 'initializeMultisig2',
130
+ 'initializeImmutableOwner',
131
+ 'transfer',
132
+ 'transferChecked',
133
+ 'transferCheckedWithFee',
134
+ 'mintTo',
135
+ 'mintToChecked',
136
+ 'burn',
137
+ 'burnChecked',
138
+ 'revoke',
139
+ 'freezeAccount',
140
+ 'thawAccount',
141
+ 'syncNative',
142
+ 'getAccountDataSize',
143
+ 'amountToUiAmount',
144
+ 'uiAmountToAmount',
145
+ 'reallocate',
146
+ 'createNativeMint',
147
+ ]);
148
+ const SYSTEM_ALLOWED = new Set([
149
+ 'createAccount',
150
+ 'createAccountWithSeed',
151
+ 'transfer',
152
+ 'advanceNonce',
153
+ 'initializeNonce',
154
+ 'allocateWithSeed',
155
+ 'upgradeNonce',
156
+ ]);
157
+ const STAKE_ALLOWED = new Set([
158
+ 'initialize',
159
+ 'initializeChecked',
160
+ 'delegate',
161
+ 'split',
162
+ 'deactivate',
163
+ 'deactivateDelinquent',
164
+ 'redelegate',
165
+ 'getMinimumDelegation',
166
+ ]);
167
+ function isTokenProgram(program) {
168
+ return TOKEN_PROGRAMS.includes(program);
169
+ }
170
+ function isNativeMint(mint) {
171
+ return NATIVE_MINTS.includes(mint);
172
+ }
173
+ const TOKEN_NAMES = {
174
+ 0: 'initializeMint',
175
+ 1: 'initializeAccount',
176
+ 2: 'initializeMultisig',
177
+ 3: 'transfer',
178
+ 4: 'approve',
179
+ 5: 'revoke',
180
+ 6: 'setAuthority',
181
+ 7: 'mintTo',
182
+ 8: 'burn',
183
+ 9: 'closeAccount',
184
+ 10: 'freezeAccount',
185
+ 11: 'thawAccount',
186
+ 12: 'transferChecked',
187
+ 13: 'approveChecked',
188
+ 14: 'mintToChecked',
189
+ 15: 'burnChecked',
190
+ 16: 'initializeAccount2',
191
+ 17: 'syncNative',
192
+ 18: 'initializeAccount3',
193
+ 19: 'initializeMultisig2',
194
+ 20: 'initializeMint2',
195
+ 21: 'getAccountDataSize',
196
+ 22: 'initializeImmutableOwner',
197
+ 23: 'amountToUiAmount',
198
+ 24: 'uiAmountToAmount',
199
+ 25: 'initializeMintCloseAuthority',
200
+ 29: 'reallocate',
201
+ 31: 'createNativeMint',
202
+ 38: 'withdrawExcessLamports',
203
+ };
204
+ const SYSTEM_NAMES = [
205
+ 'createAccount',
206
+ 'assign',
207
+ 'transfer',
208
+ 'createAccountWithSeed',
209
+ 'advanceNonce',
210
+ 'withdrawFromNonce',
211
+ 'initializeNonce',
212
+ 'authorizeNonce',
213
+ 'allocate',
214
+ 'allocateWithSeed',
215
+ 'assignWithSeed',
216
+ 'transferWithSeed',
217
+ 'upgradeNonce',
218
+ ];
219
+ const STAKE_NAMES = [
220
+ 'initialize',
221
+ 'authorize',
222
+ 'delegate',
223
+ 'split',
224
+ 'withdraw',
225
+ 'deactivate',
226
+ 'setLockup',
227
+ 'merge',
228
+ 'authorizeWithSeed',
229
+ 'initializeChecked',
230
+ 'authorizeChecked',
231
+ 'authorizeCheckedWithSeed',
232
+ 'setLockupChecked',
233
+ 'getMinimumDelegation',
234
+ 'deactivateDelinquent',
235
+ 'redelegate',
236
+ ];
237
+ function tokenName(data) {
238
+ const tag = data[0];
239
+ if (tag === undefined)
240
+ return 'emptyInstruction';
241
+ if (tag === 26 && data[1] === 1)
242
+ return 'transferCheckedWithFee';
243
+ return TOKEN_NAMES[tag] ?? `tokenInstruction${tag}`;
244
+ }
245
+ function u32Tag(data) {
246
+ if (data.length < 4)
247
+ return undefined;
248
+ return (data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24)) >>> 0;
249
+ }
250
+ function taggedName(data, names, fallback) {
251
+ const tag = u32Tag(data);
252
+ if (tag === undefined)
253
+ return 'emptyInstruction';
254
+ return names[tag] ?? `${fallback}${tag}`;
255
+ }
256
+ /** The instruction's name, for the programs this module checks. */
257
+ function instructionName(program, data) {
258
+ if (isTokenProgram(program))
259
+ return tokenName(data);
260
+ if (program === SYSTEM_PROGRAM)
261
+ return taggedName(data, SYSTEM_NAMES, 'systemInstruction');
262
+ if (program === STAKE_PROGRAM)
263
+ return taggedName(data, STAKE_NAMES, 'stakeInstruction');
264
+ if (program === UPGRADEABLE_LOADER) {
265
+ const tag = u32Tag(data);
266
+ return tag === undefined ? 'emptyInstruction' : `upgradeableLoaderInstruction${tag}`;
267
+ }
268
+ return null;
269
+ }
270
+ /** Why the signer may not take part in `name` of `program`, or `null`. */
271
+ function refusalReason(program, name) {
272
+ if (isTokenProgram(program)) {
273
+ switch (name) {
274
+ case 'approve':
275
+ case 'approveChecked':
276
+ return 'delegates tokens the signer controls';
277
+ case 'setAuthority':
278
+ return 'changes an authority the signer holds';
279
+ case 'closeAccount':
280
+ return null;
281
+ case 'withdrawExcessLamports':
282
+ return 'moves lamports out of an account the signer controls';
283
+ default:
284
+ return TOKEN_ALLOWED.has(name)
285
+ ? null
286
+ : "is a token instruction the signer's policy cannot check";
287
+ }
288
+ }
289
+ if (program === SYSTEM_PROGRAM) {
290
+ switch (name) {
291
+ case 'assign':
292
+ case 'assignWithSeed':
293
+ return 'changes the owner of an account the signer controls';
294
+ case 'allocate':
295
+ return "allocates data on the signer's account";
296
+ case 'authorizeNonce':
297
+ return 'changes a nonce authority the signer holds';
298
+ case 'withdrawFromNonce':
299
+ case 'transferWithSeed':
300
+ return 'moves lamports out of an account the signer controls';
301
+ default:
302
+ return SYSTEM_ALLOWED.has(name)
303
+ ? null
304
+ : "is a System instruction the signer's policy cannot check";
305
+ }
306
+ }
307
+ if (program === STAKE_PROGRAM) {
308
+ return STAKE_ALLOWED.has(name)
309
+ ? null
310
+ : 'changes the authority or withdraws from a stake account the signer controls';
311
+ }
312
+ if (program === UPGRADEABLE_LOADER) {
313
+ return 'changes a program or buffer the signer controls';
314
+ }
315
+ return null;
316
+ }
317
+ /** Check one instruction in which the signer takes part. */
318
+ function checkOne(program, name, inner, closed, destinationIsSigner, out) {
319
+ if (isTokenProgram(program) && name === 'closeAccount') {
320
+ if (!destinationIsSigner)
321
+ out.closedToOthers.push(closed);
322
+ return;
323
+ }
324
+ const reason = refusalReason(program, name);
325
+ if (reason !== null) {
326
+ out.findings.push({ programId: program, instruction: name, inner, reason });
327
+ }
328
+ }
329
+ function emptyChecked() {
330
+ return { findings: [], closedToOthers: [] };
331
+ }
332
+ function mergeChecked(into, other) {
333
+ into.findings.push(...other.findings);
334
+ into.closedToOthers.push(...other.closedToOthers);
335
+ }
336
+ /**
337
+ * Check the top-level instructions of `decoded`. `lookupKeys` are the
338
+ * addresses its lookup tables load (writable entries, then read-only), where
339
+ * they were resolved.
340
+ */
341
+ function checkTopLevel(decoded, signer, lookupKeys) {
342
+ const out = emptyChecked();
343
+ const signerIndex = decoded.staticKeys.slice(0, decoded.requiredSignatures).indexOf(signer);
344
+ // Not a signer of this transaction: no authority of its can be used.
345
+ if (signerIndex < 0)
346
+ return out;
347
+ const key = (index) => decoded.staticKeys[index] ?? lookupKeys[index - decoded.staticKeys.length] ?? null;
348
+ for (const instruction of decoded.instructions) {
349
+ const program = decoded.staticKeys[instruction.programIndex];
350
+ if (program === undefined)
351
+ continue;
352
+ const name = instructionName(program, instruction.data);
353
+ if (name === null)
354
+ continue;
355
+ if (!instruction.accounts.includes(signerIndex))
356
+ continue;
357
+ const first = instruction.accounts[0];
358
+ const closed = first === undefined ? null : key(first);
359
+ checkOne(program, name, false, closed, instruction.accounts[1] === signerIndex, out);
360
+ }
361
+ return out;
362
+ }
363
+ function mentions(value, signer) {
364
+ if (typeof value === 'string')
365
+ return value === signer;
366
+ if (Array.isArray(value))
367
+ return value.some((item) => mentions(item, signer));
368
+ if (isObject(value))
369
+ return Object.values(value).some((item) => mentions(item, signer));
370
+ return false;
371
+ }
372
+ /**
373
+ * Check the inner instructions a simulation returned (`innerInstructions` of
374
+ * `simulateTransaction`, in the RPC's parsed or partially decoded form).
375
+ * `accountKeys` resolves compiled instructions' indexes.
376
+ */
377
+ function checkInner(inner, signer, accountKeys) {
378
+ const out = emptyChecked();
379
+ if (!Array.isArray(inner))
380
+ return out;
381
+ for (const group of inner) {
382
+ const instructions = field(group, 'instructions');
383
+ if (!Array.isArray(instructions))
384
+ continue;
385
+ for (const instruction of instructions) {
386
+ mergeChecked(out, checkInnerOne(instruction, signer, accountKeys));
387
+ }
388
+ }
389
+ return out;
390
+ }
391
+ function checkInnerOne(instruction, signer, accountKeys) {
392
+ const out = emptyChecked();
393
+ let program = asString(field(instruction, 'programId'));
394
+ if (program === undefined) {
395
+ const index = asU64(field(instruction, 'programIdIndex'));
396
+ program = index === undefined ? undefined : (accountKeys[index] ?? undefined);
397
+ if (program === undefined)
398
+ return out;
399
+ }
400
+ const parsed = field(instruction, 'parsed');
401
+ if (isObject(parsed)) {
402
+ const name = asString(parsed.type);
403
+ if (name === undefined)
404
+ return out;
405
+ const info = parsed.info;
406
+ if (instructionName(program, new Uint8Array()) === null || !mentions(info, signer))
407
+ return out;
408
+ const closed = asString(field(info, 'account')) ?? null;
409
+ checkOne(program, name, true, closed, field(info, 'destination') === signer, out);
410
+ return out;
411
+ }
412
+ const rawAccounts = field(instruction, 'accounts');
413
+ if (!Array.isArray(rawAccounts))
414
+ return out;
415
+ const accounts = rawAccounts.map((account) => {
416
+ if (typeof account === 'string')
417
+ return account;
418
+ if (typeof account === 'number') {
419
+ const index = asU64(account);
420
+ return index === undefined ? null : (accountKeys[index] ?? null);
421
+ }
422
+ return null;
423
+ });
424
+ const encoded = asString(field(instruction, 'data'));
425
+ const data = (encoded === undefined ? null : decodeBase58(encoded)) ?? new Uint8Array();
426
+ const name = instructionName(program, data);
427
+ if (name === null)
428
+ return out;
429
+ if (!accounts.includes(signer))
430
+ return out;
431
+ checkOne(program, name, true, accounts[0] ?? null, accounts[1] === signer, out);
432
+ return out;
433
+ }
434
+
435
+ /**
436
+ * What a signer may sign for a user: the static policy.
437
+ *
438
+ * The rules, their names and the refusal shape match the Arete MCP server's
439
+ * local signer, and both implementations run the shared vectors in
440
+ * `test-vectors/signer-policy/`. The caps hold per transaction, measured by
441
+ * simulating the exact prepared bytes. Wrapped SOL counts against
442
+ * `maxSolPerTransaction`, as do the lamports of a token account closed to
443
+ * someone else. Instructions that hand control of the signer's funds to
444
+ * someone else (token approvals, authority changes, System assignments) are
445
+ * always refused, and so is any transaction whose effect on the signer
446
+ * could not be simulated.
447
+ */
448
+ /** Clusters a policy allows when it does not list any. */
449
+ const DEFAULT_CLUSTERS = ['dev'];
450
+ /**
451
+ * Every cluster a target can be: `dev` for a deployment in a dev environment
452
+ * (a fork of mainnet), `mainnet` for a published deployment.
453
+ */
454
+ const KNOWN_CLUSTERS = ['dev', 'mainnet'];
455
+ /**
456
+ * Names the MCP signer's earlier configs used, read the same way it reads
457
+ * them: `localnet` is `dev`; Arete runs on neither `devnet` nor `testnet`,
458
+ * so those are dropped.
459
+ */
460
+ const LEGACY_CLUSTERS = {
461
+ localnet: 'dev',
462
+ devnet: null,
463
+ testnet: null,
464
+ };
465
+ /**
466
+ * Programs the transaction compiler adds around a step's own instructions
467
+ * (compute budget, memo, fee transfers, token accounts and wrapped SOL).
468
+ * They are always allowed; what they move is bounded by the SOL and token
469
+ * caps.
470
+ */
471
+ const RUNTIME_PROGRAMS = [
472
+ 'ComputeBudget111111111111111111111111111111',
473
+ 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr',
474
+ 'Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo',
475
+ '11111111111111111111111111111111',
476
+ 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL',
477
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
478
+ 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb',
479
+ ];
480
+ const U64_MAX = (1n << 64n) - 1n;
481
+ const U128_MAX = (1n << 128n) - 1n;
482
+ class PolicyError extends Error {
483
+ constructor(message) {
484
+ super(message);
485
+ this.name = 'PolicyError';
486
+ }
487
+ }
488
+ const POLICY_FIELDS = new Set([
489
+ 'deployments',
490
+ 'maxSolPerTransaction',
491
+ 'programs',
492
+ 'maxTokenAmounts',
493
+ 'clusters',
494
+ ]);
495
+ /** Parse SOL (a number or decimal string) into lamports. */
496
+ function solToLamports(value) {
497
+ let text;
498
+ if (typeof value === 'string')
499
+ text = value.trim();
500
+ else if (typeof value === 'number' && Number.isFinite(value))
501
+ text = plainDecimal(value);
502
+ else
503
+ throw new PolicyError('maxSolPerTransaction must be a number of SOL');
504
+ const point = text.indexOf('.');
505
+ const whole = point < 0 ? text : text.slice(0, point);
506
+ const fraction = point < 0 ? '' : text.slice(point + 1);
507
+ if ((whole === '' && fraction === '') ||
508
+ !/^\d*$/.test(whole) ||
509
+ !/^\d*$/.test(fraction) ||
510
+ fraction.length > 9) {
511
+ throw new PolicyError('maxSolPerTransaction must be a non-negative number of SOL with at most 9 decimals');
512
+ }
513
+ const wholeValue = whole === '' ? 0n : BigInt(whole);
514
+ const lamports = wholeValue * 1000000000n + BigInt(fraction.padEnd(9, '0'));
515
+ if (wholeValue > U64_MAX || lamports > U64_MAX) {
516
+ throw new PolicyError('maxSolPerTransaction is too large');
517
+ }
518
+ return lamports;
519
+ }
520
+ function tokenAmount(value) {
521
+ let amount;
522
+ if (typeof value === 'bigint' && value >= 0n)
523
+ amount = value;
524
+ else if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
525
+ amount = BigInt(value);
526
+ }
527
+ else if (typeof value === 'string' && /^\d+$/.test(value))
528
+ amount = BigInt(value);
529
+ if (amount === undefined || amount > U128_MAX) {
530
+ throw new PolicyError('maxTokenAmounts values must be non-negative integers in base units');
531
+ }
532
+ return amount;
533
+ }
534
+ function stringList(value, name) {
535
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
536
+ throw new PolicyError(`policy.${name} must be a list of strings`);
537
+ }
538
+ return [...value];
539
+ }
540
+ /** Validate a policy. Throws {@link PolicyError} naming the field at fault. */
541
+ function definePolicy(input) {
542
+ if (!isObject(input))
543
+ throw new PolicyError('a policy must be an object');
544
+ const unknown = Object.keys(input).filter((key) => !POLICY_FIELDS.has(key));
545
+ if (unknown.length > 0)
546
+ throw new PolicyError(`policy has unknown field '${unknown[0]}'`);
547
+ if (input.deployments === undefined)
548
+ throw new PolicyError('policy.deployments is required');
549
+ const deployments = stringList(input.deployments, 'deployments');
550
+ if (input.maxSolPerTransaction === undefined) {
551
+ throw new PolicyError('policy.maxSolPerTransaction is required');
552
+ }
553
+ if (deployments.length === 0) {
554
+ throw new PolicyError('policy.deployments must list at least one deployment id');
555
+ }
556
+ const maxLamportsPerTransaction = solToLamports(input.maxSolPerTransaction);
557
+ let programs = null;
558
+ if (input.programs !== undefined && input.programs !== null) {
559
+ programs = stringList(input.programs, 'programs');
560
+ const bad = programs.find((program) => !isPubkey(program));
561
+ if (bad !== undefined)
562
+ throw new PolicyError(`policy.programs: '${bad}' is not a program id`);
563
+ }
564
+ let maxTokenAmounts = null;
565
+ if (input.maxTokenAmounts !== undefined && input.maxTokenAmounts !== null) {
566
+ if (!isObject(input.maxTokenAmounts)) {
567
+ throw new PolicyError('policy.maxTokenAmounts must map mints to amounts');
568
+ }
569
+ maxTokenAmounts = new Map();
570
+ for (const mint of Object.keys(input.maxTokenAmounts).sort()) {
571
+ if (!isPubkey(mint)) {
572
+ throw new PolicyError(`policy.maxTokenAmounts: '${mint}' is not a mint address`);
573
+ }
574
+ maxTokenAmounts.set(mint, tokenAmount(input.maxTokenAmounts[mint]));
575
+ }
576
+ }
577
+ const clustersListed = input.clusters !== undefined && input.clusters !== null;
578
+ let clusters = [...DEFAULT_CLUSTERS];
579
+ if (clustersListed) {
580
+ clusters = [];
581
+ for (const listed of stringList(input.clusters, 'clusters')) {
582
+ let cluster;
583
+ if (KNOWN_CLUSTERS.includes(listed))
584
+ cluster = listed;
585
+ else if (Object.hasOwn(LEGACY_CLUSTERS, listed))
586
+ cluster = LEGACY_CLUSTERS[listed];
587
+ else
588
+ throw new PolicyError(`policy.clusters: '${listed}' is not one of ${KNOWN_CLUSTERS.join(', ')}`);
589
+ if (cluster !== null && !clusters.includes(cluster))
590
+ clusters.push(cluster);
591
+ }
592
+ }
593
+ return { deployments, maxLamportsPerTransaction, programs, maxTokenAmounts, clusters, clustersListed };
594
+ }
595
+ /** Whether the deployment and cluster are allowed, before preparing. `null` when they are. */
596
+ function checkTarget(policy, deploymentId, cluster) {
597
+ if (!policy.deployments.includes(deploymentId)) {
598
+ return {
599
+ rule: 'deployments',
600
+ reason: `deployment ${deploymentId} is not in the signer's deployments allow-list`,
601
+ };
602
+ }
603
+ if (!policy.clusters.includes(cluster)) {
604
+ return {
605
+ rule: 'clusters',
606
+ reason: `the target cluster is ${cluster}, which the signer's clusters (${policy.clusters.join(', ')}) do not include${cluster === 'mainnet' ? '; mainnet must be listed explicitly' : ''}`,
607
+ };
608
+ }
609
+ return null;
610
+ }
611
+ function parseBigInt(value) {
612
+ if (typeof value !== 'string' || !/^-?\d+$/.test(value))
613
+ return null;
614
+ return BigInt(value);
615
+ }
616
+ /** How a JSON value prints, the way the reference implementation formats it in reasons. */
617
+ function display(value) {
618
+ return value === undefined ? 'null' : JSON.stringify(value);
619
+ }
620
+ /**
621
+ * Check a prepared step against the policy: the intent's deployment, every
622
+ * transaction's programs, the instructions that would hand over control of
623
+ * the signer's funds, and the simulated SOL and token outflows of the
624
+ * signer, per transaction. `null` when it may be signed.
625
+ */
626
+ function checkPrepared(policy, input) {
627
+ const { deploymentId, prepared, summary, simulates } = input;
628
+ const intentDeployment = asString(field(field(field(prepared, 'intent'), 'deployment'), 'deploymentId')) ??
629
+ deploymentId;
630
+ if (intentDeployment !== deploymentId || !policy.deployments.includes(intentDeployment)) {
631
+ return {
632
+ rule: 'deployments',
633
+ reason: `the prepared intent is for deployment ${intentDeployment}, which is not allowed`,
634
+ };
635
+ }
636
+ for (const transaction of summary.transactions) {
637
+ const index = display(transaction.index);
638
+ if (policy.programs !== null) {
639
+ for (const program of transaction.programs) {
640
+ const id = program.programId;
641
+ if (!RUNTIME_PROGRAMS.includes(id) && !policy.programs.includes(id)) {
642
+ return {
643
+ rule: 'programs',
644
+ reason: `transaction ${index} invokes ${id}, which is not in the signer's programs allow-list`,
645
+ };
646
+ }
647
+ }
648
+ }
649
+ const finding = transaction.unsafeInstructions[0];
650
+ if (finding !== undefined) {
651
+ return {
652
+ rule: 'authority',
653
+ reason: `transaction ${index} has ${finding.inner ? 'an inner' : 'a'} \`${finding.instruction}\` instruction of ${finding.programId} that ${finding.reason}; the signer never signs those`,
654
+ };
655
+ }
656
+ const effect = transaction.signerEffect;
657
+ if (effect.known !== true) {
658
+ let why = '';
659
+ if ((effect.omittedAccounts ?? 0) > 0) {
660
+ why = ' (it writes more accounts than the simulation reports on)';
661
+ }
662
+ else if ((effect.closedAccountLamportsOut ?? null) === null &&
663
+ typeof effect.solLamports === 'string') {
664
+ why = ' (it closes a token account the simulation could not measure)';
665
+ }
666
+ else if (!simulates) {
667
+ why = ' (no simulator is configured)';
668
+ }
669
+ return {
670
+ rule: 'maxSolPerTransaction',
671
+ reason: `the signer's balance change in transaction ${index} could not be simulated${why}, so the caps cannot be checked`,
672
+ };
673
+ }
674
+ const lamports = parseBigInt(effect.solLamports) ?? -(1n << 126n);
675
+ const tokens = effect.tokens ?? [];
676
+ // Wrapped SOL is SOL: its delta joins the lamports, so wrapping is not
677
+ // an outflow and spending wrapped SOL is.
678
+ const wrapped = tokens
679
+ .filter((token) => isNativeMint(token.mint))
680
+ .reduce((sum, token) => sum + (parseBigInt(token.amount) ?? 0n), 0n);
681
+ const closed = parseBigInt(effect.closedAccountLamportsOut) ?? 0n;
682
+ let outflow = closed - lamports - wrapped;
683
+ if (outflow < 0n)
684
+ outflow = 0n;
685
+ if (outflow > policy.maxLamportsPerTransaction) {
686
+ return {
687
+ rule: 'maxSolPerTransaction',
688
+ reason: `transaction ${index} moves ${lamportsToSol(outflow)} SOL out of the signer (wrapped SOL and closed token accounts included), over the ${lamportsToSol(policy.maxLamportsPerTransaction)} SOL cap`,
689
+ };
690
+ }
691
+ for (const token of tokens) {
692
+ if (isNativeMint(token.mint))
693
+ continue;
694
+ const amount = parseBigInt(token.amount) ?? 0n;
695
+ if (amount >= 0n)
696
+ continue;
697
+ const tokenOutflow = -amount;
698
+ const mint = token.mint;
699
+ if (policy.maxTokenAmounts === null) {
700
+ return {
701
+ rule: 'maxTokenAmounts',
702
+ reason: `transaction ${index} moves ${tokenOutflow} base units of ${mint} out of the signer, and the policy sets no maxTokenAmounts, so no token may leave`,
703
+ };
704
+ }
705
+ const cap = policy.maxTokenAmounts.get(mint);
706
+ if (cap === undefined) {
707
+ return {
708
+ rule: 'maxTokenAmounts',
709
+ reason: `transaction ${index} moves ${tokenOutflow} base units of ${mint} out of the signer, and that mint has no cap`,
710
+ };
711
+ }
712
+ if (tokenOutflow > cap) {
713
+ return {
714
+ rule: 'maxTokenAmounts',
715
+ reason: `transaction ${index} moves ${tokenOutflow} base units of ${mint} out of the signer, over the cap of ${cap}`,
716
+ };
717
+ }
718
+ }
719
+ }
720
+ return null;
721
+ }
722
+
723
+ /** A {@link Simulator} over a JSON-RPC URL. */
724
+ function rpcSimulator(url, options = {}) {
725
+ const fetchImpl = options.fetch ?? globalThis.fetch;
726
+ if (typeof fetchImpl !== 'function') {
727
+ throw new TypeError('rpcSimulator needs a fetch implementation');
728
+ }
729
+ const timeoutMs = options.timeoutMs ?? 15_000;
730
+ return {
731
+ async call(method, params) {
732
+ const controller = new AbortController();
733
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
734
+ try {
735
+ let response;
736
+ try {
737
+ response = await fetchImpl(url, {
738
+ method: 'POST',
739
+ headers: { 'content-type': 'application/json', ...options.headers },
740
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
741
+ signal: controller.signal,
742
+ });
743
+ }
744
+ catch {
745
+ throw new Error(controller.signal.aborted
746
+ ? `The RPC did not answer ${method} within ${timeoutMs}ms`
747
+ : `The RPC is unreachable for ${method}`);
748
+ }
749
+ let body;
750
+ try {
751
+ body = await response.json();
752
+ }
753
+ catch {
754
+ throw new Error(controller.signal.aborted
755
+ ? `The RPC did not answer ${method} within ${timeoutMs}ms`
756
+ : `The RPC answered ${method} with invalid JSON`);
757
+ }
758
+ const record = (body ?? {});
759
+ if (record.error !== undefined && record.error !== null) {
760
+ const message = typeof record.error.message === 'string' ? record.error.message : 'unknown error';
761
+ throw new Error(`${method} failed: ${message}`);
762
+ }
763
+ return record.result ?? null;
764
+ }
765
+ finally {
766
+ clearTimeout(timer);
767
+ }
768
+ },
769
+ };
770
+ }
771
+
772
+ class TransactionDecodeError extends Error {
773
+ constructor(message) {
774
+ super(message);
775
+ this.name = 'TransactionDecodeError';
776
+ }
777
+ }
778
+ /** The version byte of a v1 transaction, which leads its wire bytes. */
779
+ const V1_PREFIX = 0x81;
780
+ /** Configuration mask bits a v1 message may set; each adds one 4-byte value. */
781
+ const V1_KNOWN_CONFIG_BITS = 0b11111;
782
+ class Reader {
783
+ bytes;
784
+ at;
785
+ constructor(bytes, at = 0) {
786
+ this.bytes = bytes;
787
+ this.at = at;
788
+ }
789
+ compactU16() {
790
+ let value = 0;
791
+ for (let index = 0; index < 3; index++) {
792
+ const byte = this.bytes[this.at];
793
+ if (byte === undefined)
794
+ throw new TransactionDecodeError('truncated transaction');
795
+ this.at += 1;
796
+ value |= (byte & 0x7f) << (7 * index);
797
+ if ((byte & 0x80) === 0)
798
+ return value;
799
+ }
800
+ throw new TransactionDecodeError('invalid compact-u16 length');
801
+ }
802
+ take(length) {
803
+ if (this.at + length > this.bytes.length) {
804
+ throw new TransactionDecodeError('truncated transaction');
805
+ }
806
+ const slice = this.bytes.subarray(this.at, this.at + length);
807
+ this.at += length;
808
+ return slice;
809
+ }
810
+ byte() {
811
+ return this.take(1)[0];
812
+ }
813
+ }
814
+ function writableIndexes(keyCount, required, readonlySigned, readonlyUnsigned) {
815
+ if (required > keyCount || readonlySigned > required || readonlyUnsigned > keyCount - required) {
816
+ throw new TransactionDecodeError('invalid message header');
817
+ }
818
+ const out = [];
819
+ for (let index = 0; index < keyCount; index++) {
820
+ const writable = index < required ? index < required - readonlySigned : index < keyCount - readonlyUnsigned;
821
+ if (writable)
822
+ out.push(index);
823
+ }
824
+ return out;
825
+ }
826
+ /** Decode a legacy, v0 or v1 wire transaction. */
827
+ function decodeTransaction(bytes) {
828
+ if (bytes[0] === V1_PREFIX)
829
+ return decodeV1(bytes);
830
+ const reader = new Reader(bytes);
831
+ const signatureCount = reader.compactU16();
832
+ const signatureOffset = reader.at;
833
+ const signatures = [];
834
+ for (let index = 0; index < signatureCount; index++) {
835
+ signatures.push(reader.take(64));
836
+ }
837
+ const messageStart = reader.at;
838
+ const first = bytes[reader.at];
839
+ if (first === undefined)
840
+ throw new TransactionDecodeError('truncated message');
841
+ let version = null;
842
+ if ((first & 0x80) !== 0) {
843
+ reader.at += 1;
844
+ if ((first & 0x7f) !== 0)
845
+ throw new TransactionDecodeError('unsupported transaction version');
846
+ version = 0;
847
+ }
848
+ const header = reader.take(3);
849
+ const [required, readonlySigned, readonlyUnsigned] = [header[0], header[1], header[2]];
850
+ const keyCount = reader.compactU16();
851
+ const staticKeys = [];
852
+ for (let index = 0; index < keyCount; index++) {
853
+ staticKeys.push(encodeBase58(reader.take(32)));
854
+ }
855
+ const writableStatic = writableIndexes(keyCount, required, readonlySigned, readonlyUnsigned);
856
+ reader.take(32); // recent blockhash
857
+ const instructionCount = reader.compactU16();
858
+ const instructionPrograms = [];
859
+ const instructions = [];
860
+ for (let index = 0; index < instructionCount; index++) {
861
+ const programIndex = reader.byte();
862
+ const accounts = reader.take(reader.compactU16());
863
+ const data = reader.take(reader.compactU16());
864
+ instructions.push({ programIndex, accounts: [...accounts], data: data.slice() });
865
+ const program = staticKeys[programIndex];
866
+ if (program === undefined) {
867
+ throw new TransactionDecodeError("an instruction's program is not a static key");
868
+ }
869
+ instructionPrograms.push(program);
870
+ }
871
+ const lookups = [];
872
+ if (version !== null) {
873
+ const count = reader.compactU16();
874
+ for (let index = 0; index < count; index++) {
875
+ const table = encodeBase58(reader.take(32));
876
+ const writable = [...reader.take(reader.compactU16())];
877
+ const readonly = [...reader.take(reader.compactU16())];
878
+ lookups.push({ table, writable, readonly });
879
+ }
880
+ }
881
+ return {
882
+ version,
883
+ signatures,
884
+ signatureOffset,
885
+ message: { start: messageStart, end: bytes.length },
886
+ requiredSignatures: required,
887
+ staticKeys,
888
+ writableStatic,
889
+ instructionPrograms,
890
+ instructions,
891
+ lookups,
892
+ };
893
+ }
894
+ /** A v1 transaction: the version byte and message, then one signature per required signer. */
895
+ function decodeV1(bytes) {
896
+ const reader = new Reader(bytes, 1);
897
+ const header = reader.take(3);
898
+ const [required, readonlySigned, readonlyUnsigned] = [header[0], header[1], header[2]];
899
+ const maskBytes = reader.take(4);
900
+ const mask = (maskBytes[0] | (maskBytes[1] << 8) | (maskBytes[2] << 16) | (maskBytes[3] << 24)) >>> 0;
901
+ if ((mask & ~V1_KNOWN_CONFIG_BITS) >>> 0 !== 0) {
902
+ throw new TransactionDecodeError('unsupported v1 transaction configuration');
903
+ }
904
+ reader.take(32); // lifetime specifier
905
+ const instructionCount = reader.byte();
906
+ const keyCount = reader.byte();
907
+ const staticKeys = [];
908
+ for (let index = 0; index < keyCount; index++) {
909
+ staticKeys.push(encodeBase58(reader.take(32)));
910
+ }
911
+ reader.take(popcount(mask) * 4);
912
+ const headers = reader.take(instructionCount * 4).slice();
913
+ const instructionPrograms = [];
914
+ const instructions = [];
915
+ for (let offset = 0; offset < headers.length; offset += 4) {
916
+ const programIndex = headers[offset];
917
+ const accountCount = headers[offset + 1];
918
+ const dataLength = headers[offset + 2] | (headers[offset + 3] << 8);
919
+ const accounts = reader.take(accountCount);
920
+ const data = reader.take(dataLength);
921
+ instructions.push({ programIndex, accounts: [...accounts], data: data.slice() });
922
+ const program = staticKeys[programIndex];
923
+ if (program === undefined) {
924
+ throw new TransactionDecodeError("an instruction's program is not an address");
925
+ }
926
+ instructionPrograms.push(program);
927
+ }
928
+ const messageEnd = reader.at;
929
+ const signatures = [];
930
+ for (let index = 0; index < required; index++) {
931
+ signatures.push(reader.take(64));
932
+ }
933
+ if (reader.at !== bytes.length) {
934
+ throw new TransactionDecodeError('trailing bytes after the transaction');
935
+ }
936
+ return {
937
+ version: 1,
938
+ signatures,
939
+ signatureOffset: messageEnd,
940
+ message: { start: 0, end: messageEnd },
941
+ requiredSignatures: required,
942
+ staticKeys,
943
+ writableStatic: writableIndexes(keyCount, required, readonlySigned, readonlyUnsigned),
944
+ instructionPrograms,
945
+ instructions,
946
+ lookups: [],
947
+ };
948
+ }
949
+ function popcount(value) {
950
+ let count = 0;
951
+ for (let bits = value >>> 0; bits !== 0; bits >>>= 1)
952
+ count += bits & 1;
953
+ return count;
954
+ }
955
+ /** The required signers and whether each has signed. */
956
+ function transactionSigners(decoded) {
957
+ return decoded.staticKeys
958
+ .slice(0, decoded.requiredSignatures)
959
+ .map((key, index) => [key, decoded.signatures[index]?.some((byte) => byte !== 0) ?? false]);
960
+ }
961
+ /** The distinct programs invoked, in first-use order. */
962
+ function transactionPrograms(decoded) {
963
+ return [...new Set(decoded.instructionPrograms)];
964
+ }
965
+ function writableStaticKeys(decoded) {
966
+ return decoded.writableStatic.map((index) => decoded.staticKeys[index]);
967
+ }
968
+
969
+ /**
970
+ * What a prepared step's transactions do, read from the transactions
971
+ * themselves rather than from the labels a prepare returns:
972
+ *
973
+ * - the programs each transaction invokes;
974
+ * - the accounts it writes (static keys, plus address lookup table entries,
975
+ * resolved when a simulator is available);
976
+ * - the signatures it carries and the ones still required;
977
+ * - with a simulator, the signer's SOL and token balance changes from a
978
+ * simulation of the exact bytes, the simulation error and the tail of its
979
+ * logs;
980
+ * - the instructions that would hand control of the signer's funds to
981
+ * someone else (see `authority.ts`): read from the top-level instructions
982
+ * always, and from the inner instructions when the simulation returns
983
+ * them.
984
+ *
985
+ * Without a simulator the balance changes are reported as unavailable,
986
+ * never as zero.
987
+ */
988
+ const LOOKUP_TABLE_HEADER = 56;
989
+ const LOG_TAIL = 20;
990
+ /** The most accounts one transaction's simulation reports on. */
991
+ const MAX_SIMULATED_ACCOUNTS = 64;
992
+ function readU64(bytes, offset) {
993
+ let value = 0n;
994
+ for (let index = 7; index >= 0; index--) {
995
+ value = (value << 8n) | BigInt(bytes[offset + index]);
996
+ }
997
+ return value;
998
+ }
999
+ // RPC lamports arrive as JSON numbers, so balances above 2^53 lamports lose
1000
+ // precision when parsed. No single account the policy measures holds that.
1001
+ function accountFacts(account) {
1002
+ if (account === null || account === undefined)
1003
+ return null;
1004
+ const lamports = BigInt(asU64(field(account, 'lamports')) ?? 0);
1005
+ const owner = asString(field(account, 'owner')) ?? '';
1006
+ let token = null;
1007
+ if (isTokenProgram(owner)) {
1008
+ const data = field(account, 'data');
1009
+ const encoded = Array.isArray(data) ? asString(data[0]) : undefined;
1010
+ const bytes = encoded === undefined ? null : decodeBase64(encoded);
1011
+ if (bytes !== null && bytes.length >= 72) {
1012
+ token = {
1013
+ mint: encodeBase58(bytes.subarray(0, 32)),
1014
+ owner: encodeBase58(bytes.subarray(32, 64)),
1015
+ amount: readU64(bytes, 64),
1016
+ };
1017
+ }
1018
+ }
1019
+ return { lamports, token };
1020
+ }
1021
+ /** The accounts at `addresses` and the highest slot the RPC read them at. */
1022
+ async function accountsAt(simulator, addresses) {
1023
+ const accounts = [];
1024
+ let slot = null;
1025
+ for (let start = 0; start < addresses.length; start += 100) {
1026
+ const chunk = addresses.slice(start, start + 100);
1027
+ const result = await simulator.call('getMultipleAccounts', [
1028
+ chunk,
1029
+ { encoding: 'base64', commitment: 'confirmed' },
1030
+ ]);
1031
+ const values = field(result, 'value');
1032
+ if (!Array.isArray(values))
1033
+ throw new Error('getMultipleAccounts returned no accounts');
1034
+ const read = asU64(field(field(result, 'context'), 'slot'));
1035
+ if (read !== undefined)
1036
+ slot = slot === null ? read : Math.max(slot, read);
1037
+ accounts.push(...values);
1038
+ }
1039
+ return { accounts, slot };
1040
+ }
1041
+ function noLookups() {
1042
+ return { writable: [], keys: [], unresolved: [] };
1043
+ }
1044
+ async function resolveLookups(simulator, decoded) {
1045
+ const out = noLookups();
1046
+ if (decoded.lookups.length === 0)
1047
+ return out;
1048
+ let accounts = [];
1049
+ try {
1050
+ accounts = (await accountsAt(simulator, decoded.lookups.map((lookup) => lookup.table))).accounts;
1051
+ }
1052
+ catch {
1053
+ accounts = [];
1054
+ }
1055
+ const tableAddresses = decoded.lookups.map((_, index) => {
1056
+ const data = field(accounts[index], 'data');
1057
+ const encoded = Array.isArray(data) ? asString(data[0]) : undefined;
1058
+ const bytes = encoded === undefined ? null : decodeBase64(encoded);
1059
+ return bytes !== null && bytes.length >= LOOKUP_TABLE_HEADER
1060
+ ? bytes.subarray(LOOKUP_TABLE_HEADER)
1061
+ : null;
1062
+ });
1063
+ const entry = (table, position) => {
1064
+ const addresses = tableAddresses[table];
1065
+ if (!addresses)
1066
+ return null;
1067
+ const start = position * 32;
1068
+ return start + 32 <= addresses.length
1069
+ ? encodeBase58(addresses.subarray(start, start + 32))
1070
+ : null;
1071
+ };
1072
+ decoded.lookups.forEach((lookup, index) => {
1073
+ for (const position of lookup.writable) {
1074
+ const key = entry(index, position);
1075
+ if (key !== null)
1076
+ out.writable.push(key);
1077
+ else if (!out.unresolved.includes(lookup.table))
1078
+ out.unresolved.push(lookup.table);
1079
+ out.keys.push(key);
1080
+ }
1081
+ });
1082
+ decoded.lookups.forEach((lookup, index) => {
1083
+ for (const position of lookup.readonly)
1084
+ out.keys.push(entry(index, position));
1085
+ });
1086
+ return out;
1087
+ }
1088
+ function effectKnown(effect) {
1089
+ return (effect.solLamports !== null &&
1090
+ effect.unresolved.length === 0 &&
1091
+ effect.omittedAccounts === 0 &&
1092
+ effect.closedLamportsOut !== null);
1093
+ }
1094
+ /**
1095
+ * Simulate `transaction` (wire bytes) and measure its effect on `signer`.
1096
+ *
1097
+ * The pre-state is read with `getMultipleAccounts` and the simulation is
1098
+ * asked not to run on an older slot (`minContextSlot`), but the two are
1099
+ * separate reads: an account that changes in between skews the deltas.
1100
+ */
1101
+ async function simulateSignerEffect(simulator, transaction, decoded, signer, lookups) {
1102
+ const addresses = [];
1103
+ for (const key of [signer, ...writableStaticKeys(decoded), ...lookups.writable]) {
1104
+ if (!addresses.includes(key))
1105
+ addresses.push(key);
1106
+ }
1107
+ const omittedAccounts = Math.max(0, addresses.length - MAX_SIMULATED_ACCOUNTS);
1108
+ addresses.length = Math.min(addresses.length, MAX_SIMULATED_ACCOUNTS);
1109
+ const accountKeys = [...decoded.staticKeys, ...lookups.keys];
1110
+ const checked = checkTopLevel(decoded, signer, lookups.keys);
1111
+ const { accounts: before, slot: preSlot } = await accountsAt(simulator, addresses);
1112
+ const config = {
1113
+ encoding: 'base64',
1114
+ sigVerify: false,
1115
+ replaceRecentBlockhash: true,
1116
+ commitment: 'confirmed',
1117
+ innerInstructions: true,
1118
+ accounts: { encoding: 'base64', addresses },
1119
+ };
1120
+ if (preSlot !== null)
1121
+ config.minContextSlot = preSlot;
1122
+ const result = await simulator.call('simulateTransaction', [encodeBase64(transaction), config]);
1123
+ const value = field(result, 'value');
1124
+ const rawLogs = field(value, 'logs');
1125
+ const logs = Array.isArray(rawLogs)
1126
+ ? rawLogs.filter((line) => typeof line === 'string')
1127
+ : [];
1128
+ const logsTail = logs.slice(Math.max(0, logs.length - LOG_TAIL));
1129
+ const err = field(value, 'err') ?? null;
1130
+ const inner = field(value, 'innerInstructions');
1131
+ const innerChecked = Array.isArray(inner);
1132
+ mergeChecked(checked, checkInner(inner, signer, accountKeys));
1133
+ const slots = [
1134
+ preSlot,
1135
+ asU64(field(field(result, 'context'), 'slot')) ?? null,
1136
+ ];
1137
+ const rawAfter = field(value, 'accounts');
1138
+ const after = Array.isArray(rawAfter) ? rawAfter : [];
1139
+ if (err !== null || after.length !== addresses.length) {
1140
+ return {
1141
+ solLamports: null,
1142
+ tokens: new Map(),
1143
+ closedLamportsOut: null,
1144
+ err,
1145
+ logsTail,
1146
+ unresolved: lookups.unresolved,
1147
+ omittedAccounts,
1148
+ findings: checked.findings,
1149
+ innerChecked,
1150
+ slots,
1151
+ };
1152
+ }
1153
+ let solLamports = null;
1154
+ const tokens = new Map();
1155
+ const signersSide = (facts) => facts?.token && facts.token.owner === signer ? facts.token : null;
1156
+ for (const [index, address] of addresses.entries()) {
1157
+ const pre = accountFacts(before[index]);
1158
+ const post = accountFacts(after[index]);
1159
+ if (address === signer) {
1160
+ solLamports = (post?.lamports ?? 0n) - (pre?.lamports ?? 0n);
1161
+ }
1162
+ const preToken = signersSide(pre);
1163
+ if (preToken)
1164
+ tokens.set(preToken.mint, (tokens.get(preToken.mint) ?? 0n) - preToken.amount);
1165
+ const postToken = signersSide(post);
1166
+ if (postToken) {
1167
+ tokens.set(postToken.mint, (tokens.get(postToken.mint) ?? 0n) + postToken.amount);
1168
+ }
1169
+ }
1170
+ for (const [mint, delta] of tokens)
1171
+ if (delta === 0n)
1172
+ tokens.delete(mint);
1173
+ let closedLamportsOut = 0n;
1174
+ for (const closed of checked.closedToOthers) {
1175
+ let lamports = null;
1176
+ const index = closed === null ? -1 : addresses.indexOf(closed);
1177
+ const facts = index < 0 ? null : accountFacts(before[index]);
1178
+ if (facts) {
1179
+ const wrapped = facts.token && isNativeMint(facts.token.mint) ? facts.token.amount : 0n;
1180
+ lamports = facts.lamports > wrapped ? facts.lamports - wrapped : 0n;
1181
+ }
1182
+ closedLamportsOut =
1183
+ closedLamportsOut !== null && lamports !== null ? closedLamportsOut + lamports : null;
1184
+ }
1185
+ return {
1186
+ solLamports,
1187
+ tokens,
1188
+ closedLamportsOut,
1189
+ err,
1190
+ logsTail,
1191
+ unresolved: lookups.unresolved,
1192
+ omittedAccounts,
1193
+ findings: checked.findings,
1194
+ innerChecked,
1195
+ slots,
1196
+ };
1197
+ }
1198
+ // ---------------------------------------------------------------------------
1199
+ // Summary
1200
+ // ---------------------------------------------------------------------------
1201
+ /** The wire transactions of a prepare result, with their indexes. */
1202
+ function preparedTransactions(prepared) {
1203
+ const transactions = field(prepared, 'transactions');
1204
+ if (!Array.isArray(transactions))
1205
+ throw new Error('the prepare returned no transactions');
1206
+ return transactions.map((transaction, position) => {
1207
+ const encoded = asString(field(transaction, 'transactionBase64'));
1208
+ if (encoded === undefined)
1209
+ throw new Error('a prepared transaction has no transactionBase64');
1210
+ const bytes = decodeBase64(encoded);
1211
+ if (bytes === null)
1212
+ throw new Error('a prepared transaction is not base64');
1213
+ return [asU64(field(transaction, 'index')) ?? position, bytes];
1214
+ });
1215
+ }
1216
+ function entryFor(list, index, position) {
1217
+ if (!Array.isArray(list))
1218
+ return null;
1219
+ const found = list.find((entry) => asU64(field(entry, 'index')) === index) ?? list[position];
1220
+ return found === undefined ? null : found;
1221
+ }
1222
+ function sortedTokens(tokens) {
1223
+ return [...tokens.keys()]
1224
+ .sort()
1225
+ .map((mint) => ({ mint, amount: tokens.get(mint).toString() }));
1226
+ }
1227
+ /**
1228
+ * The summary of a prepared step for `signer` (the wallet that signs).
1229
+ * `simulator` adds the simulated balance changes.
1230
+ */
1231
+ async function summarizePrepared(prepared, signer, simulator) {
1232
+ const transactions = preparedTransactions(prepared);
1233
+ const entries = [];
1234
+ const programsAll = [];
1235
+ let solTotal = 0n;
1236
+ const tokenTotals = new Map();
1237
+ const simulationErrors = [];
1238
+ const stillRequired = [];
1239
+ for (const [position, [index, bytes]] of transactions.entries()) {
1240
+ let decoded;
1241
+ try {
1242
+ decoded = decodeTransaction(bytes);
1243
+ }
1244
+ catch (error) {
1245
+ throw new Error(`prepared transaction ${index} does not decode: ${error.message}`);
1246
+ }
1247
+ const anatomy = entryFor(field(prepared, 'anatomy'), index, position);
1248
+ const serverSimulation = entryFor(field(prepared, 'simulation'), index, position);
1249
+ const rawLabels = field(anatomy, 'instructions');
1250
+ const labels = Array.isArray(rawLabels) ? rawLabels : [];
1251
+ const programs = transactionPrograms(decoded).map((program) => {
1252
+ if (!programsAll.includes(program))
1253
+ programsAll.push(program);
1254
+ const roles = new Set();
1255
+ for (const label of labels) {
1256
+ if (field(label, 'programId') !== program)
1257
+ continue;
1258
+ const role = asString(field(label, 'role'));
1259
+ if (role === undefined)
1260
+ continue;
1261
+ const kind = asString(field(label, 'kind'));
1262
+ roles.add(kind !== undefined && kind !== role ? `${role}:${kind}` : role);
1263
+ }
1264
+ return { programId: program, roles: [...roles].sort() };
1265
+ });
1266
+ const signers = transactionSigners(decoded);
1267
+ const missing = signers.filter(([, signed]) => !signed).map(([key]) => key);
1268
+ for (const key of missing)
1269
+ if (!stillRequired.includes(key))
1270
+ stillRequired.push(key);
1271
+ // Resolve once for both the displayed write set and simulation. Even if
1272
+ // simulation fails, the write set must not silently omit lookup keys.
1273
+ const lookups = simulator ? await resolveLookups(simulator, decoded) : noLookups();
1274
+ const accountsWritten = writableStaticKeys(decoded);
1275
+ const unresolvedWrites = [];
1276
+ let offset = 0;
1277
+ for (const lookup of decoded.lookups) {
1278
+ for (const addressIndex of lookup.writable) {
1279
+ const key = lookups.keys[offset] ?? null;
1280
+ if (key !== null)
1281
+ accountsWritten.push(key);
1282
+ else
1283
+ unresolvedWrites.push({ table: lookup.table, addressIndex });
1284
+ offset += 1;
1285
+ }
1286
+ }
1287
+ const lookupWritable = decoded.lookups.reduce((sum, lookup) => sum + lookup.writable.length, 0);
1288
+ const dependent = field(serverSimulation, 'simulated') === false;
1289
+ let unsafeInstructions = checkTopLevel(decoded, signer, []).findings;
1290
+ let signerEffect;
1291
+ let simulation;
1292
+ if (simulator) {
1293
+ try {
1294
+ const effect = await simulateSignerEffect(simulator, bytes, decoded, signer, lookups);
1295
+ unsafeInstructions = effect.findings;
1296
+ if (effect.err !== null) {
1297
+ simulationErrors.push({
1298
+ transaction: index,
1299
+ err: effect.err,
1300
+ dependsOnEarlierTransactions: dependent,
1301
+ });
1302
+ }
1303
+ solTotal =
1304
+ effect.solLamports !== null && solTotal !== null ? solTotal + effect.solLamports : null;
1305
+ for (const [mint, delta] of effect.tokens) {
1306
+ tokenTotals.set(mint, (tokenTotals.get(mint) ?? 0n) + delta);
1307
+ }
1308
+ signerEffect = {
1309
+ solLamports: effect.solLamports?.toString() ?? null,
1310
+ sol: effect.solLamports === null ? null : lamportsToSol(effect.solLamports),
1311
+ tokens: sortedTokens(effect.tokens),
1312
+ closedAccountLamportsOut: effect.closedLamportsOut?.toString() ?? null,
1313
+ known: effectKnown(effect),
1314
+ unresolvedLookupTables: effect.unresolved,
1315
+ omittedAccounts: effect.omittedAccounts,
1316
+ innerInstructionsChecked: effect.innerChecked,
1317
+ preStateSlot: effect.slots[0],
1318
+ simulatedSlot: effect.slots[1],
1319
+ };
1320
+ if (effect.omittedAccounts > 0)
1321
+ solTotal = null;
1322
+ simulation = { err: effect.err, logsTail: effect.logsTail };
1323
+ }
1324
+ catch (error) {
1325
+ solTotal = null;
1326
+ signerEffect = { known: false, error: error.message };
1327
+ }
1328
+ }
1329
+ else {
1330
+ solTotal = null;
1331
+ signerEffect = { known: false };
1332
+ }
1333
+ entries.push({
1334
+ index,
1335
+ version: decoded.version === null ? 'legacy' : `v${decoded.version}`,
1336
+ feePayer: decoded.staticKeys[0] ?? null,
1337
+ programs,
1338
+ accountsWritten,
1339
+ accountsWrittenComplete: unresolvedWrites.length === 0,
1340
+ unresolvedWritableAccounts: unresolvedWrites,
1341
+ signatures: {
1342
+ required: signers.map(([key]) => key),
1343
+ present: signers.filter(([, signed]) => signed).map(([key]) => key),
1344
+ missing,
1345
+ },
1346
+ serverSimulation,
1347
+ ...(lookupWritable > 0
1348
+ ? {
1349
+ lookupTables: decoded.lookups.map((lookup) => ({
1350
+ table: lookup.table,
1351
+ writableEntries: lookup.writable.length,
1352
+ readonlyEntries: lookup.readonly.length,
1353
+ })),
1354
+ }
1355
+ : {}),
1356
+ signerEffect,
1357
+ ...(simulation ? { simulation } : {}),
1358
+ unsafeInstructions,
1359
+ });
1360
+ }
1361
+ for (const [mint, delta] of tokenTotals)
1362
+ if (delta === 0n)
1363
+ tokenTotals.delete(mint);
1364
+ const signerBalanceChanges = simulator
1365
+ ? {
1366
+ available: true,
1367
+ complete: solTotal !== null && entries.every((entry) => entry.signerEffect.known),
1368
+ solLamports: solTotal?.toString() ?? null,
1369
+ sol: solTotal === null ? null : lamportsToSol(solTotal),
1370
+ tokens: sortedTokens(tokenTotals),
1371
+ note: 'Simulated one transaction at a time against current chain state; a transaction that depends on an earlier one may not simulate on its own.',
1372
+ }
1373
+ : {
1374
+ available: false,
1375
+ reason: "no RPC URL is configured to simulate the transactions, so the signer's balance changes are unknown",
1376
+ };
1377
+ return {
1378
+ signer,
1379
+ transactions: entries,
1380
+ programsInvoked: programsAll,
1381
+ signerBalanceChanges,
1382
+ simulationErrors,
1383
+ signaturesStillRequired: stillRequired,
1384
+ };
1385
+ }
1386
+
1387
+ /** What the API prepares by default, then the fallback. */
1388
+ const DEFAULT_TRANSACTION_FORMATS = ['v1', 'v0'];
1389
+ /**
1390
+ * The first format in `preferred` (the deployment's order) that the signer
1391
+ * supports, or `null` when they share none.
1392
+ */
1393
+ function negotiateTransactionFormat(signerFormats, preferred = DEFAULT_TRANSACTION_FORMATS) {
1394
+ return preferred.find((format) => signerFormats.includes(format)) ?? null;
1395
+ }
1396
+
1397
+ /**
1398
+ * The exact-bytes checks around a delegated signer.
1399
+ *
1400
+ * Before signing, the prepared transactions must be the ones the intent
1401
+ * committed to. After signing, every message must hash to the intent's
1402
+ * `messageSha256`, every signature slot but the wallet's must be untouched,
1403
+ * and the wallet's slot must hold the wallet's valid signature. A signer
1404
+ * that adds a compute-budget instruction, re-encodes, or signs something
1405
+ * else fails here, before anything is submitted.
1406
+ */
1407
+ function messageSha256(bytes, decoded) {
1408
+ return utils.bytesToHex(sha2.sha256(bytes.subarray(decoded.message.start, decoded.message.end)));
1409
+ }
1410
+ function decoded(base64) {
1411
+ const bytes = decodeBase64(base64);
1412
+ if (bytes === null)
1413
+ return null;
1414
+ try {
1415
+ return { bytes, decoded: decodeTransaction(bytes) };
1416
+ }
1417
+ catch {
1418
+ return null;
1419
+ }
1420
+ }
1421
+ function signerSlot(transaction, wallet) {
1422
+ const required = Math.min(transaction.requiredSignatures, transaction.signatures.length);
1423
+ return transaction.staticKeys.slice(0, required).indexOf(wallet);
1424
+ }
1425
+ function sameBytes(left, right) {
1426
+ return left.length === right.length && left.every((byte, index) => byte === right[index]);
1427
+ }
1428
+ /**
1429
+ * Whether the prepared transactions are what `intent` committed to, in the
1430
+ * format that was asked for, with `wallet` as a required signer of each.
1431
+ * `null` when they are.
1432
+ */
1433
+ function checkPreparedAgainstIntent(input) {
1434
+ const { wallet, intent, transactions, format } = input;
1435
+ if (intent.wallet !== wallet) {
1436
+ return { rule: 'intent_mismatch', reason: `the intent is for wallet ${intent.wallet}, not ${wallet}` };
1437
+ }
1438
+ if (intent.transactionFormat !== format) {
1439
+ return {
1440
+ rule: 'transaction_format',
1441
+ reason: `the intent was prepared as ${intent.transactionFormat}, not the negotiated ${format}`,
1442
+ };
1443
+ }
1444
+ if (transactions.length !== intent.transactions.length) {
1445
+ return {
1446
+ rule: 'intent_mismatch',
1447
+ reason: `the prepare returned ${transactions.length} transactions for an intent of ${intent.transactions.length}`,
1448
+ };
1449
+ }
1450
+ for (const [position, transaction] of transactions.entries()) {
1451
+ const committed = intent.transactions[position];
1452
+ if (transaction.index !== committed.index) {
1453
+ return {
1454
+ rule: 'intent_mismatch',
1455
+ reason: `prepared transaction ${position} has index ${transaction.index}, the intent's has ${committed.index}`,
1456
+ };
1457
+ }
1458
+ const read = decoded(transaction.transactionBase64);
1459
+ if (read === null) {
1460
+ return { rule: 'intent_mismatch', reason: `prepared transaction ${committed.index} does not decode` };
1461
+ }
1462
+ if (messageSha256(read.bytes, read.decoded) !== committed.messageSha256) {
1463
+ return {
1464
+ rule: 'intent_mismatch',
1465
+ reason: `prepared transaction ${committed.index} is not the message the intent committed to`,
1466
+ };
1467
+ }
1468
+ if (read.decoded.version !== (format === 'v1' ? 1 : 0)) {
1469
+ return {
1470
+ rule: 'transaction_format',
1471
+ reason: `prepared transaction ${committed.index} is not a ${format} transaction`,
1472
+ };
1473
+ }
1474
+ if (signerSlot(read.decoded, wallet) < 0) {
1475
+ return {
1476
+ rule: 'intent_mismatch',
1477
+ reason: `the wallet ${wallet} is not a required signer of prepared transaction ${committed.index}`,
1478
+ };
1479
+ }
1480
+ }
1481
+ return null;
1482
+ }
1483
+ /**
1484
+ * Whether `signed` is `prepared` with only the wallet's signature slot
1485
+ * filled, by the wallet. `null` when it is.
1486
+ *
1487
+ * `prepared` is the baseline: pass a copy the signer never had, because a
1488
+ * signer that can change the objects it was given could change the
1489
+ * baseline too.
1490
+ */
1491
+ function checkSignedTransactions(input) {
1492
+ const { wallet, intent, prepared, signed } = input;
1493
+ if (signed.length !== prepared.length) {
1494
+ return {
1495
+ rule: 'signer_modified',
1496
+ reason: `the signer returned ${signed.length} transactions for ${prepared.length}`,
1497
+ };
1498
+ }
1499
+ const walletKey = decodeBase58(wallet);
1500
+ for (const [position, base64] of signed.entries()) {
1501
+ const committed = intent.transactions[position];
1502
+ const index = committed.index;
1503
+ const after = decoded(base64);
1504
+ const before = decoded(prepared[position].transactionBase64);
1505
+ if (after === null || before === null) {
1506
+ return { rule: 'signer_modified', reason: `signed transaction ${index} does not decode` };
1507
+ }
1508
+ if (messageSha256(after.bytes, after.decoded) !== committed.messageSha256) {
1509
+ return {
1510
+ rule: 'signer_modified',
1511
+ reason: `the signer changed the message of transaction ${index}; its hash no longer matches the intent`,
1512
+ };
1513
+ }
1514
+ const slot = signerSlot(after.decoded, wallet);
1515
+ if (slot < 0 || after.decoded.signatures.length !== before.decoded.signatures.length) {
1516
+ return { rule: 'signer_modified', reason: `the signer changed the signers of transaction ${index}` };
1517
+ }
1518
+ for (const [other, signature] of after.decoded.signatures.entries()) {
1519
+ if (other !== slot && !sameBytes(signature, before.decoded.signatures[other])) {
1520
+ return {
1521
+ rule: 'signer_modified',
1522
+ reason: `the signer changed signature ${other} of transaction ${index}, which is not the wallet's`,
1523
+ };
1524
+ }
1525
+ }
1526
+ // Nothing but the wallet's 64 signature bytes may differ, so the same
1527
+ // message and signatures under another encoding are refused too.
1528
+ const at = after.decoded.signatureOffset + 64 * slot;
1529
+ const beforeSlot = signerSlot(before.decoded, wallet);
1530
+ if (after.bytes.length !== before.bytes.length ||
1531
+ before.decoded.signatureOffset + 64 * beforeSlot !== at ||
1532
+ !sameBytes(after.bytes.subarray(0, at), before.bytes.subarray(0, at)) ||
1533
+ !sameBytes(after.bytes.subarray(at + 64), before.bytes.subarray(at + 64))) {
1534
+ return {
1535
+ rule: 'signer_modified',
1536
+ reason: `the signer changed bytes of transaction ${index} outside the wallet's signature`,
1537
+ };
1538
+ }
1539
+ const message = after.bytes.subarray(after.decoded.message.start, after.decoded.message.end);
1540
+ const signature = after.decoded.signatures[slot];
1541
+ let valid = false;
1542
+ try {
1543
+ valid = walletKey !== null && ed25519.ed25519.verify(signature, message, walletKey);
1544
+ }
1545
+ catch {
1546
+ valid = false;
1547
+ }
1548
+ if (!valid) {
1549
+ return {
1550
+ rule: 'signer_signature',
1551
+ reason: `transaction ${index} does not carry a valid signature by ${wallet}`,
1552
+ };
1553
+ }
1554
+ }
1555
+ return null;
1556
+ }
1557
+
1558
+ /**
1559
+ * Thrown by a signer that declines to sign, for example because the wallet
1560
+ * provider's own policy refused the transaction. The job ends `refused`
1561
+ * with this rule instead of being retried.
1562
+ */
1563
+ const REFUSAL_BRAND = Symbol.for('@usearete/automations/SignerRefusal');
1564
+ class SignerRefusal extends Error {
1565
+ rule;
1566
+ /** Recognised across entry points: each bundle has its own copy of this class. */
1567
+ [REFUSAL_BRAND] = true;
1568
+ constructor(message, rule = 'provider_policy') {
1569
+ super(message);
1570
+ this.name = 'SignerRefusal';
1571
+ this.rule = rule;
1572
+ }
1573
+ }
1574
+ /**
1575
+ * Whether `error` is a {@link SignerRefusal}, including one thrown by a
1576
+ * signer from another entry point (`@usearete/automations/privy` is a
1577
+ * separate bundle, so `instanceof` would miss it).
1578
+ */
1579
+ function isSignerRefusal(error) {
1580
+ return (error instanceof SignerRefusal ||
1581
+ (typeof error === 'object' && error !== null && error[REFUSAL_BRAND] === true));
1582
+ }
1583
+
1584
+ const UNITS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
1585
+ function durationMs(duration) {
1586
+ if (typeof duration === 'number') {
1587
+ if (!Number.isFinite(duration) || duration < 0)
1588
+ throw new RangeError(`invalid duration ${duration}`);
1589
+ return duration;
1590
+ }
1591
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(duration);
1592
+ if (!match)
1593
+ throw new RangeError(`invalid duration '${duration}'; use for example 30s, 10m, 24h or 7d`);
1594
+ return Number(match[1]) * UNITS[match[2]];
1595
+ }
1596
+ const systemClock = {
1597
+ now: () => Date.now(),
1598
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1599
+ };
1600
+ function pad(value, length = 2) {
1601
+ return String(value).padStart(length, '0');
1602
+ }
1603
+ /** The ISO 8601 week of a UTC date, as `2026-W43`. */
1604
+ function isoWeek(date) {
1605
+ const day = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
1606
+ const weekday = day.getUTCDay() || 7;
1607
+ day.setUTCDate(day.getUTCDate() + 4 - weekday);
1608
+ const yearStart = Date.UTC(day.getUTCFullYear(), 0, 1);
1609
+ const week = Math.ceil(((day.getTime() - yearStart) / 86_400_000 + 1) / 7);
1610
+ return `${day.getUTCFullYear()}-W${pad(week)}`;
1611
+ }
1612
+ /**
1613
+ * A deterministic dispatch id for a scheduled run: the same name, period
1614
+ * and time give the same id, so a cron that fires twice in one period is
1615
+ * deduplicated. Times are UTC.
1616
+ *
1617
+ * `scheduleSlot('weekly-buy', 'week')` → `weekly-buy:2026-W43`.
1618
+ */
1619
+ function scheduleSlot(name, period, at = Date.now()) {
1620
+ const date = typeof at === 'number' ? new Date(at) : at;
1621
+ const day = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
1622
+ switch (period) {
1623
+ case 'minute':
1624
+ return `${name}:${day}T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
1625
+ case 'hour':
1626
+ return `${name}:${day}T${pad(date.getUTCHours())}`;
1627
+ case 'day':
1628
+ return `${name}:${day}`;
1629
+ case 'week':
1630
+ return `${name}:${isoWeek(date)}`;
1631
+ case 'month':
1632
+ return `${name}:${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}`;
1633
+ }
1634
+ }
1635
+
1636
+ /**
1637
+ * Consent: what a user signs to let automations run steps for their wallet.
1638
+ *
1639
+ * The proof is a human-readable, domain-bound message in the style of
1640
+ * Sign-In-With-Solana. It is built from the grant's fields, so the backend
1641
+ * rebuilds it from the fields it is given and checks the wallet's signature
1642
+ * over that, rather than parsing a message. Raising a limit or changing a
1643
+ * setting needs a new signature; revoking does not.
1644
+ *
1645
+ * The signature is an accountability and anti-bug record. It does not
1646
+ * protect against a compromised backend; the wallet provider's policy and
1647
+ * the protocol's on-chain rules do.
1648
+ */
1649
+ /** The asset key for SOL (wrapped SOL included), in lamports. */
1650
+ const SOL = 'SOL';
1651
+ const CONSENT_PROOF_VERSION = 'arete.automation-consent/v1';
1652
+ class ConsentError extends Error {
1653
+ code;
1654
+ constructor(code, message) {
1655
+ super(message);
1656
+ this.name = 'ConsentError';
1657
+ this.code = code;
1658
+ }
1659
+ }
1660
+ function singleLine(name, value) {
1661
+ if (typeof value !== 'string' || value.length === 0 || /[\r\n]/.test(value)) {
1662
+ throw new ConsentError('invalid_fields', `${name} must be a non-empty single-line string`);
1663
+ }
1664
+ return value;
1665
+ }
1666
+ function amountText(name, value) {
1667
+ let amount;
1668
+ if (typeof value === 'bigint' && value >= 0n)
1669
+ amount = value;
1670
+ else if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
1671
+ amount = BigInt(value);
1672
+ else if (typeof value === 'string' && /^\d+$/.test(value))
1673
+ amount = BigInt(value);
1674
+ if (amount === undefined)
1675
+ throw new ConsentError('invalid_fields', `${name} must be a non-negative integer`);
1676
+ return amount.toString();
1677
+ }
1678
+ function asset(name, key) {
1679
+ if (key !== SOL && !isPubkey(key)) {
1680
+ throw new ConsentError('invalid_fields', `${name}: '${key}' is neither SOL nor a mint address`);
1681
+ }
1682
+ // Outflows count wrapped SOL as SOL, so a limit keyed by the native mint
1683
+ // would be shown to the user and never applied.
1684
+ if (isNativeMint(key)) {
1685
+ throw new ConsentError('invalid_fields', `${name}: wrapped SOL counts as SOL; set the limit on SOL instead of ${key}`);
1686
+ }
1687
+ return key;
1688
+ }
1689
+ function sortedRecord(entries) {
1690
+ return Object.fromEntries(entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
1691
+ }
1692
+ /** Validate and normalize consent fields. Throws {@link ConsentError}. */
1693
+ function normalizeConsent(fields) {
1694
+ const wallet = singleLine('wallet', fields.wallet);
1695
+ if (!isPubkey(wallet))
1696
+ throw new ConsentError('invalid_fields', 'wallet must be a public key');
1697
+ const deploymentId = singleLine('deploymentId', fields.deploymentId);
1698
+ const instanceId = fields.instanceId === undefined || fields.instanceId === null ? null : singleLine('instanceId', fields.instanceId);
1699
+ if (!Array.isArray(fields.steps) || fields.steps.length === 0) {
1700
+ throw new ConsentError('invalid_fields', 'steps must list at least one step');
1701
+ }
1702
+ const steps = [...new Set(fields.steps.map((step, index) => singleLine(`steps[${index}]`, step)))].sort();
1703
+ if (steps.some((step) => /[,\s]/.test(step))) {
1704
+ throw new ConsentError('invalid_fields', 'step names cannot contain commas or spaces');
1705
+ }
1706
+ const perExecution = sortedRecord(Object.entries(fields.limits?.perExecution ?? {}).map(([key, value]) => [
1707
+ asset('limits.perExecution', key),
1708
+ amountText(`limits.perExecution.${key}`, value),
1709
+ ]));
1710
+ const perWindow = sortedRecord(Object.entries(fields.limits?.perWindow ?? {}).map(([key, value]) => {
1711
+ const windowMs = durationMs(value.window);
1712
+ if (windowMs <= 0 || !Number.isSafeInteger(windowMs)) {
1713
+ throw new ConsentError('invalid_fields', `limits.perWindow.${key}.window must be a positive duration`);
1714
+ }
1715
+ return [asset('limits.perWindow', key), { amount: amountText(`limits.perWindow.${key}.amount`, value.amount), windowMs }];
1716
+ }));
1717
+ // A deep snapshot: the grant stores exactly what the message states, even
1718
+ // if the caller changes its objects while the grant is verified.
1719
+ let params;
1720
+ try {
1721
+ params = JSON.parse(sdk.canonicalizeJson(fields.params ?? {}));
1722
+ }
1723
+ catch (error) {
1724
+ throw new ConsentError('invalid_fields', `params must be JSON: ${error.message}`);
1725
+ }
1726
+ const expiresAt = fields.expiresAt instanceof Date ? fields.expiresAt.getTime() : fields.expiresAt;
1727
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) {
1728
+ throw new ConsentError('invalid_fields', 'expiresAt must be a date');
1729
+ }
1730
+ return { wallet, deploymentId, instanceId, steps, limits: { perExecution, perWindow }, params, expiresAt };
1731
+ }
1732
+ /** A window as the message shows it: the largest whole unit. */
1733
+ function windowText(windowMs) {
1734
+ for (const [unit, size] of [
1735
+ ['d', 86_400_000],
1736
+ ['h', 3_600_000],
1737
+ ['m', 60_000],
1738
+ ['s', 1000],
1739
+ ]) {
1740
+ if (windowMs % size === 0)
1741
+ return `${windowMs / size}${unit}`;
1742
+ }
1743
+ return `${windowMs}ms`;
1744
+ }
1745
+ function amountLine(key, amount) {
1746
+ return key === SOL ? `${amount} lamports of SOL` : `${amount} base units of ${key}`;
1747
+ }
1748
+ /**
1749
+ * The message a wallet signs to grant consent. Lines are joined by `\n`:
1750
+ *
1751
+ * ```text
1752
+ * <domain> asks to run protocol steps for your wallet without asking each time:
1753
+ * <wallet>
1754
+ *
1755
+ * Deployment: <deployment id>
1756
+ * Instance: <instance id, or "any">
1757
+ * Steps: <steps, sorted, comma-separated>
1758
+ * Per run: <amount> lamports of SOL | <amount> base units of <mint> (one line per asset, sorted)
1759
+ * Per <window>: <amount> ... (one line per asset, sorted)
1760
+ * Setting <key as a JSON string>: <RFC 8785 JSON of the value> (one line per key, sorted)
1761
+ * Expires At: <ISO 8601 UTC>
1762
+ *
1763
+ * Nothing else may leave your wallet through these steps. You can revoke this at any time without signing.
1764
+ *
1765
+ * Version: arete.automation-consent/v1
1766
+ * Nonce: <nonce>
1767
+ * Issued At: <ISO 8601 UTC>
1768
+ * ```
1769
+ *
1770
+ * Without per-run limits the message says that nothing may leave the wallet.
1771
+ */
1772
+ function consentMessage(domain, consent, nonce, issuedAt) {
1773
+ if (!/^[A-Za-z0-9.-]+(:\d+)?$/.test(domain)) {
1774
+ throw new ConsentError('no_domain', `'${domain}' is not a host name`);
1775
+ }
1776
+ if (!/^[1-9A-HJ-NP-Za-km-z]{16,64}$/.test(nonce)) {
1777
+ throw new ConsentError('invalid_fields', 'the nonce must be 16 to 64 base58 characters');
1778
+ }
1779
+ const limits = consent.limits;
1780
+ const perRun = Object.entries(limits.perExecution);
1781
+ const lines = [
1782
+ `${domain} asks to run protocol steps for your wallet without asking each time:`,
1783
+ consent.wallet,
1784
+ '',
1785
+ `Deployment: ${consent.deploymentId}`,
1786
+ `Instance: ${consent.instanceId ?? 'any'}`,
1787
+ `Steps: ${consent.steps.join(', ')}`,
1788
+ ...(perRun.length === 0
1789
+ ? ['Per run: nothing may leave your wallet']
1790
+ : perRun.map(([key, amount]) => `Per run: ${amountLine(key, amount)}`)),
1791
+ ...Object.entries(limits.perWindow).map(([key, { amount, windowMs }]) => `Per ${windowText(windowMs)}: ${amountLine(key, amount)}`),
1792
+ ...Object.keys(consent.params)
1793
+ .sort()
1794
+ // Keys are JSON strings, so no key can end its line and forge another.
1795
+ .map((key) => `Setting ${JSON.stringify(key)}: ${sdk.canonicalizeJson(consent.params[key])}`),
1796
+ `Expires At: ${new Date(consent.expiresAt).toISOString()}`,
1797
+ '',
1798
+ 'Nothing else may leave your wallet through these steps. You can revoke this at any time without signing.',
1799
+ '',
1800
+ `Version: ${CONSENT_PROOF_VERSION}`,
1801
+ `Nonce: ${nonce}`,
1802
+ `Issued At: ${new Date(issuedAt).toISOString()}`,
1803
+ ];
1804
+ return lines.join('\n');
1805
+ }
1806
+ function parse(value) {
1807
+ return typeof value === 'string' && /^-?\d+$/.test(value) ? BigInt(value) : null;
1808
+ }
1809
+ /**
1810
+ * What a prepared step moves out of the wallet, per asset, summed over its
1811
+ * transactions: SOL as the policy measures it (wrapped SOL and token
1812
+ * accounts closed to someone else included), and every mint that leaves.
1813
+ * The summary must be complete; the policy refuses one that is not.
1814
+ */
1815
+ function stepOutflows(summary) {
1816
+ const out = new Map();
1817
+ for (const { amounts } of transactionOutflows(summary)) {
1818
+ for (const [asset, amount] of amounts)
1819
+ out.set(asset, (out.get(asset) ?? 0n) + amount);
1820
+ }
1821
+ return out;
1822
+ }
1823
+ /** {@link stepOutflows}, per transaction, so a partly landed intent can be accounted exactly. */
1824
+ function transactionOutflows(summary) {
1825
+ return summary.transactions.map((transaction) => {
1826
+ const out = new Map();
1827
+ const add = (key, amount) => {
1828
+ if (amount > 0n)
1829
+ out.set(key, (out.get(key) ?? 0n) + amount);
1830
+ };
1831
+ const effect = transaction.signerEffect;
1832
+ const tokens = effect.tokens ?? [];
1833
+ const lamports = parse(effect.solLamports) ?? 0n;
1834
+ const wrapped = tokens
1835
+ .filter((token) => isNativeMint(token.mint))
1836
+ .reduce((sum, token) => sum + (parse(token.amount) ?? 0n), 0n);
1837
+ const closed = parse(effect.closedAccountLamportsOut) ?? 0n;
1838
+ add(SOL, closed - lamports - wrapped);
1839
+ for (const token of tokens) {
1840
+ if (!isNativeMint(token.mint))
1841
+ add(token.mint, -(parse(token.amount) ?? 0n));
1842
+ }
1843
+ return { index: transaction.index, amounts: out };
1844
+ });
1845
+ }
1846
+
1847
+ /**
1848
+ * View sources: watch a live view and dispatch a trigger when a condition
1849
+ * becomes true.
1850
+ *
1851
+ * Exactly-once comes from the store, not from ids derived from the view's
1852
+ * data (list and state views carry no cursor or version). Each source key
1853
+ * keeps its last stable value; a change is written by compare-and-set and,
1854
+ * when `changed(previous, next)` holds, its delivery
1855
+ * (`view:${source}:${key}:${generation}`) is accepted in the same step. So:
1856
+ *
1857
+ * - replicas watching one view produce one delivery per transition;
1858
+ * - after a restart the first snapshot is compared with the stored value,
1859
+ * so a crossing during downtime fires once and nothing fires twice;
1860
+ * - every transition has its own generation, so A → B → A → B fires three
1861
+ * times.
1862
+ *
1863
+ * A value that changed and changed back while no replica was watching is
1864
+ * not seen: list and state views hold current state, not history.
1865
+ *
1866
+ * Replicas whose views lag each other can still disagree about the order of
1867
+ * values: one stores B while another, behind, still sees A. Give the source
1868
+ * `observedAt` (for example the slot a row was updated at) so an older
1869
+ * observation never replaces a newer one, or run it on one replica
1870
+ * (`exclusive`).
1871
+ *
1872
+ * A trigger means "go and look", not the truth: handlers read live state
1873
+ * before returning steps, and every step is checked again before signing.
1874
+ */
1875
+ function observedOrder(value) {
1876
+ if (value === null || value === undefined)
1877
+ return null;
1878
+ if (typeof value === 'number' && !Number.isSafeInteger(value))
1879
+ throw new RangeError('observedAt must be an integer');
1880
+ return BigInt(value);
1881
+ }
1882
+ const AGGREGATE_KEY = 'all';
1883
+ /** The stable hash of a key no value has settled on yet. */
1884
+ const NO_VALUE = '';
1885
+ function common(options) {
1886
+ if (!options.name)
1887
+ throw new TypeError('a view source needs a name');
1888
+ return {
1889
+ name: options.name,
1890
+ changed: options.changed ?? (() => true),
1891
+ stableForMs: options.stableFor === undefined ? 0 : durationMs(options.stableFor),
1892
+ };
1893
+ }
1894
+ /** A source with one signal for the whole list view, under the key `all`. */
1895
+ function fromView(view, options) {
1896
+ const { derive, observedAt, key } = options;
1897
+ return {
1898
+ kind: 'list',
1899
+ view,
1900
+ rowKey: key,
1901
+ ...common(options),
1902
+ signals: (rows) => [[AGGREGATE_KEY, derive(rows), observedOrder(observedAt?.(rows))]],
1903
+ rowSignal: null,
1904
+ };
1905
+ }
1906
+ /** A source with one signal per row of a list view, keyed by `key(row)`. */
1907
+ function fromViewRows(view, options) {
1908
+ const { key, derive, filter, observedAt } = options;
1909
+ const rowSignal = (row) => filter && !filter(row) ? undefined : [key(row), derive(row), observedOrder(observedAt?.(row))];
1910
+ return {
1911
+ kind: 'list',
1912
+ view,
1913
+ rowKey: key,
1914
+ ...common(options),
1915
+ signals: (rows) => rows.map(rowSignal).filter((entry) => entry !== undefined),
1916
+ rowSignal,
1917
+ };
1918
+ }
1919
+ /** A source over an append view: one trigger per row, resumed from the stored cursor. */
1920
+ function fromAppendView(view, options) {
1921
+ if (!options.name)
1922
+ throw new TypeError('a view source needs a name');
1923
+ return { kind: 'append', name: options.name, view, ...(options.filter ? { filter: options.filter } : {}) };
1924
+ }
1925
+ /**
1926
+ * Compare a key's new value with its stored state and move it on by
1927
+ * compare-and-set, accepting a delivery when the change is a trigger.
1928
+ * Returns `pending` while `stableFor` has not elapsed; evaluate again then.
1929
+ */
1930
+ async function evaluateSignal(store, clock, source, key, value, observed = null) {
1931
+ const json = value === undefined ? null : value;
1932
+ const hash = sdk.canonicalJsonSha256(json);
1933
+ for (let attempt = 0; attempt < 5; attempt += 1) {
1934
+ const now = clock.now();
1935
+ const state = await store.sources.get(source.name, key);
1936
+ const stored = state?.observed === null || state?.observed === undefined ? null : BigInt(state.observed);
1937
+ // A lagging reader must not move the key back to an older value.
1938
+ if (observed !== null && stored !== null && observed < stored)
1939
+ return 'stale';
1940
+ const order = observed === null ? stored : stored === null || observed > stored ? observed : stored;
1941
+ const expectedVersion = state?.version ?? null;
1942
+ const write = (next, deliver) => store.sources.advance({
1943
+ source: source.name,
1944
+ key,
1945
+ expectedVersion,
1946
+ next: { ...next, observed: order === null ? null : order.toString() },
1947
+ now,
1948
+ ...(deliver ? { deliver } : {}),
1949
+ });
1950
+ if (state && state.hash === hash) {
1951
+ if (state.pending === null)
1952
+ return 'same';
1953
+ // The value went back to the stable one before the new one held.
1954
+ if (await write({ hash, value: state.value, pending: null }))
1955
+ return 'same';
1956
+ continue;
1957
+ }
1958
+ if (source.stableForMs > 0) {
1959
+ const pending = state?.pending;
1960
+ if (!pending || pending.hash !== hash) {
1961
+ const stable = state ? { hash: state.hash, value: state.value } : { hash: NO_VALUE, value: null };
1962
+ if (await write({ ...stable, pending: { hash, value: json, since: now } }))
1963
+ return 'pending';
1964
+ continue;
1965
+ }
1966
+ if (now - pending.since < source.stableForMs)
1967
+ return 'pending';
1968
+ }
1969
+ const previous = state && state.hash !== NO_VALUE ? state.value : undefined;
1970
+ const fire = source.changed(previous, value);
1971
+ const payload = { source: source.name, key, value, ...(previous !== undefined ? { previous } : {}) };
1972
+ if (await write({ hash, value: json, pending: null }, fire ? { name: source.name, payload } : undefined)) {
1973
+ return fire ? 'delivered' : 'changed';
1974
+ }
1975
+ }
1976
+ return 'contended';
1977
+ }
1978
+
1979
+ /**
1980
+ * Automations as a definition: the handlers and view sources, apart from the
1981
+ * client, signer, store and secrets that run them. The same definition runs
1982
+ * under `a4 dev` (against the project's dev environment) and in production
1983
+ * (`automations.use(definition)`), so moving from one to the other changes
1984
+ * configuration only.
1985
+ */
1986
+ /**
1987
+ * Define automations for a protocol: handlers for its events and dispatched
1988
+ * triggers, and view sources. Export the definition as the module's default
1989
+ * for `a4 dev` (`[dev.automations] entry` in arete.toml).
1990
+ */
1991
+ function defineAutomations(protocol, setup, options = {}) {
1992
+ return { kind: 'automations', protocol, setup, ...(options.policy ? { policy: options.policy } : {}) };
1993
+ }
1994
+ /** Whether `value` is a definition, from this or another copy of the package. */
1995
+ function isAutomationsDefinition(value) {
1996
+ return (typeof value === 'object' &&
1997
+ value !== null &&
1998
+ value.kind === 'automations' &&
1999
+ typeof value.setup === 'function' &&
2000
+ typeof value.protocol === 'object');
2001
+ }
2002
+ /** Connects with the SDK's defaults: `ARETE_API_KEY` or the `a4` login. */
2003
+ const connectStack = (stack) => sdk.Arete.connect(stack);
2004
+
2005
+ const ACTIVE_STEP_STATES = [
2006
+ 'running',
2007
+ 'signed',
2008
+ 'submitted',
2009
+ 'submitted_unknown',
2010
+ ];
2011
+ const TERMINAL_STEP_STATES = [
2012
+ 'completed',
2013
+ 'chain_failed',
2014
+ 'expired',
2015
+ 'skipped',
2016
+ 'rejected',
2017
+ 'refused',
2018
+ 'failed',
2019
+ ];
2020
+ const TERMINAL_DELIVERY_STATES = ['done', 'failed'];
2021
+ /**
2022
+ * A step job's id: `step:${deliveryId}:${step}:${wallet}`, then
2023
+ * `:${instance}` for a step on an instance, so one trigger can run the same
2024
+ * step for one wallet on several instances. The `step:` namespace and the
2025
+ * encoded components keep step job ids apart from delivery ids (which start
2026
+ * `webhook:`, `stream:`, `dispatch:` or `view:`) and from each other.
2027
+ */
2028
+ function stepJobId(deliveryId, request) {
2029
+ const base = `step:${encodeURIComponent(deliveryId)}:${encodeURIComponent(request.step)}:${request.wallet}`;
2030
+ return request.instance === undefined ? base : `${base}:${encodeURIComponent(request.instance)}`;
2031
+ }
2032
+
2033
+ /** Thrown when another worker took the job over; the current run stops quietly. */
2034
+ class LostLease extends Error {
2035
+ }
2036
+ const ACTIVE_RECONCILE_CAP_MS = 5 * 60_000;
2037
+ function randomId() {
2038
+ const crypto = globalThis.crypto;
2039
+ return crypto?.randomUUID?.() ?? `worker-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
2040
+ }
2041
+ function isRecord(value) {
2042
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
2043
+ }
2044
+ function message(error) {
2045
+ return error instanceof Error ? error.message : String(error);
2046
+ }
2047
+ /** Where a client's deployment runs: `dev` when `a4 dev` generated it, else `mainnet`. */
2048
+ function clientCluster(client) {
2049
+ // Read structurally: SDKs before the field carry it untyped.
2050
+ const deployment = client.definition?.deployment;
2051
+ return deployment?.environment === 'dev' ? 'dev' : 'mainnet';
2052
+ }
2053
+ function createAutomations(options) {
2054
+ const { client, signer, policy, simulator, store } = options;
2055
+ const cluster = options.cluster ?? clientCluster(client);
2056
+ const clock = options.clock ?? systemClock;
2057
+ const worker = options.worker ?? randomId();
2058
+ const leaseMs = durationMs(options.lease ?? 60_000);
2059
+ const maxAttempts = options.maxAttempts ?? 5;
2060
+ const trackForMs = durationMs(options.trackFor ?? 30_000);
2061
+ const trackIntervalMs = durationMs(options.trackInterval ?? 2_000);
2062
+ const reconcileMs = durationMs(options.reconcileInterval ?? 10_000);
2063
+ const formats = options.transactionFormats ?? DEFAULT_TRANSACTION_FORMATS;
2064
+ const handlers = new Map();
2065
+ const proofTtlMs = durationMs(options.consent?.proofTtl ?? 600_000);
2066
+ const report = (error, context) => {
2067
+ try {
2068
+ options.onError?.(error, context);
2069
+ }
2070
+ catch {
2071
+ // A failing error hook must not stop the worker.
2072
+ }
2073
+ };
2074
+ const backoff = (attempts) => Math.min(reconcileMs * 2 ** Math.max(0, attempts - 1), ACTIVE_RECONCILE_CAP_MS);
2075
+ const leaseUntil = () => ({ until: clock.now() + leaseMs });
2076
+ // -------------------------------------------------------------------------
2077
+ // Accepting triggers
2078
+ // -------------------------------------------------------------------------
2079
+ // One store serves one deployment; claims and delivery ids are not scoped
2080
+ // by deployment, so the store refuses a second one.
2081
+ let binding = null;
2082
+ const bound = () => {
2083
+ binding ??= store.bindDeployment(client.deploymentId).catch((error) => {
2084
+ binding = null;
2085
+ throw error;
2086
+ });
2087
+ return binding;
2088
+ };
2089
+ const accept = async (delivery) => {
2090
+ await bound();
2091
+ return store.deliveries.accept({ ...delivery, receivedAt: clock.now() }, clock.now());
2092
+ };
2093
+ const acceptWebhook = async (body, headers, webhook) => {
2094
+ let verified;
2095
+ try {
2096
+ verified = sdk.verifyWebhook(webhook.secret, body, headers, {
2097
+ ...(webhook.toleranceSeconds !== undefined ? { toleranceSeconds: webhook.toleranceSeconds } : {}),
2098
+ now: Math.floor(clock.now() / 1000),
2099
+ });
2100
+ }
2101
+ catch (error) {
2102
+ if (error instanceof sdk.WebhookVerificationError) {
2103
+ const status = error.code === 'invalid-signature' || error.code === 'timestamp-out-of-tolerance' ? 401 : 400;
2104
+ return { status, body: { error: error.code } };
2105
+ }
2106
+ throw error;
2107
+ }
2108
+ const payload = verified.payload;
2109
+ if (!isRecord(payload) ||
2110
+ typeof payload.event !== 'string' ||
2111
+ typeof payload.instanceId !== 'string' ||
2112
+ (typeof payload.id !== 'string' && typeof payload.id !== 'number')) {
2113
+ return { status: 400, body: { error: 'invalid-payload' } };
2114
+ }
2115
+ if (payload.deploymentId !== client.deploymentId) {
2116
+ return { status: 200, body: { accepted: false, ignored: 'other-deployment' } };
2117
+ }
2118
+ if (!handlers.has(payload.event)) {
2119
+ return { status: 200, body: { accepted: false, ignored: 'no-handler' } };
2120
+ }
2121
+ const event = {
2122
+ id: String(payload.id),
2123
+ event: payload.event,
2124
+ deploymentId: payload.deploymentId,
2125
+ instanceId: payload.instanceId,
2126
+ participantId: typeof payload.participantId === 'string' ? payload.participantId : null,
2127
+ state: typeof payload.state === 'string' ? payload.state : null,
2128
+ occurredAt: typeof payload.occurredAt === 'string' ? payload.occurredAt : '',
2129
+ source: 'webhook',
2130
+ };
2131
+ // The id comes from the signed body: the delivery-id header is not covered
2132
+ // by the signature, so a replay under a new header id would otherwise
2133
+ // run again.
2134
+ const id = `webhook:${encodeURIComponent(String(payload.deploymentId))}:${encodeURIComponent(String(payload.id))}`;
2135
+ const accepted = await accept({ id, name: payload.event, source: 'webhook', payload: event });
2136
+ return { status: 200, body: { accepted, duplicate: !accepted } };
2137
+ };
2138
+ // -------------------------------------------------------------------------
2139
+ // Step jobs
2140
+ // -------------------------------------------------------------------------
2141
+ /**
2142
+ * The ledger after a chain failure, per transaction: one that landed
2143
+ * successfully keeps everything it moved; one that failed counts the fee
2144
+ * it was charged (all its SOL when the fee cannot be read); one that never
2145
+ * landed counts nothing.
2146
+ */
2147
+ const failedLedger = async (job, state) => {
2148
+ const saved = job.progress.outflows;
2149
+ if (!saved) {
2150
+ // Signed before outflows were saved per transaction: if any transaction
2151
+ // landed, keep every entry; otherwise keep the fees, or all the SOL.
2152
+ if (state.transactions.some((transaction) => transaction.signature !== null && !transaction.error))
2153
+ return {};
2154
+ const fees = await chargedFees(state.transactions.map((transaction) => transaction.signature));
2155
+ return fees === null
2156
+ ? { void: true, keepAssets: [SOL] }
2157
+ : {
2158
+ void: true,
2159
+ record: fees > 0n ? [{ wallet: job.request.wallet, deploymentId: job.deploymentId, asset: SOL, amount: fees.toString() }] : [],
2160
+ };
2161
+ }
2162
+ const spent = new Map();
2163
+ const add = (asset, amount) => {
2164
+ if (amount > 0n)
2165
+ spent.set(asset, (spent.get(asset) ?? 0n) + amount);
2166
+ };
2167
+ for (const transaction of state.transactions) {
2168
+ const amounts = saved.find((entry) => entry.index === transaction.index)?.amounts ?? {};
2169
+ if (transaction.signature === null)
2170
+ continue;
2171
+ if (!transaction.error) {
2172
+ for (const [asset, amount] of Object.entries(amounts))
2173
+ add(asset, BigInt(amount));
2174
+ continue;
2175
+ }
2176
+ const fee = await chargedFees([transaction.signature]);
2177
+ add(SOL, fee ?? BigInt(amounts[SOL] ?? '0'));
2178
+ }
2179
+ // The rebuilt entries keep the signing time, so a late failure check
2180
+ // cannot restart the window for spending that is already ageing out.
2181
+ const recordedAt = job.progress.signedAt;
2182
+ return {
2183
+ void: true,
2184
+ record: [...spent].map(([asset, amount]) => ({
2185
+ wallet: job.request.wallet,
2186
+ deploymentId: job.deploymentId,
2187
+ asset,
2188
+ amount: amount.toString(),
2189
+ ...(recordedAt !== undefined ? { recordedAt } : {}),
2190
+ })),
2191
+ };
2192
+ };
2193
+ /** The fees the landed transactions paid, from the chain; `null` when one cannot be read. */
2194
+ const chargedFees = async (signatures) => {
2195
+ let total = 0n;
2196
+ for (const signature of signatures) {
2197
+ if (signature === null)
2198
+ continue; // never landed, so never charged
2199
+ try {
2200
+ const transaction = await simulator.call('getTransaction', [
2201
+ signature,
2202
+ { encoding: 'json', commitment: 'confirmed', maxSupportedTransactionVersion: 0 },
2203
+ ]);
2204
+ const fee = transaction?.meta?.fee;
2205
+ if (typeof fee !== 'number' || !Number.isSafeInteger(fee) || fee < 0)
2206
+ return null;
2207
+ total += BigInt(fee);
2208
+ }
2209
+ catch (error) {
2210
+ report(error, { phase: 'fees' });
2211
+ return null;
2212
+ }
2213
+ }
2214
+ return total;
2215
+ };
2216
+ const outcomeOf = (job, state, outcome) => ({
2217
+ jobId: job.id,
2218
+ deliveryId: job.deliveryId,
2219
+ deploymentId: job.deploymentId,
2220
+ step: job.request.step,
2221
+ wallet: job.request.wallet,
2222
+ instanceId: job.request.instance ?? null,
2223
+ state,
2224
+ code: outcome.code,
2225
+ ...(outcome.reason !== undefined ? { reason: outcome.reason } : {}),
2226
+ ...(outcome.detail !== undefined ? { detail: outcome.detail } : {}),
2227
+ executionId: job.progress.intent?.executionId ?? null,
2228
+ signatures: job.progress.signatures ?? [],
2229
+ });
2230
+ const notify = async (outcome) => {
2231
+ const hooks = [options.onOutcome];
2232
+ if (outcome.state === 'refused')
2233
+ hooks.push(options.onRefused);
2234
+ if (outcome.state === 'rejected')
2235
+ hooks.push(options.onRejected);
2236
+ for (const hook of hooks) {
2237
+ if (!hook)
2238
+ continue;
2239
+ try {
2240
+ await hook(outcome);
2241
+ }
2242
+ catch (error) {
2243
+ report(error, { jobId: outcome.jobId, phase: 'hook' });
2244
+ }
2245
+ }
2246
+ };
2247
+ /**
2248
+ * The runners for one claim. `worker` here is the claim's own holder
2249
+ * token, so a lapsed claim's late writes can never touch the job after
2250
+ * another claim (even in this process) took it over.
2251
+ */
2252
+ const forClaim = (worker) => {
2253
+ /** Move `job` to a final state and release it. */
2254
+ const finish = async (job, state, outcome, progress = undefined, ledger = { void: true }) => {
2255
+ // Spending counts against a window only when the intent landed.
2256
+ const landed = state === 'completed';
2257
+ const voiding = ledger;
2258
+ const moved = await store.jobs.transition(job.id, {
2259
+ worker,
2260
+ from: job.state,
2261
+ to: state,
2262
+ now: clock.now(),
2263
+ outcome,
2264
+ ...(progress ? { progress } : {}),
2265
+ ...(landed ? {} : { ledger: voiding }),
2266
+ });
2267
+ if (!moved)
2268
+ throw new LostLease();
2269
+ await notify(outcomeOf(progress ? { ...job, progress: { ...job.progress, ...progress } } : job, state, outcome));
2270
+ };
2271
+ /** Move `job` to another active state, keeping the lease. */
2272
+ const advance = async (job, state, progress, ledger) => {
2273
+ const moved = await store.jobs.transition(job.id, {
2274
+ worker,
2275
+ from: job.state,
2276
+ to: state,
2277
+ now: clock.now(),
2278
+ keepLease: leaseUntil(),
2279
+ ...(progress ? { progress } : {}),
2280
+ ...(ledger ? { ledger } : {}),
2281
+ });
2282
+ if (!moved)
2283
+ throw new LostLease();
2284
+ return { ...job, state, progress: { ...job.progress, ...progress } };
2285
+ };
2286
+ /** Release `job` in its current state, to be claimed again later. */
2287
+ const park = async (job, delayMs, attempts = job.attempts) => {
2288
+ const moved = await store.jobs.transition(job.id, {
2289
+ worker,
2290
+ from: job.state,
2291
+ to: job.state,
2292
+ now: clock.now(),
2293
+ notBefore: clock.now() + delayMs,
2294
+ attempts,
2295
+ });
2296
+ if (!moved)
2297
+ throw new LostLease();
2298
+ };
2299
+ const refuse = (job, refusal) => finish(job, 'refused', { code: refusal.rule, reason: refusal.reason });
2300
+ /** The active grant for a job; instance-scoped grants end with their instance. */
2301
+ const consentFor = async (job) => {
2302
+ const grant = await store.consent.find({
2303
+ wallet: job.request.wallet,
2304
+ deploymentId: job.deploymentId,
2305
+ step: job.request.step,
2306
+ instanceId: job.request.instance ?? null,
2307
+ now: clock.now(),
2308
+ });
2309
+ if (grant?.instanceId && client.instances) {
2310
+ const instance = await client.instances.get(grant.instanceId);
2311
+ if (instance.terminal) {
2312
+ await store.consent.revoke({
2313
+ wallet: grant.wallet,
2314
+ deploymentId: grant.deploymentId,
2315
+ instanceId: grant.instanceId,
2316
+ now: clock.now(),
2317
+ });
2318
+ return consentFor(job);
2319
+ }
2320
+ }
2321
+ return grant;
2322
+ };
2323
+ /** The grant's limits against what this step moves, and the window spent so far. */
2324
+ const checkLimits = async (job, grant, outflows) => {
2325
+ for (const [asset, amount] of outflows) {
2326
+ const limit = grant.limits.perExecution[asset];
2327
+ if (limit === undefined) {
2328
+ return {
2329
+ rule: 'consent_limit',
2330
+ reason: `the step moves ${amount} of ${asset} out of the wallet, and the consent sets no per-run limit for it`,
2331
+ };
2332
+ }
2333
+ if (amount > BigInt(limit)) {
2334
+ return {
2335
+ rule: 'consent_limit',
2336
+ reason: `the step moves ${amount} of ${asset} out of the wallet, over the consent's per-run limit of ${limit}`,
2337
+ };
2338
+ }
2339
+ const window = grant.limits.perWindow[asset];
2340
+ if (window) {
2341
+ const spent = await store.ledger.spent({
2342
+ wallet: job.request.wallet,
2343
+ deploymentId: job.deploymentId,
2344
+ asset,
2345
+ since: clock.now() - window.windowMs,
2346
+ });
2347
+ if (spent + amount > BigInt(window.amount)) {
2348
+ return {
2349
+ rule: 'consent_window',
2350
+ reason: `the step moves ${amount} of ${asset}; with ${spent} already moved in the window that exceeds the consent's limit of ${window.amount}`,
2351
+ };
2352
+ }
2353
+ }
2354
+ }
2355
+ return null;
2356
+ };
2357
+ /** Prepare, check, sign and submit a `running` step job. */
2358
+ const attempt = async (job) => {
2359
+ const { request } = job;
2360
+ const wallet = request.wallet;
2361
+ if (!(await consentFor(job))) {
2362
+ return finish(job, 'skipped', { code: 'no_consent', reason: `no active consent for ${request.step}` });
2363
+ }
2364
+ if (request.cooldownMs !== undefined) {
2365
+ const last = await store.jobs.lastCompleted({ wallet, deploymentId: job.deploymentId, step: request.step });
2366
+ if (last !== null && clock.now() - last < request.cooldownMs) {
2367
+ return finish(job, 'skipped', {
2368
+ code: 'cooldown',
2369
+ reason: `${request.step} completed for this wallet ${Math.round((clock.now() - last) / 1000)}s ago`,
2370
+ });
2371
+ }
2372
+ }
2373
+ const target = checkTarget(policy, job.deploymentId, cluster);
2374
+ if (target)
2375
+ return refuse(job, target);
2376
+ if (request.instance !== undefined) {
2377
+ const availability = (await client.allowedSteps({ instance: request.instance, wallet })).find((entry) => entry.step === request.step);
2378
+ if (!availability) {
2379
+ return finish(job, 'rejected', { code: 'step_not_available', reason: `${request.step} is not a step of this instance` });
2380
+ }
2381
+ if (!availability.allowed) {
2382
+ return finish(job, 'rejected', {
2383
+ code: availability.reason ?? 'not_allowed',
2384
+ ...(availability.detail !== undefined ? { detail: availability.detail } : {}),
2385
+ });
2386
+ }
2387
+ }
2388
+ const format = negotiateTransactionFormat(signer.supportedTransactionFormats, formats);
2389
+ if (format === null) {
2390
+ return refuse(job, {
2391
+ rule: 'transaction_format',
2392
+ reason: `the ${signer.kind} signer supports ${signer.supportedTransactionFormats.join(', ')}, the deployment ${formats.join(', ')}`,
2393
+ });
2394
+ }
2395
+ if (!(await signer.canSign(wallet))) {
2396
+ return refuse(job, { rule: 'signer_unavailable', reason: `the ${signer.kind} signer cannot sign for ${wallet}` });
2397
+ }
2398
+ const stepClient = client.steps[request.step];
2399
+ if (!stepClient) {
2400
+ return finish(job, 'rejected', { code: 'step_not_client_callable', reason: `${request.step} cannot be prepared by this client` });
2401
+ }
2402
+ const result = await stepClient.prepare({ ...request.args, wallet, ...(request.instance !== undefined ? { instance: request.instance } : {}) }, { transactionFormat: format, idempotencyKey: `${job.id}:${job.progress.generation}` });
2403
+ if (!result.ok) {
2404
+ return finish(job, 'rejected', {
2405
+ code: result.rejection.rejected,
2406
+ ...(result.rejection.detail !== undefined ? { detail: result.rejection.detail } : {}),
2407
+ });
2408
+ }
2409
+ const { intent, transactions } = result;
2410
+ const mismatch = checkPreparedAgainstIntent({ wallet, intent, transactions, format });
2411
+ if (mismatch)
2412
+ return refuse(job, mismatch);
2413
+ const summary = await summarizePrepared({ intent, transactions }, wallet, simulator);
2414
+ const refusal = checkPrepared(policy, {
2415
+ deploymentId: job.deploymentId,
2416
+ prepared: { intent, transactions },
2417
+ summary,
2418
+ simulates: true,
2419
+ });
2420
+ if (refusal)
2421
+ return refuse(job, refusal);
2422
+ if (!(await store.jobs.renew(job.id, worker, leaseUntil().until)))
2423
+ throw new LostLease();
2424
+ // Consent can be revoked or changed while the step was prepared and simulated.
2425
+ const grant = await consentFor(job);
2426
+ if (!grant) {
2427
+ return finish(job, 'skipped', { code: 'no_consent', reason: 'consent was revoked before signing' });
2428
+ }
2429
+ const outflows = stepOutflows(summary);
2430
+ const overLimit = await checkLimits(job, grant, outflows);
2431
+ if (overLimit)
2432
+ return refuse(job, overLimit);
2433
+ // The signer gets copies; the checks below compare against a baseline it never had.
2434
+ const baseline = transactions.map(({ index, transactionBase64 }) => ({ index, transactionBase64 }));
2435
+ let signed;
2436
+ try {
2437
+ signed = await signer.signTransactions({
2438
+ wallet,
2439
+ intent: structuredClone(intent),
2440
+ transactions: baseline.map((transaction) => ({ ...transaction })),
2441
+ });
2442
+ }
2443
+ catch (error) {
2444
+ if (isSignerRefusal(error))
2445
+ return refuse(job, { rule: error.rule, reason: error.message });
2446
+ throw error;
2447
+ }
2448
+ const tampered = checkSignedTransactions({ wallet, intent, prepared: baseline, signed: [...signed] });
2449
+ if (tampered)
2450
+ return refuse(job, tampered);
2451
+ // Recorded with the signed bytes, before submit, so a crash cannot let a
2452
+ // step escape its window; voided if the intent never lands.
2453
+ const perTransaction = transactionOutflows(summary).map(({ index, amounts }) => ({
2454
+ index,
2455
+ amounts: Object.fromEntries([...amounts].map(([asset, amount]) => [asset, amount.toString()])),
2456
+ }));
2457
+ const saved = await advance(job, 'signed', { format, intent, prepared: baseline, signed, outflows: perTransaction, signedAt: clock.now() }, {
2458
+ record: [...outflows].map(([asset, amount]) => ({
2459
+ wallet,
2460
+ deploymentId: job.deploymentId,
2461
+ asset,
2462
+ amount: amount.toString(),
2463
+ })),
2464
+ });
2465
+ return submit(saved);
2466
+ };
2467
+ /** Submit a `signed` job's saved transactions, then track the intent. */
2468
+ const submit = async (job) => {
2469
+ let state;
2470
+ try {
2471
+ state = await client.submit(job.progress.intent, job.progress.signed);
2472
+ }
2473
+ catch (error) {
2474
+ // The API may have accepted it; the next claim reads the intent first.
2475
+ report(error, { jobId: job.id, phase: 'submit' });
2476
+ return park(job, backoff(job.attempts + 1), job.attempts + 1);
2477
+ }
2478
+ return track(await advance(job, 'submitted'), state);
2479
+ };
2480
+ /** Follow a submitted intent until it settles or this claim's time is up. */
2481
+ const track = async (initial, first) => {
2482
+ let job = initial;
2483
+ const deadline = clock.now() + trackForMs;
2484
+ let state = first;
2485
+ for (;;) {
2486
+ if (state === undefined) {
2487
+ try {
2488
+ state = await client.intents.get(job.progress.intent.executionId);
2489
+ }
2490
+ catch (error) {
2491
+ report(error, { jobId: job.id, phase: 'intent' });
2492
+ }
2493
+ }
2494
+ if (state !== undefined) {
2495
+ const signatures = state.transactions.map((transaction) => transaction.signature);
2496
+ switch (state.state) {
2497
+ case 'completed':
2498
+ return finish(job, 'completed', { code: 'completed' }, { ...job.progress, signatures });
2499
+ case 'chain_failed': {
2500
+ const ledger = await failedLedger(job, state);
2501
+ return finish(job, 'chain_failed', {
2502
+ code: 'chain_failed',
2503
+ detail: state.transactions.filter((transaction) => transaction.error).map(({ index, error }) => ({ index, error })),
2504
+ }, { ...job.progress, signatures }, ledger);
2505
+ }
2506
+ case 'rejected':
2507
+ return finish(job, 'rejected', { code: 'intent_rejected' }, { ...job.progress, signatures });
2508
+ case 'expired':
2509
+ return prepareAgain(job);
2510
+ case 'prepared':
2511
+ // Not submitted yet: submit the saved bytes (never signs again).
2512
+ if (job.state === 'signed')
2513
+ return submit(job);
2514
+ break;
2515
+ case 'submitted_unknown':
2516
+ if (job.state !== 'submitted_unknown')
2517
+ job = await advance(job, 'submitted_unknown', { signatures });
2518
+ break;
2519
+ default:
2520
+ if (job.state === 'signed')
2521
+ job = await advance(job, 'submitted', { signatures });
2522
+ }
2523
+ }
2524
+ if (clock.now() + trackIntervalMs > deadline) {
2525
+ return park(job, backoff(job.attempts + 1), job.attempts + 1);
2526
+ }
2527
+ await clock.sleep(trackIntervalMs);
2528
+ if (!(await store.jobs.renew(job.id, worker, leaseUntil().until)))
2529
+ throw new LostLease();
2530
+ state = undefined;
2531
+ }
2532
+ };
2533
+ /** An expired intent is prepared again once, with a new idempotency key. */
2534
+ const prepareAgain = async (job) => {
2535
+ if (job.progress.generation >= 1) {
2536
+ return finish(job, 'expired', { code: 'expired', reason: 'the intent expired again after preparing it a second time' });
2537
+ }
2538
+ const fresh = await advance(job, 'running', { generation: job.progress.generation + 1, intent: undefined, prepared: undefined, signed: undefined, signatures: undefined }, { void: true });
2539
+ return attempt(fresh);
2540
+ };
2541
+ /** A job that had signed: read the intent before doing anything else. */
2542
+ const reconcile = async (job) => {
2543
+ let state;
2544
+ try {
2545
+ state = await client.intents.get(job.progress.intent.executionId);
2546
+ }
2547
+ catch (error) {
2548
+ report(error, { jobId: job.id, phase: 'reconcile' });
2549
+ return park(job, backoff(job.attempts + 1), job.attempts + 1);
2550
+ }
2551
+ return track(job, state);
2552
+ };
2553
+ const runStep = async (job) => {
2554
+ try {
2555
+ if (job.state === 'running') {
2556
+ // A lapsed claim before signing starts over: prepare is idempotent.
2557
+ await attempt(job);
2558
+ }
2559
+ else {
2560
+ await reconcile(job);
2561
+ }
2562
+ }
2563
+ catch (error) {
2564
+ if (error instanceof LostLease)
2565
+ return;
2566
+ report(error, { jobId: job.id, phase: job.state });
2567
+ const current = await store.jobs.get(job.id);
2568
+ if (!current || current.kind !== 'step' || current.lease?.worker !== worker)
2569
+ return;
2570
+ const attempts = current.attempts + 1;
2571
+ if (current.state === 'running') {
2572
+ if (attempts >= maxAttempts) {
2573
+ await finish(current, 'failed', { code: 'error', reason: message(error) }).catch(() => undefined);
2574
+ }
2575
+ else {
2576
+ // Nothing was signed: let other jobs of the wallet run meanwhile.
2577
+ await store.jobs.transition(current.id, {
2578
+ worker,
2579
+ from: 'running',
2580
+ to: 'queued',
2581
+ now: clock.now(),
2582
+ notBefore: clock.now() + backoff(attempts),
2583
+ attempts,
2584
+ });
2585
+ }
2586
+ }
2587
+ else {
2588
+ // Signed or later: never fail blind; read the intent next time.
2589
+ await park(current, backoff(attempts), attempts).catch(() => undefined);
2590
+ }
2591
+ }
2592
+ };
2593
+ // -------------------------------------------------------------------------
2594
+ // Delivery jobs
2595
+ // -------------------------------------------------------------------------
2596
+ const runDelivery = async (job) => {
2597
+ const { delivery } = job;
2598
+ const handler = handlers.get(delivery.name);
2599
+ const ctx = {
2600
+ client,
2601
+ trigger: { name: delivery.name, id: delivery.id, source: delivery.source, receivedAt: delivery.receivedAt },
2602
+ consents: {
2603
+ async list(query = {}) {
2604
+ const grants = await store.consent.active({
2605
+ deploymentId: client.deploymentId,
2606
+ ...(query.step !== undefined ? { step: query.step } : {}),
2607
+ now: clock.now(),
2608
+ });
2609
+ if (query.instanceId === undefined)
2610
+ return grants;
2611
+ // One effective grant per wallet: the instance's own grant wins over
2612
+ // one for every instance, as consent checks choose at run time.
2613
+ const effective = new Map();
2614
+ for (const grant of grants) {
2615
+ if (grant.instanceId !== null && grant.instanceId !== query.instanceId)
2616
+ continue;
2617
+ const current = effective.get(grant.wallet);
2618
+ if (!current || (current.instanceId === null && grant.instanceId !== null))
2619
+ effective.set(grant.wallet, grant);
2620
+ }
2621
+ return [...effective.values()];
2622
+ },
2623
+ },
2624
+ step(step, request, stepOptions = {}) {
2625
+ return {
2626
+ step,
2627
+ wallet: request.wallet,
2628
+ ...(request.instance !== undefined ? { instance: request.instance } : {}),
2629
+ args: { ...(request.args ?? {}) },
2630
+ ...(stepOptions.cooldown !== undefined ? { cooldownMs: durationMs(stepOptions.cooldown) } : {}),
2631
+ };
2632
+ },
2633
+ };
2634
+ try {
2635
+ const requests = handler ? ((await handler(delivery.payload, ctx)) ?? []) : [];
2636
+ const steps = new Map();
2637
+ for (const request of requests) {
2638
+ if (typeof request?.step !== 'string' || typeof request.wallet !== 'string' || request.wallet === '') {
2639
+ throw new TypeError(`handler for ${delivery.name} returned a step request without a step or wallet`);
2640
+ }
2641
+ const id = stepJobId(delivery.id, request);
2642
+ if (!steps.has(id))
2643
+ steps.set(id, { id, deploymentId: client.deploymentId, request });
2644
+ }
2645
+ if (!(await store.jobs.completeDelivery(job.id, worker, [...steps.values()], clock.now()))) {
2646
+ throw new LostLease();
2647
+ }
2648
+ }
2649
+ catch (error) {
2650
+ if (error instanceof LostLease)
2651
+ return;
2652
+ report(error, { jobId: job.id, phase: 'handler' });
2653
+ const attempts = job.attempts + 1;
2654
+ await store.jobs.transition(job.id, {
2655
+ worker,
2656
+ from: 'running',
2657
+ to: attempts >= maxAttempts ? 'failed' : 'queued',
2658
+ now: clock.now(),
2659
+ attempts,
2660
+ notBefore: clock.now() + backoff(attempts),
2661
+ ...(attempts >= maxAttempts ? { outcome: { code: 'handler_failed', reason: message(error) } } : {}),
2662
+ });
2663
+ }
2664
+ };
2665
+ return { runStep, runDelivery };
2666
+ };
2667
+ // -------------------------------------------------------------------------
2668
+ // Workers
2669
+ // -------------------------------------------------------------------------
2670
+ let claims = 0;
2671
+ const runOnce = async () => {
2672
+ await bound();
2673
+ claims += 1;
2674
+ const holder = `${worker}#${claims}`;
2675
+ const job = await store.jobs.claimNext({ worker: holder, leaseMs, now: clock.now() });
2676
+ if (!job)
2677
+ return false;
2678
+ const runner = forClaim(holder);
2679
+ if (job.kind === 'delivery')
2680
+ await runner.runDelivery(job);
2681
+ else
2682
+ await runner.runStep(job);
2683
+ return true;
2684
+ };
2685
+ let running = false;
2686
+ let loops = [];
2687
+ // -------------------------------------------------------------------------
2688
+ // View sources
2689
+ // -------------------------------------------------------------------------
2690
+ const ENDED = Symbol('ended');
2691
+ const newAttempt = () => {
2692
+ let end = () => undefined;
2693
+ const ended = new Promise((resolve) => {
2694
+ end = () => resolve(ENDED);
2695
+ });
2696
+ return { active: true, ended, end };
2697
+ };
2698
+ /** Close an attempt: no further work of it runs, and its stream is closed. */
2699
+ const closeAttempt = async (attempt) => {
2700
+ attempt.active = false;
2701
+ attempt.end();
2702
+ const iterator = attempt.iterator;
2703
+ attempt.iterator = undefined;
2704
+ try {
2705
+ await iterator?.return?.();
2706
+ }
2707
+ catch {
2708
+ // Closing a stream that already failed.
2709
+ }
2710
+ };
2711
+ /** `promise`, unless the attempt ends first. */
2712
+ const within = (attempt, promise) => Promise.race([promise, attempt.ended]);
2713
+ const watchList = async (source, attempt) => {
2714
+ // Signals are evaluated against the rows as of each event, in order.
2715
+ const latest = new Map();
2716
+ const rowsByKey = new Map();
2717
+ let rows = [];
2718
+ const waiting = new Set();
2719
+ const current = (key) => {
2720
+ if (source.rowSignal)
2721
+ return latest.get(key);
2722
+ const signal = source.signals(rows).find(([candidate]) => candidate === key);
2723
+ return signal ? [signal[1], signal[2]] : undefined;
2724
+ };
2725
+ const evaluate = async (key, value, observed) => {
2726
+ const result = await evaluateSignal(store, clock, source, key, value, observed);
2727
+ if (result === 'pending' && !waiting.has(key)) {
2728
+ waiting.add(key);
2729
+ void clock.sleep(source.stableForMs).then(() => settle(key));
2730
+ }
2731
+ };
2732
+ // A pending value is promoted when its timer fires; a failed write is
2733
+ // retried until it lands or the attempt ends.
2734
+ const settle = async (key) => {
2735
+ waiting.delete(key);
2736
+ for (let failures = 0; attempt.active; failures += 1) {
2737
+ const signal = current(key);
2738
+ if (!signal)
2739
+ return;
2740
+ try {
2741
+ await evaluate(key, signal[0], signal[1]);
2742
+ return;
2743
+ }
2744
+ catch (error) {
2745
+ report(error, { phase: 'source' });
2746
+ if ((await within(attempt, clock.sleep(backoff(failures + 1)))) === ENDED)
2747
+ return;
2748
+ }
2749
+ }
2750
+ };
2751
+ // Read the stream into a local, unbounded queue from the moment it opens:
2752
+ // the SDK's own buffer is bounded and drops list updates silently.
2753
+ const iterator = source.view.watchRich({ withSnapshot: true })[Symbol.asyncIterator]();
2754
+ attempt.iterator = iterator;
2755
+ const queued = [];
2756
+ const waiter = { wake: null };
2757
+ let finished = false;
2758
+ let failure = null;
2759
+ void (async () => {
2760
+ try {
2761
+ for (;;) {
2762
+ const next = await within(attempt, iterator.next());
2763
+ if (next === ENDED || next.done)
2764
+ break;
2765
+ queued.push(next.value);
2766
+ waiter.wake?.();
2767
+ }
2768
+ }
2769
+ catch (error) {
2770
+ failure = error;
2771
+ }
2772
+ finally {
2773
+ finished = true;
2774
+ waiter.wake?.();
2775
+ }
2776
+ })();
2777
+ const take = async () => {
2778
+ while (queued.length === 0 && !finished) {
2779
+ await new Promise((resolve) => {
2780
+ waiter.wake = resolve;
2781
+ });
2782
+ waiter.wake = null;
2783
+ }
2784
+ const update = queued.shift();
2785
+ if (update !== undefined)
2786
+ return update;
2787
+ if (failure !== null)
2788
+ throw failure;
2789
+ return null;
2790
+ };
2791
+ // Seed from the complete current rows: a query that is already open
2792
+ // replays nothing on a new stream, so the stream alone cannot be the
2793
+ // baseline.
2794
+ const snapshot = await within(attempt, source.view.get());
2795
+ if (snapshot === ENDED || !attempt.active)
2796
+ return;
2797
+ const rowKeys = new Map();
2798
+ for (const row of snapshot)
2799
+ rowsByKey.set(source.rowKey(row), row);
2800
+ /** Apply an update; the row it leaves, or `undefined` for a removal. */
2801
+ /** A row's signal is forgotten, so no pending `stableFor` change of it can still fire. */
2802
+ const forget = (key) => {
2803
+ rowsByKey.delete(key);
2804
+ latest.delete(key);
2805
+ };
2806
+ // A removal of a row the stream never announced (seeded from the
2807
+ // snapshot) that carries no `lastKnown`: its key cannot be derived.
2808
+ let unmatchedRemoval = false;
2809
+ /** Apply an update: the row and its key, or `undefined` for a removal. */
2810
+ const apply = (update) => {
2811
+ if (update.type === 'created' || update.type === 'updated') {
2812
+ const row = update.type === 'created' ? update.data : update.after;
2813
+ const key = source.rowKey(row);
2814
+ rowKeys.set(update.key, key);
2815
+ rowsByKey.set(key, row);
2816
+ return { row, key };
2817
+ }
2818
+ const key = update.lastKnown !== undefined ? source.rowKey(update.lastKnown) : rowKeys.get(update.key);
2819
+ if (key !== undefined)
2820
+ forget(key);
2821
+ else
2822
+ unmatchedRemoval = true;
2823
+ rowKeys.delete(update.key);
2824
+ return undefined;
2825
+ };
2826
+ /** Drop the rows the view no longer has, reading it when it has no synchronous copy. */
2827
+ const reconcileRemoval = async () => {
2828
+ unmatchedRemoval = false;
2829
+ const current = source.view.getSync?.() ?? (await within(attempt, source.view.get()));
2830
+ if (current === ENDED)
2831
+ return false;
2832
+ const present = new Set(current.map((row) => source.rowKey(row)));
2833
+ for (const key of [...rowsByKey.keys()])
2834
+ if (!present.has(key))
2835
+ forget(key);
2836
+ return true;
2837
+ };
2838
+ /**
2839
+ * After a reconnect the SDK replaces the list with a new snapshot and
2840
+ * announces only the rows still present; drop the ones the view no
2841
+ * longer has.
2842
+ */
2843
+ const pruneGone = () => {
2844
+ const current = source.view.getSync?.();
2845
+ if (!current)
2846
+ return 0;
2847
+ const present = new Set(current.map((row) => source.rowKey(row)));
2848
+ let removed = 0;
2849
+ for (const key of [...rowsByKey.keys()]) {
2850
+ if (!present.has(key)) {
2851
+ forget(key);
2852
+ removed += 1;
2853
+ }
2854
+ }
2855
+ return removed;
2856
+ };
2857
+ const rowJson = (row) => JSON.stringify(row, (_key, value) => (typeof value === 'bigint' ? value.toString() : value));
2858
+ // Rows a reconnect's rebuild already took in, until the stream announces them.
2859
+ const resynced = new Map();
2860
+ // Let the reader drain what the stream already delivered (buffered
2861
+ // events resolve as microtasks, before this timer), then apply it in order
2862
+ // without evaluating: it is already reflected in the snapshot, and
2863
+ // replaying it leaves every row at its latest value.
2864
+ if ((await within(attempt, new Promise((resolve) => setTimeout(resolve, 0)))) === ENDED)
2865
+ return;
2866
+ while (queued.length > 0)
2867
+ apply(queued.shift());
2868
+ // A removal that arrived after the snapshot was read may name a row only
2869
+ // the snapshot holds.
2870
+ if (unmatchedRemoval && !(await reconcileRemoval()))
2871
+ return;
2872
+ rows = [...rowsByKey.values()];
2873
+ for (const [key, value, observed] of source.signals(rows)) {
2874
+ if (!attempt.active)
2875
+ return;
2876
+ if (source.rowSignal)
2877
+ latest.set(key, [value, observed]);
2878
+ await evaluate(key, value, observed);
2879
+ }
2880
+ while (attempt.active) {
2881
+ const update = await take();
2882
+ if (update === null || !attempt.active)
2883
+ return;
2884
+ if (!source.rowSignal && update.type === 'created') {
2885
+ const key = source.rowKey(update.data);
2886
+ if (resynced.get(key) === rowJson(update.data)) {
2887
+ // Part of a reconnect already evaluated as a whole.
2888
+ resynced.delete(key);
2889
+ rowKeys.set(update.key, key);
2890
+ continue;
2891
+ }
2892
+ const announcedAgain = rowsByKey.has(key);
2893
+ apply(update);
2894
+ const current = source.view.getSync?.();
2895
+ if ((announcedAgain || pruneGone() > 0) && current) {
2896
+ // A reconnect: the SDK replaced the whole list and now announces it
2897
+ // row by row. Evaluate the new list once, as a whole, not a mix of
2898
+ // old and new rows.
2899
+ rowsByKey.clear();
2900
+ resynced.clear();
2901
+ for (const row of current) {
2902
+ const rowKey = source.rowKey(row);
2903
+ rowsByKey.set(rowKey, row);
2904
+ if (rowKey !== key)
2905
+ resynced.set(rowKey, rowJson(row));
2906
+ }
2907
+ }
2908
+ rows = [...rowsByKey.values()];
2909
+ for (const [signalKey, value, observed] of source.signals(rows)) {
2910
+ if (!attempt.active)
2911
+ return;
2912
+ await evaluate(signalKey, value, observed);
2913
+ }
2914
+ continue;
2915
+ }
2916
+ const applied = apply(update);
2917
+ if (update.type === 'created')
2918
+ pruneGone();
2919
+ if (unmatchedRemoval && !(await reconcileRemoval()))
2920
+ return;
2921
+ rows = [...rowsByKey.values()];
2922
+ if (source.rowSignal) {
2923
+ if (applied === undefined)
2924
+ continue;
2925
+ const signal = source.rowSignal(applied.row);
2926
+ if (!signal) {
2927
+ // It no longer passes the filter: its old signal must not fire.
2928
+ latest.delete(applied.key);
2929
+ continue;
2930
+ }
2931
+ latest.set(signal[0], [signal[1], signal[2]]);
2932
+ await evaluate(signal[0], signal[1], signal[2]);
2933
+ continue;
2934
+ }
2935
+ for (const [key, value, observed] of source.signals(rows)) {
2936
+ if (!attempt.active)
2937
+ return;
2938
+ await evaluate(key, value, observed);
2939
+ }
2940
+ }
2941
+ };
2942
+ const watchAppend = async (source, attempt) => {
2943
+ const cursor = await within(attempt, store.sources.cursor(source.name));
2944
+ if (cursor === ENDED || !attempt.active)
2945
+ return;
2946
+ const iterator = source.view.watch(cursor ? { after: cursor } : {})[Symbol.asyncIterator]();
2947
+ attempt.iterator = iterator;
2948
+ while (attempt.active) {
2949
+ const next = await within(attempt, iterator.next());
2950
+ if (next === ENDED || next.done || !attempt.active)
2951
+ return;
2952
+ const update = next.value;
2953
+ if (update.type !== 'upsert')
2954
+ continue;
2955
+ if (!update.cursor)
2956
+ throw new Error(`${source.name}: the view is not an append view (its updates carry no cursor)`);
2957
+ if (!source.filter || source.filter(update.data)) {
2958
+ const payload = { source: source.name, cursor: update.cursor, row: update.data };
2959
+ await accept({
2960
+ id: `view:${encodeURIComponent(source.name)}:${update.cursor}`,
2961
+ name: source.name,
2962
+ source: 'view',
2963
+ payload,
2964
+ });
2965
+ }
2966
+ await store.sources.putCursor(source.name, update.cursor, clock.now());
2967
+ }
2968
+ };
2969
+ const startSource = (source, sourceOptions = {}) => {
2970
+ let stopped = false;
2971
+ const stopping = newAttempt();
2972
+ let attempt = null;
2973
+ let holding = !sourceOptions.exclusive;
2974
+ const pause = (ms) => within(stopping, clock.sleep(ms));
2975
+ const acquire = async () => {
2976
+ try {
2977
+ return await store.sources.acquireLease(source.name, worker, clock.now() + leaseMs, clock.now());
2978
+ }
2979
+ catch (error) {
2980
+ report(error, { phase: 'source' });
2981
+ return false;
2982
+ }
2983
+ };
2984
+ const keepLease = async () => {
2985
+ while (!stopped && holding) {
2986
+ if ((await pause(Math.max(1, Math.floor(leaseMs / 3)))) === ENDED)
2987
+ return;
2988
+ holding = await acquire();
2989
+ if (!holding && attempt)
2990
+ await closeAttempt(attempt);
2991
+ }
2992
+ };
2993
+ const loop = (async () => {
2994
+ let failures = 0;
2995
+ while (!stopped) {
2996
+ if (sourceOptions.exclusive && !holding) {
2997
+ holding = await acquire();
2998
+ if (!holding) {
2999
+ await pause(Math.max(1, Math.floor(leaseMs / 2)));
3000
+ continue;
3001
+ }
3002
+ void keepLease();
3003
+ }
3004
+ const current = newAttempt();
3005
+ attempt = current;
3006
+ try {
3007
+ await bound();
3008
+ if (source.kind === 'list')
3009
+ await watchList(source, current);
3010
+ else
3011
+ await watchAppend(source, current);
3012
+ failures = 0;
3013
+ }
3014
+ catch (error) {
3015
+ if (!stopped) {
3016
+ report(error, { phase: 'source' });
3017
+ failures += 1;
3018
+ }
3019
+ }
3020
+ finally {
3021
+ await closeAttempt(current);
3022
+ }
3023
+ if (!stopped)
3024
+ await pause(failures > 0 ? backoff(failures) : Math.min(reconcileMs, 1000));
3025
+ }
3026
+ })();
3027
+ return {
3028
+ async stop() {
3029
+ stopped = true;
3030
+ stopping.end();
3031
+ if (attempt)
3032
+ await closeAttempt(attempt);
3033
+ await loop;
3034
+ if (sourceOptions.exclusive) {
3035
+ await store.sources.releaseLease(source.name, worker).catch((error) => report(error, { phase: 'source' }));
3036
+ }
3037
+ },
3038
+ };
3039
+ };
3040
+ const domain = () => {
3041
+ if (!options.consent?.domain) {
3042
+ throw new ConsentError('no_domain', 'createAutomations needs consent.domain to build consent proofs');
3043
+ }
3044
+ return options.consent.domain;
3045
+ };
3046
+ const consent = {
3047
+ async proofMessage(fields) {
3048
+ await bound();
3049
+ const normalized = normalizeConsent(fields);
3050
+ if (normalized.deploymentId !== client.deploymentId) {
3051
+ throw new ConsentError('invalid_fields', `consent is for ${normalized.deploymentId}, this client runs ${client.deploymentId}`);
3052
+ }
3053
+ const random = new Uint8Array(16);
3054
+ globalThis.crypto.getRandomValues(random);
3055
+ const nonce = encodeBase58(random);
3056
+ const issuedAt = clock.now();
3057
+ const message = consentMessage(domain(), normalized, nonce, issuedAt);
3058
+ await store.consent.issueNonce(nonce, issuedAt + proofTtlMs);
3059
+ return { message, bytes: new TextEncoder().encode(message), nonce, issuedAt };
3060
+ },
3061
+ async grant(input) {
3062
+ await bound();
3063
+ const now = clock.now();
3064
+ const normalized = normalizeConsent(input);
3065
+ if (normalized.deploymentId !== client.deploymentId) {
3066
+ throw new ConsentError('invalid_fields', `consent is for ${normalized.deploymentId}, this client runs ${client.deploymentId}`);
3067
+ }
3068
+ if (normalized.expiresAt <= now)
3069
+ throw new ConsentError('expired', 'the consent has already expired');
3070
+ if (!Number.isSafeInteger(input.issuedAt) || input.issuedAt > now + 60_000 || now - input.issuedAt > proofTtlMs) {
3071
+ throw new ConsentError('proof_stale', 'the proof was issued too long ago; build and sign a new one');
3072
+ }
3073
+ const message = consentMessage(domain(), normalized, input.nonce, input.issuedAt);
3074
+ const signature = typeof input.signature === 'string' ? decodeBase58(input.signature) : input.signature;
3075
+ const walletKey = decodeBase58(normalized.wallet);
3076
+ let valid = false;
3077
+ try {
3078
+ valid =
3079
+ signature !== null &&
3080
+ signature.length === 64 &&
3081
+ walletKey !== null &&
3082
+ ed25519.ed25519.verify(signature, new TextEncoder().encode(message), walletKey);
3083
+ }
3084
+ catch {
3085
+ valid = false;
3086
+ }
3087
+ if (!valid) {
3088
+ throw new ConsentError('invalid_signature', 'the signature is not the wallet\'s over this consent');
3089
+ }
3090
+ if (!(await store.consent.useNonce(input.nonce, now))) {
3091
+ throw new ConsentError('nonce_not_usable', 'the nonce was not issued, has expired, or was already used');
3092
+ }
3093
+ const grant = {
3094
+ ...normalized,
3095
+ grantedAt: now,
3096
+ revokedAt: null,
3097
+ proof: { message, signature: encodeBase58(signature), nonce: input.nonce, issuedAt: input.issuedAt },
3098
+ };
3099
+ await store.consent.put(grant);
3100
+ const warnings = [];
3101
+ let status;
3102
+ try {
3103
+ status = signer.delegationStatus ? await signer.delegationStatus(normalized.wallet) : undefined;
3104
+ }
3105
+ catch (error) {
3106
+ // The grant is stored; a provider outage must not report it as failed.
3107
+ warnings.push(`the ${signer.kind} signer could not report its delegation: ${String(error?.message ?? error)}`);
3108
+ return { grant, warnings };
3109
+ }
3110
+ if (!status) {
3111
+ warnings.push(`the ${signer.kind} signer cannot report whether a provider policy bounds what it signs`);
3112
+ }
3113
+ else {
3114
+ if (!status.delegated)
3115
+ warnings.push(`the ${signer.kind} signer has no delegated access to ${normalized.wallet}`);
3116
+ if (!status.providerPolicy) {
3117
+ warnings.push(`no provider policy bounds what the ${signer.kind} signer may sign for ${normalized.wallet}`);
3118
+ }
3119
+ }
3120
+ return { grant, warnings };
3121
+ },
3122
+ async revoke(input) {
3123
+ await bound();
3124
+ return store.consent.revoke({ ...input, deploymentId: client.deploymentId, now: clock.now() });
3125
+ },
3126
+ async list(input) {
3127
+ await bound();
3128
+ return store.consent.list(input);
3129
+ },
3130
+ };
3131
+ const automations = {
3132
+ consent,
3133
+ on(name, handler) {
3134
+ handlers.set(name, handler);
3135
+ return automations;
3136
+ },
3137
+ dispatch(name, payload, { id }) {
3138
+ return accept({ id: `dispatch:${id}`, name, source: 'dispatch', payload });
3139
+ },
3140
+ acceptWebhook,
3141
+ webhookHandler(webhook) {
3142
+ return async (request) => {
3143
+ const body = new Uint8Array(await request.arrayBuffer());
3144
+ const result = await acceptWebhook(body, request.headers, webhook);
3145
+ return new Response(JSON.stringify(result.body), {
3146
+ status: result.status,
3147
+ headers: { 'content-type': 'application/json' },
3148
+ });
3149
+ };
3150
+ },
3151
+ expressWebhookHandler(webhook) {
3152
+ return async (req, res) => {
3153
+ const raw = req.body;
3154
+ if (!(raw instanceof Uint8Array) && typeof raw !== 'string') {
3155
+ res.status(500).json({
3156
+ error: 'raw-body-required',
3157
+ message: "mount the webhook route behind express.raw({ type: 'application/json' })",
3158
+ });
3159
+ return;
3160
+ }
3161
+ const result = await acceptWebhook(raw, req.headers, webhook);
3162
+ res.status(result.status).json(result.body);
3163
+ };
3164
+ },
3165
+ follow(instanceId, followOptions = {}) {
3166
+ if (!client.events)
3167
+ throw new Error('this client cannot follow event streams');
3168
+ let stopped = false;
3169
+ let queue = Promise.resolve();
3170
+ // The stream moves past an event before its handler runs, so an event
3171
+ // whose accept fails is retried here, in order, until it is stored.
3172
+ const acceptUntilStored = async (delivery) => {
3173
+ for (let failures = 0; !stopped && !followOptions.signal?.aborted; failures += 1) {
3174
+ try {
3175
+ await accept(delivery);
3176
+ return;
3177
+ }
3178
+ catch (error) {
3179
+ report(error, { phase: 'stream' });
3180
+ await clock.sleep(Math.min(1000 * 2 ** failures, 30_000));
3181
+ }
3182
+ }
3183
+ };
3184
+ const subscription = client.events(instanceId, {
3185
+ ...(followOptions.lastEventId !== undefined ? { lastEventId: followOptions.lastEventId } : {}),
3186
+ ...(followOptions.signal ? { signal: followOptions.signal } : {}),
3187
+ onEvent(streamEvent) {
3188
+ if (streamEvent.type !== 'emit' || !handlers.has(streamEvent.event))
3189
+ return;
3190
+ const data = streamEvent.data;
3191
+ const event = {
3192
+ id: String(data.id),
3193
+ event: streamEvent.event,
3194
+ deploymentId: client.deploymentId,
3195
+ instanceId: data.instanceId,
3196
+ participantId: data.participantId,
3197
+ state: null,
3198
+ occurredAt: data.at,
3199
+ source: 'stream',
3200
+ };
3201
+ const delivery = {
3202
+ id: `stream:${encodeURIComponent(data.instanceId)}:${data.id}`,
3203
+ name: streamEvent.event,
3204
+ source: 'stream',
3205
+ payload: event,
3206
+ };
3207
+ queue = queue.then(() => acceptUntilStored(delivery));
3208
+ },
3209
+ onError(error) {
3210
+ report(error, { phase: 'stream' });
3211
+ },
3212
+ });
3213
+ return {
3214
+ get lastEventId() {
3215
+ return subscription.lastEventId;
3216
+ },
3217
+ get closed() {
3218
+ return subscription.closed;
3219
+ },
3220
+ close() {
3221
+ stopped = true;
3222
+ subscription.close();
3223
+ },
3224
+ };
3225
+ },
3226
+ source: startSource,
3227
+ async use(definition, useOptions = {}) {
3228
+ // A definition for another protocol would register handlers for events
3229
+ // this deployment never emits.
3230
+ const own = client.definition?.name;
3231
+ if (own !== undefined && definition.protocol.name !== own) {
3232
+ throw new Error(`the definition is for protocol ${definition.protocol.name}, but the client is for ${own}`);
3233
+ }
3234
+ const setup = {
3235
+ on: ((name, handler) => automations.on(name, handler)),
3236
+ source: startSource,
3237
+ connect: useOptions.connect ?? connectStack,
3238
+ };
3239
+ await definition.setup(setup);
3240
+ return automations;
3241
+ },
3242
+ async poll(source) {
3243
+ if (source.kind !== 'list')
3244
+ throw new TypeError('only list-view sources can be polled');
3245
+ await bound();
3246
+ let delivered = 0;
3247
+ let pending = 0;
3248
+ for (const [key, value, observed] of source.signals(await source.view.get())) {
3249
+ const result = await evaluateSignal(store, clock, source, key, value, observed);
3250
+ if (result === 'delivered')
3251
+ delivered += 1;
3252
+ if (result === 'pending')
3253
+ pending += 1;
3254
+ }
3255
+ return { delivered, pending };
3256
+ },
3257
+ runOnce,
3258
+ async drain(drainOptions = {}) {
3259
+ const max = drainOptions.maxJobs ?? Number.POSITIVE_INFINITY;
3260
+ let ran = 0;
3261
+ while (ran < max && (await runOnce()))
3262
+ ran += 1;
3263
+ return ran;
3264
+ },
3265
+ start(startOptions = {}) {
3266
+ if (running)
3267
+ return;
3268
+ running = true;
3269
+ const pollMs = durationMs(startOptions.pollInterval ?? 1_000);
3270
+ const loop = async () => {
3271
+ while (running) {
3272
+ let ran = false;
3273
+ try {
3274
+ ran = await runOnce();
3275
+ }
3276
+ catch (error) {
3277
+ report(error, { phase: 'worker' });
3278
+ }
3279
+ if (!ran && running)
3280
+ await clock.sleep(pollMs);
3281
+ }
3282
+ };
3283
+ loops = Array.from({ length: Math.max(1, startOptions.concurrency ?? 1) }, loop);
3284
+ },
3285
+ async stop() {
3286
+ running = false;
3287
+ await Promise.all(loops);
3288
+ loops = [];
3289
+ },
3290
+ };
3291
+ return automations;
3292
+ }
3293
+
3294
+ /** An append-view cursor `{epoch}:{offset}` as numbers, or `null` when it is not one. */
3295
+ function parseCursor(cursor) {
3296
+ // The epoch is opaque (the server uses a UUID per tape lifetime); the
3297
+ // offset follows the last colon.
3298
+ const at = cursor.lastIndexOf(':');
3299
+ const epoch = cursor.slice(0, at);
3300
+ const offset = cursor.slice(at + 1);
3301
+ if (at <= 0 || !/^\d+$/.test(offset))
3302
+ return null;
3303
+ return { epoch, offset: BigInt(offset) };
3304
+ }
3305
+ /**
3306
+ * Whether `next` moves past `current`. Offsets are compared within an
3307
+ * epoch; a different epoch is a new tape lifetime, which cannot be ordered
3308
+ * against the old one, so it always moves on. Any cursor is after an
3309
+ * unreadable one.
3310
+ */
3311
+ function cursorAfter(next, current) {
3312
+ const order = parseCursor(next);
3313
+ if (!order)
3314
+ throw new TypeError(`'${next}' is not an {epoch}:{offset} cursor`);
3315
+ const stored = current === null ? null : parseCursor(current);
3316
+ if (!stored || stored.epoch !== order.epoch)
3317
+ return true;
3318
+ return order.offset > stored.offset;
3319
+ }
3320
+ class StoreDeploymentError extends Error {
3321
+ constructor(bound, requested) {
3322
+ super(`this store belongs to deployment ${bound}, not ${requested}; use one store per deployment`);
3323
+ this.name = 'StoreDeploymentError';
3324
+ }
3325
+ }
3326
+
3327
+ /**
3328
+ * An in-memory store for tests and single-process development. State is
3329
+ * lost on restart, so it must not back automations that sign real funds.
3330
+ * Each method runs to completion without awaiting, which makes every
3331
+ * operation atomic under JavaScript's single-threaded event loop.
3332
+ */
3333
+ function isActiveStep(job) {
3334
+ return job.kind === 'step' && ACTIVE_STEP_STATES.includes(job.state);
3335
+ }
3336
+ function isFinished(job) {
3337
+ return job.kind === 'step'
3338
+ ? TERMINAL_STEP_STATES.includes(job.state)
3339
+ : TERMINAL_DELIVERY_STATES.includes(job.state);
3340
+ }
3341
+ function clone(value) {
3342
+ return structuredClone(value);
3343
+ }
3344
+ const STATES = new WeakMap();
3345
+ function memoryStore(options = {}) {
3346
+ const initial = options.state ? clone(options.state) : null;
3347
+ if (initial && initial.version !== 1)
3348
+ throw new TypeError('unknown memory store state');
3349
+ const deliveries = initial?.deliveries ?? new Map();
3350
+ const jobs = initial?.jobs ?? new Map();
3351
+ let order = initial?.order ?? 0;
3352
+ const sequence = initial?.sequence ?? new Map();
3353
+ const grants = initial?.grants ?? [];
3354
+ let deployment = initial?.deployment ?? null;
3355
+ const nonces = initial?.nonces ?? new Map();
3356
+ const ledger = initial?.ledger ?? [];
3357
+ const sourceStates = initial?.sourceStates ?? new Map();
3358
+ const cursors = initial?.cursors ?? new Map();
3359
+ const sourceLeases = new Map();
3360
+ // A restored state's workers stopped with the process that saved it, so
3361
+ // their claims are released rather than left to expire.
3362
+ for (const [id, job] of jobs)
3363
+ if (job.lease)
3364
+ jobs.set(id, { ...job, lease: null });
3365
+ const insert = (job) => {
3366
+ jobs.set(job.id, job);
3367
+ sequence.set(job.id, order++);
3368
+ };
3369
+ const acceptNow = (delivery, now) => {
3370
+ if (deliveries.has(delivery.id))
3371
+ return false;
3372
+ // Clone first: a value that cannot be stored leaves the store unchanged.
3373
+ const copy = clone(delivery);
3374
+ deliveries.set(delivery.id, now);
3375
+ if (jobs.has(delivery.id))
3376
+ return false;
3377
+ insert({
3378
+ kind: 'delivery',
3379
+ id: delivery.id,
3380
+ state: 'queued',
3381
+ delivery: copy,
3382
+ attempts: 0,
3383
+ notBefore: now,
3384
+ lease: null,
3385
+ createdAt: now,
3386
+ updatedAt: now,
3387
+ outcome: null,
3388
+ });
3389
+ return true;
3390
+ };
3391
+ const walletBusy = (wallet, except) => {
3392
+ for (const job of jobs.values()) {
3393
+ if (job.id !== except && job.kind === 'step' && job.request.wallet === wallet && isActiveStep(job)) {
3394
+ return true;
3395
+ }
3396
+ }
3397
+ return false;
3398
+ };
3399
+ const claimable = (job, now) => {
3400
+ if (job.notBefore > now)
3401
+ return false;
3402
+ const leased = job.lease !== null && job.lease.until > now;
3403
+ if (leased)
3404
+ return false;
3405
+ if (job.kind === 'delivery')
3406
+ return job.state === 'queued' || job.state === 'running';
3407
+ if (job.state === 'queued')
3408
+ return !walletBusy(job.request.wallet, job.id);
3409
+ return isActiveStep(job);
3410
+ };
3411
+ const store = {
3412
+ async bindDeployment(deploymentId) {
3413
+ if (deployment !== null && deployment !== deploymentId)
3414
+ throw new StoreDeploymentError(deployment, deploymentId);
3415
+ deployment = deploymentId;
3416
+ },
3417
+ deliveries: {
3418
+ async accept(delivery, now) {
3419
+ return acceptNow(delivery, now);
3420
+ },
3421
+ async prune(before) {
3422
+ let removed = 0;
3423
+ for (const [id, at] of deliveries) {
3424
+ if (at < before) {
3425
+ deliveries.delete(id);
3426
+ removed += 1;
3427
+ }
3428
+ }
3429
+ return removed;
3430
+ },
3431
+ },
3432
+ jobs: {
3433
+ async get(id) {
3434
+ const job = jobs.get(id);
3435
+ return job ? clone(job) : null;
3436
+ },
3437
+ async claimNext({ worker, leaseMs, now }) {
3438
+ const candidates = [...jobs.values()]
3439
+ .filter((job) => claimable(job, now))
3440
+ .sort((a, b) => a.notBefore - b.notBefore || sequence.get(a.id) - sequence.get(b.id));
3441
+ const job = candidates[0];
3442
+ if (!job)
3443
+ return null;
3444
+ const lease = { worker, until: now + leaseMs };
3445
+ const claimed = job.state === 'queued'
3446
+ ? { ...job, state: 'running', lease, updatedAt: now }
3447
+ : { ...job, lease, updatedAt: now };
3448
+ jobs.set(job.id, claimed);
3449
+ return clone(claimed);
3450
+ },
3451
+ async transition(id, input) {
3452
+ const job = jobs.get(id);
3453
+ if (!job || job.state !== input.from)
3454
+ return false;
3455
+ if (job.lease?.worker !== input.worker)
3456
+ return false;
3457
+ const next = {
3458
+ ...job,
3459
+ state: input.to,
3460
+ updatedAt: input.now,
3461
+ lease: input.keepLease ? { worker: input.worker, until: input.keepLease.until } : null,
3462
+ ...(input.notBefore !== undefined ? { notBefore: input.notBefore } : {}),
3463
+ ...(input.attempts !== undefined ? { attempts: input.attempts } : {}),
3464
+ ...(input.outcome !== undefined ? { outcome: clone(input.outcome) } : {}),
3465
+ ...(job.kind === 'step' && input.progress
3466
+ ? { progress: { ...job.progress, ...clone(input.progress) } }
3467
+ : {}),
3468
+ };
3469
+ if (next.kind === 'step' && isActiveStep(next) && walletBusy(next.request.wallet, id)) {
3470
+ return false;
3471
+ }
3472
+ jobs.set(id, next);
3473
+ if (input.ledger?.void) {
3474
+ for (const [index, entry] of ledger.entries()) {
3475
+ if (entry.jobId === id && entry.voidedAt === null && !input.ledger.keepAssets?.includes(entry.asset)) {
3476
+ ledger[index] = { ...entry, voidedAt: input.now };
3477
+ }
3478
+ }
3479
+ }
3480
+ for (const entry of input.ledger?.record ?? []) {
3481
+ ledger.push({ ...clone(entry), jobId: id, recordedAt: entry.recordedAt ?? input.now, voidedAt: null });
3482
+ }
3483
+ return true;
3484
+ },
3485
+ async renew(id, worker, until) {
3486
+ const job = jobs.get(id);
3487
+ if (!job || job.lease?.worker !== worker)
3488
+ return false;
3489
+ jobs.set(id, { ...job, lease: { worker, until } });
3490
+ return true;
3491
+ },
3492
+ async completeDelivery(id, worker, steps, now) {
3493
+ const job = jobs.get(id);
3494
+ if (!job || job.kind !== 'delivery' || job.state !== 'running' || job.lease?.worker !== worker) {
3495
+ return false;
3496
+ }
3497
+ const requests = steps.map((step) => clone(step.request));
3498
+ jobs.set(id, { ...job, state: 'done', lease: null, updatedAt: now, outcome: null });
3499
+ for (const [index, step] of steps.entries()) {
3500
+ if (jobs.has(step.id))
3501
+ continue;
3502
+ const created = {
3503
+ kind: 'step',
3504
+ id: step.id,
3505
+ state: 'queued',
3506
+ deliveryId: id,
3507
+ deploymentId: step.deploymentId,
3508
+ request: requests[index],
3509
+ progress: { generation: 0 },
3510
+ attempts: 0,
3511
+ notBefore: now,
3512
+ lease: null,
3513
+ createdAt: now,
3514
+ updatedAt: now,
3515
+ outcome: null,
3516
+ };
3517
+ insert(created);
3518
+ }
3519
+ return true;
3520
+ },
3521
+ async lastCompleted({ wallet, deploymentId, step }) {
3522
+ let last = null;
3523
+ for (const job of jobs.values()) {
3524
+ if (job.kind === 'step' &&
3525
+ job.state === 'completed' &&
3526
+ job.request.wallet === wallet &&
3527
+ job.request.step === step &&
3528
+ job.deploymentId === deploymentId) {
3529
+ last = last === null ? job.updatedAt : Math.max(last, job.updatedAt);
3530
+ }
3531
+ }
3532
+ return last;
3533
+ },
3534
+ async list(filter = {}) {
3535
+ return [...jobs.values()]
3536
+ .filter((job) => (filter.kind ? job.kind === filter.kind : true))
3537
+ .filter((job) => (filter.state ? job.state === filter.state : true))
3538
+ .sort((a, b) => sequence.get(a.id) - sequence.get(b.id))
3539
+ .map(clone);
3540
+ },
3541
+ async prune(before) {
3542
+ let removed = 0;
3543
+ for (const job of [...jobs.values()]) {
3544
+ if (isFinished(job) && job.updatedAt < before) {
3545
+ jobs.delete(job.id);
3546
+ removed += 1;
3547
+ }
3548
+ }
3549
+ return removed;
3550
+ },
3551
+ },
3552
+ consent: {
3553
+ async put(grant) {
3554
+ const index = grants.findIndex((existing) => existing.wallet === grant.wallet &&
3555
+ existing.deploymentId === grant.deploymentId &&
3556
+ existing.instanceId === grant.instanceId);
3557
+ if (index >= 0)
3558
+ grants.splice(index, 1);
3559
+ grants.push(clone(grant));
3560
+ },
3561
+ async find({ wallet, deploymentId, step, instanceId, now }) {
3562
+ const candidates = grants.filter((grant) => grant.wallet === wallet &&
3563
+ grant.deploymentId === deploymentId &&
3564
+ (grant.instanceId === null || grant.instanceId === instanceId) &&
3565
+ grant.steps.includes(step) &&
3566
+ grant.revokedAt === null &&
3567
+ grant.expiresAt > now);
3568
+ // A grant scoped to the instance is more specific than one for every instance.
3569
+ const match = candidates.find((grant) => grant.instanceId !== null) ?? candidates[0];
3570
+ return match ? clone(match) : null;
3571
+ },
3572
+ async revoke({ wallet, deploymentId, steps, instanceId, now }) {
3573
+ let revoked = 0;
3574
+ for (const [index, grant] of grants.entries()) {
3575
+ if (grant.wallet !== wallet || grant.deploymentId !== deploymentId || grant.revokedAt !== null)
3576
+ continue;
3577
+ if (instanceId !== undefined && grant.instanceId !== instanceId)
3578
+ continue;
3579
+ if (steps && !grant.steps.some((step) => steps.includes(step)))
3580
+ continue;
3581
+ const remaining = steps ? grant.steps.filter((step) => !steps.includes(step)) : [];
3582
+ grants[index] = remaining.length > 0 ? { ...grant, steps: remaining } : { ...grant, revokedAt: now };
3583
+ revoked += 1;
3584
+ }
3585
+ return revoked;
3586
+ },
3587
+ async list({ wallet }) {
3588
+ return grants.filter((grant) => grant.wallet === wallet).map(clone);
3589
+ },
3590
+ async active({ deploymentId, step, now }) {
3591
+ return grants
3592
+ .filter((grant) => grant.deploymentId === deploymentId &&
3593
+ grant.revokedAt === null &&
3594
+ grant.expiresAt > now &&
3595
+ (step === undefined || grant.steps.includes(step)))
3596
+ .map(clone);
3597
+ },
3598
+ async issueNonce(nonce, expiresAt) {
3599
+ if (!nonces.has(nonce))
3600
+ nonces.set(nonce, { expiresAt, used: false });
3601
+ },
3602
+ async useNonce(nonce, now) {
3603
+ const issued = nonces.get(nonce);
3604
+ if (!issued || issued.used || issued.expiresAt <= now)
3605
+ return false;
3606
+ nonces.set(nonce, { ...issued, used: true });
3607
+ return true;
3608
+ },
3609
+ },
3610
+ sources: {
3611
+ async get(source, key) {
3612
+ const state = sourceStates.get(`${source}\u0000${key}`);
3613
+ return state ? clone(state) : null;
3614
+ },
3615
+ async advance({ source, key, expectedVersion, next, deliver, now }) {
3616
+ const id = `${source}\u0000${key}`;
3617
+ const current = sourceStates.get(id) ?? null;
3618
+ if ((current?.version ?? null) !== expectedVersion)
3619
+ return null;
3620
+ const generation = (current?.generation ?? 0) + (current?.hash === next.hash ? 0 : 1);
3621
+ const state = {
3622
+ source,
3623
+ key,
3624
+ hash: next.hash,
3625
+ value: clone(next.value),
3626
+ generation,
3627
+ pending: next.pending ? clone(next.pending) : null,
3628
+ observed: next.observed ?? null,
3629
+ version: (current?.version ?? 0) + 1,
3630
+ updatedAt: now,
3631
+ };
3632
+ // Copy everything before changing anything: a payload that cannot be
3633
+ // stored must leave the source state as it was, or the trigger is lost.
3634
+ const delivery = deliver
3635
+ ? {
3636
+ id: `view:${encodeURIComponent(source)}:${encodeURIComponent(key)}:${generation}`,
3637
+ name: deliver.name,
3638
+ source: 'view',
3639
+ payload: clone(deliver.payload),
3640
+ receivedAt: now,
3641
+ }
3642
+ : null;
3643
+ const result = clone(state);
3644
+ sourceStates.set(id, state);
3645
+ if (delivery)
3646
+ acceptNow(delivery, now);
3647
+ return result;
3648
+ },
3649
+ async cursor(source) {
3650
+ return cursors.get(source) ?? null;
3651
+ },
3652
+ async putCursor(source, cursor) {
3653
+ if (cursorAfter(cursor, cursors.get(source) ?? null))
3654
+ cursors.set(source, cursor);
3655
+ },
3656
+ async acquireLease(source, holder, until, now) {
3657
+ const lease = sourceLeases.get(source);
3658
+ if (lease && lease.holder !== holder && lease.until > now)
3659
+ return false;
3660
+ sourceLeases.set(source, { holder, until });
3661
+ return true;
3662
+ },
3663
+ async releaseLease(source, holder) {
3664
+ if (sourceLeases.get(source)?.holder === holder)
3665
+ sourceLeases.delete(source);
3666
+ },
3667
+ },
3668
+ ledger: {
3669
+ async spent({ wallet, deploymentId, asset, since }) {
3670
+ let total = 0n;
3671
+ for (const entry of ledger) {
3672
+ if (entry.wallet === wallet &&
3673
+ entry.deploymentId === deploymentId &&
3674
+ entry.asset === asset &&
3675
+ entry.voidedAt === null &&
3676
+ entry.recordedAt >= since) {
3677
+ total += BigInt(entry.amount);
3678
+ }
3679
+ }
3680
+ return total;
3681
+ },
3682
+ async list({ wallet }) {
3683
+ return ledger.filter((entry) => entry.wallet === wallet).map(clone);
3684
+ },
3685
+ },
3686
+ };
3687
+ STATES.set(store, () => clone({ version: 1, deliveries, jobs, order, sequence, grants, deployment, nonces, ledger, sourceStates, cursors }));
3688
+ return store;
3689
+ }
3690
+
3691
+ exports.ACTIVE_STEP_STATES = ACTIVE_STEP_STATES;
3692
+ exports.AGGREGATE_KEY = AGGREGATE_KEY;
3693
+ exports.CONSENT_PROOF_VERSION = CONSENT_PROOF_VERSION;
3694
+ exports.ConsentError = ConsentError;
3695
+ exports.DEFAULT_CLUSTERS = DEFAULT_CLUSTERS;
3696
+ exports.DEFAULT_TRANSACTION_FORMATS = DEFAULT_TRANSACTION_FORMATS;
3697
+ exports.KNOWN_CLUSTERS = KNOWN_CLUSTERS;
3698
+ exports.PolicyError = PolicyError;
3699
+ exports.RUNTIME_PROGRAMS = RUNTIME_PROGRAMS;
3700
+ exports.SOL = SOL;
3701
+ exports.SignerRefusal = SignerRefusal;
3702
+ exports.StoreDeploymentError = StoreDeploymentError;
3703
+ exports.TERMINAL_DELIVERY_STATES = TERMINAL_DELIVERY_STATES;
3704
+ exports.TERMINAL_STEP_STATES = TERMINAL_STEP_STATES;
3705
+ exports.TransactionDecodeError = TransactionDecodeError;
3706
+ exports.checkPrepared = checkPrepared;
3707
+ exports.checkPreparedAgainstIntent = checkPreparedAgainstIntent;
3708
+ exports.checkSignedTransactions = checkSignedTransactions;
3709
+ exports.checkTarget = checkTarget;
3710
+ exports.clientCluster = clientCluster;
3711
+ exports.connectStack = connectStack;
3712
+ exports.consentMessage = consentMessage;
3713
+ exports.createAutomations = createAutomations;
3714
+ exports.cursorAfter = cursorAfter;
3715
+ exports.decodeTransaction = decodeTransaction;
3716
+ exports.defineAutomations = defineAutomations;
3717
+ exports.definePolicy = definePolicy;
3718
+ exports.durationMs = durationMs;
3719
+ exports.fromAppendView = fromAppendView;
3720
+ exports.fromView = fromView;
3721
+ exports.fromViewRows = fromViewRows;
3722
+ exports.isAutomationsDefinition = isAutomationsDefinition;
3723
+ exports.isSignerRefusal = isSignerRefusal;
3724
+ exports.memoryStore = memoryStore;
3725
+ exports.negotiateTransactionFormat = negotiateTransactionFormat;
3726
+ exports.normalizeConsent = normalizeConsent;
3727
+ exports.parseCursor = parseCursor;
3728
+ exports.rpcSimulator = rpcSimulator;
3729
+ exports.scheduleSlot = scheduleSlot;
3730
+ exports.stepJobId = stepJobId;
3731
+ exports.stepOutflows = stepOutflows;
3732
+ exports.summarizePrepared = summarizePrepared;
3733
+ exports.systemClock = systemClock;
3734
+ exports.windowText = windowText;
3735
+ //# sourceMappingURL=index.cjs.map