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