@ixo/oracle-runtime-workers 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/package.json +10 -4
- package/src/action-proposals/policy.ts +54 -0
- package/src/action-proposals/reconcile.ts +87 -0
- package/src/action-proposals/store.ts +863 -0
- package/src/action-proposals/topic-budget.ts +84 -0
- package/src/action-proposals/topic-chain.ts +272 -0
- package/src/action-proposals/topic-effects.ts +44 -0
- package/src/action-proposals/topic-errors.ts +79 -0
- package/src/action-proposals/topic-history.ts +267 -0
- package/src/action-proposals/topic-matrix.ts +292 -0
- package/src/action-proposals/topic-prepare.ts +299 -0
- package/src/action-proposals/topic-store.ts +999 -0
- package/src/action-proposals/topic-verify.ts +279 -0
- package/src/action-proposals/verify.ts +223 -0
- package/src/channels/auth.ts +6 -1
- package/src/channels/contract.ts +4 -0
- package/src/channels/turns.ts +1 -0
- package/src/core/env.ts +89 -5
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +2 -0
- package/src/core/runtime-context.ts +12 -0
- package/src/delivery/plan.ts +43 -2
- package/src/delivery/schema.ts +25 -1
- package/src/delivery/types.ts +13 -2
- package/src/do/ambient.ts +6 -0
- package/src/do/contracts.ts +39 -0
- package/src/do/user-oracle-do.ts +1253 -7
- package/src/hub-actions/client.ts +163 -0
- package/src/hub-actions/request.ts +110 -0
- package/src/hub-actions/store.ts +485 -0
- package/src/matrix/gateway-do.ts +176 -0
- package/src/matrix/reply-parts.ts +8 -2
- package/src/plugin-api/types.ts +54 -0
- package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
- package/src/plugins/action-proposals/evidence.ts +67 -0
- package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
- package/src/plugins/index.ts +6 -0
- package/src/shell/app.ts +195 -1
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
import type { VerifiedActionTransaction } from './verify';
|
|
2
|
+
import {
|
|
3
|
+
ActionProposalSchema,
|
|
4
|
+
canonicalizeIxoJson,
|
|
5
|
+
actionOutcome,
|
|
6
|
+
type ActionOutcome,
|
|
7
|
+
ActionProposalSubjectSchema,
|
|
8
|
+
assertActionProposalUnchanged,
|
|
9
|
+
digestActionProposal,
|
|
10
|
+
type ActionProposal,
|
|
11
|
+
type ActionProposalSubject,
|
|
12
|
+
} from '@ixo/ixo-transaction/action-proposals';
|
|
13
|
+
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
14
|
+
import type { TurnRequest } from '../do/contracts';
|
|
15
|
+
|
|
16
|
+
export type ActionReceiptOrigin = {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
roomId: string;
|
|
19
|
+
client: TurnRequest['client'];
|
|
20
|
+
channel?: TurnRequest['channel'];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class ActionProposalConflict extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'ActionProposalConflict';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export type ProposalCommand =
|
|
30
|
+
| { kind: 'claim'; digest: string }
|
|
31
|
+
| { kind: 'reject' | 'revoke' | 'supersede'; digest: string }
|
|
32
|
+
| { kind: 'reconcile'; transactionHash?: string };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Chain lookups for a recorded transaction: a cooldown that doubles from 30 s
|
|
36
|
+
* to 10 min between attempts, and the alarm stops asking after ten of them
|
|
37
|
+
* (about an hour). A Portal reconcile still looks the transaction up once
|
|
38
|
+
* the cooldown has passed, however many attempts the alarm made.
|
|
39
|
+
*/
|
|
40
|
+
export const VERIFY_RETRY_BASE_MS = 30_000;
|
|
41
|
+
export const VERIFY_RETRY_MAX_MS = 10 * 60_000;
|
|
42
|
+
export const VERIFY_MAX_ATTEMPTS = 10;
|
|
43
|
+
/**
|
|
44
|
+
* A hash recorded for the first time is looked up no sooner than this: the
|
|
45
|
+
* Portal reports the hash of the signed bytes BEFORE it broadcasts them.
|
|
46
|
+
*/
|
|
47
|
+
export const VERIFY_FIRST_DELAY_MS = 30_000;
|
|
48
|
+
/**
|
|
49
|
+
* "Not found" this soon after the hash arrived is the broadcast or the
|
|
50
|
+
* indexer still in flight: the lookup is rescheduled without spending one
|
|
51
|
+
* of the ten attempts.
|
|
52
|
+
*/
|
|
53
|
+
export const VERIFY_NOT_FOUND_GRACE_MS = 2 * 60_000;
|
|
54
|
+
/**
|
|
55
|
+
* Receipt posts into the originating room: the same doubling cooldown, up to
|
|
56
|
+
* an hour apart, abandoned after twelve attempts (about five hours) so a
|
|
57
|
+
* room that refuses the oracle does not keep its owner object awake for ever.
|
|
58
|
+
*/
|
|
59
|
+
export const RECEIPT_RETRY_BASE_MS = 30_000;
|
|
60
|
+
export const RECEIPT_RETRY_MAX_MS = 60 * 60_000;
|
|
61
|
+
export const RECEIPT_MAX_ATTEMPTS = 12;
|
|
62
|
+
/** Finished cards older than this are dropped; `/a/<id>` then answers 404. */
|
|
63
|
+
export const ACTION_PROPOSAL_RETENTION_MS = 90 * 24 * 60 * 60_000;
|
|
64
|
+
/**
|
|
65
|
+
* Cards that never reached an outcome the chain proved (a hash whose
|
|
66
|
+
* lookups ran out, a blocked card nobody revoked) are kept twice as long,
|
|
67
|
+
* then dropped too, so no row stays in the owner copy for ever.
|
|
68
|
+
*/
|
|
69
|
+
export const ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS = 180 * 24 * 60 * 60_000;
|
|
70
|
+
/**
|
|
71
|
+
* How long after its claim a card with no recorded transaction may still
|
|
72
|
+
* receive one. After it the card is closed as `revoked` (the existing
|
|
73
|
+
* terminal state; a new status would break strict readers of the contract):
|
|
74
|
+
* a wallet cancel or a lost claim reply must not leave it `claimed` for
|
|
75
|
+
* ever. It never goes back to `prepared`: a broadcast nobody observed is
|
|
76
|
+
* still possible, so the link stays used up.
|
|
77
|
+
*/
|
|
78
|
+
export const ACTION_PROPOSAL_EXECUTION_WINDOW_MS = 60 * 60_000;
|
|
79
|
+
/** Cards one agent run may prepare; more is a looping run, refused. */
|
|
80
|
+
export const ACTION_PROPOSALS_PER_RUN = 8;
|
|
81
|
+
/** Cards still awaiting the owner (live `prepared`, `claimed`, hash-less `unknown`) per owner. */
|
|
82
|
+
export const ACTION_PROPOSALS_OPEN_PER_OWNER = 16;
|
|
83
|
+
/** A `prepared` card counts as open this long after creation (its TTL plus clock skew). */
|
|
84
|
+
export const OPEN_PREPARED_MS = 20 * 60_000;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A card's claim time from its lifecycle JSON, NULL when the JSON is not
|
|
88
|
+
* valid (such a row is the prune's, never a deadline). Claim times are
|
|
89
|
+
* written by `toISOString()`, so they order as text.
|
|
90
|
+
*/
|
|
91
|
+
const UNEXECUTED_CLAIMED_AT = `(CASE WHEN json_valid(lifecycle) THEN json_extract(lifecycle,'$.claimedAt') END)`;
|
|
92
|
+
|
|
93
|
+
const backoff = (base: number, max: number, attempts: number) =>
|
|
94
|
+
Math.min(base * 2 ** attempts, max);
|
|
95
|
+
|
|
96
|
+
/** The verifier's answers that prove a recorded hash is not this card's transaction. */
|
|
97
|
+
/**
|
|
98
|
+
* The verifier's proof that a recorded hash is ANOTHER transaction (the
|
|
99
|
+
* chain holds it, signed under another card's memo or none): the owner may
|
|
100
|
+
* replace it. A hash mismatch is not in it: an honest node answers
|
|
101
|
+
* `/tx?hash=` with bytes that hash to the hash asked for, so that one is a
|
|
102
|
+
* faulty RPC, retried like any other read failure.
|
|
103
|
+
*/
|
|
104
|
+
export const TRANSACTION_REJECTIONS = new Set(['Transaction memo mismatch']);
|
|
105
|
+
/**
|
|
106
|
+
* The verifier's proof that the recorded transaction carries THIS card's
|
|
107
|
+
* memo but other messages: an effect the owner bound to the card that went
|
|
108
|
+
* wrong. It is never set aside; the card is blocked instead.
|
|
109
|
+
*/
|
|
110
|
+
export const TRANSACTION_CARD_BOUND_MISMATCH = 'Transaction subject mismatch';
|
|
111
|
+
|
|
112
|
+
export class ActionProposalStore {
|
|
113
|
+
constructor(
|
|
114
|
+
private readonly db: DoSqliteDatabase,
|
|
115
|
+
readonly now = Date.now,
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
async setup(): Promise<void> {
|
|
119
|
+
// The unreleased first shape of these tables (no status, hash or attempt
|
|
120
|
+
// columns) exists only where a pre-release build of this branch ran: a
|
|
121
|
+
// local harness, a wrangler state directory, a devnet test user. Nothing
|
|
122
|
+
// in them is worth a migration; the tables are rebuilt and the fact
|
|
123
|
+
// logged, so an object holding the old shape boots instead of failing
|
|
124
|
+
// every request on the index below.
|
|
125
|
+
const columns = await this.db.exec<{ name: string }>(
|
|
126
|
+
`PRAGMA table_info(action_proposals)`,
|
|
127
|
+
);
|
|
128
|
+
if (columns.length && !columns.some((c) => c.name === 'transaction_hash')) {
|
|
129
|
+
console.warn(
|
|
130
|
+
'[action-proposals] rebuilding pre-release action_proposals tables',
|
|
131
|
+
);
|
|
132
|
+
await this.db.run(`DROP TABLE IF EXISTS action_proposal_receipts`);
|
|
133
|
+
await this.db.run(`DROP TABLE IF EXISTS action_proposals`);
|
|
134
|
+
}
|
|
135
|
+
await this.db.run(
|
|
136
|
+
`CREATE TABLE IF NOT EXISTS action_proposals (id TEXT PRIMARY KEY, owner_did TEXT NOT NULL, invocation_id TEXT NOT NULL, session_id TEXT NOT NULL, run_id TEXT NOT NULL, subject TEXT NOT NULL, digest TEXT NOT NULL, lifecycle TEXT NOT NULL, status TEXT NOT NULL, transaction_hash TEXT, transaction_hash_rejected INTEGER NOT NULL DEFAULT 0, verify_after INTEGER NOT NULL DEFAULT 0, verify_attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, UNIQUE(owner_did, invocation_id))`,
|
|
137
|
+
);
|
|
138
|
+
// One signed transaction verifies one card: the memo already pins it,
|
|
139
|
+
// the index refuses a second card recording the same hash.
|
|
140
|
+
// Added after the first released shape: when the current hash arrived
|
|
141
|
+
// (the not-found grace window counts from it). Adding a column is
|
|
142
|
+
// instant in SQLite.
|
|
143
|
+
const shape = await this.db.exec<{ name: string }>(
|
|
144
|
+
`PRAGMA table_info(action_proposals)`,
|
|
145
|
+
);
|
|
146
|
+
if (!shape.some((c) => c.name === 'hash_recorded_at'))
|
|
147
|
+
await this.db.run(
|
|
148
|
+
`ALTER TABLE action_proposals ADD COLUMN hash_recorded_at INTEGER NOT NULL DEFAULT 0`,
|
|
149
|
+
);
|
|
150
|
+
// `closeUnexecuted`'s mark: 1 = closed as revoked when the execution
|
|
151
|
+
// window passed with no transaction (`unexecuted_claimed_at` keeps the
|
|
152
|
+
// claim, so a late hash can still be accepted), 2 = unreadable, out of
|
|
153
|
+
// the sweep's schedule (the prune drops it).
|
|
154
|
+
if (!shape.some((c) => c.name === 'unexecuted_close'))
|
|
155
|
+
await this.db.run(
|
|
156
|
+
`ALTER TABLE action_proposals ADD COLUMN unexecuted_close INTEGER NOT NULL DEFAULT 0`,
|
|
157
|
+
);
|
|
158
|
+
if (!shape.some((c) => c.name === 'unexecuted_claimed_at'))
|
|
159
|
+
await this.db.run(
|
|
160
|
+
`ALTER TABLE action_proposals ADD COLUMN unexecuted_claimed_at TEXT`,
|
|
161
|
+
);
|
|
162
|
+
await this.db.run(
|
|
163
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS action_proposals_transaction ON action_proposals(owner_did, transaction_hash)`,
|
|
164
|
+
);
|
|
165
|
+
await this.db.run(
|
|
166
|
+
`CREATE INDEX IF NOT EXISTS action_proposals_run ON action_proposals(owner_did, session_id, run_id)`,
|
|
167
|
+
);
|
|
168
|
+
await this.db.run(
|
|
169
|
+
`CREATE INDEX IF NOT EXISTS action_proposals_status ON action_proposals(owner_did, status, verify_after)`,
|
|
170
|
+
);
|
|
171
|
+
// The retention sweep reads `created_at` per row; without the index it
|
|
172
|
+
// walks every card's JSON overflow pages.
|
|
173
|
+
await this.db.run(
|
|
174
|
+
`CREATE INDEX IF NOT EXISTS action_proposals_created ON action_proposals(owner_did, created_at)`,
|
|
175
|
+
);
|
|
176
|
+
await this.db.run(
|
|
177
|
+
`CREATE TABLE IF NOT EXISTS action_proposal_receipts (proposal_id TEXT PRIMARY KEY, owner_did TEXT NOT NULL, origin TEXT, state TEXT NOT NULL, next_attempt INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0)`,
|
|
178
|
+
);
|
|
179
|
+
await this.db.run(
|
|
180
|
+
`CREATE INDEX IF NOT EXISTS action_proposal_receipts_due ON action_proposal_receipts(owner_did, state, next_attempt)`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Every lifecycle write goes through here so the indexed `status` and
|
|
186
|
+
* `transaction_hash` columns always mirror the JSON, and a hash another
|
|
187
|
+
* card of this owner already recorded is refused before the index does.
|
|
188
|
+
*/
|
|
189
|
+
private async writeLifecycle(
|
|
190
|
+
ownerDid: string,
|
|
191
|
+
id: string,
|
|
192
|
+
lifecycle: ActionProposal['lifecycle'],
|
|
193
|
+
/**
|
|
194
|
+
* A hash the indexed column keeps although the lifecycle variant has no
|
|
195
|
+
* field for it (a revoked blocked card): the "one hash, one card" index
|
|
196
|
+
* and the audit trail survive without changing the wire contract.
|
|
197
|
+
*/
|
|
198
|
+
retainedHash?: string,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const hash =
|
|
201
|
+
'transactionHash' in lifecycle && lifecycle.transactionHash
|
|
202
|
+
? lifecycle.transactionHash.toUpperCase()
|
|
203
|
+
: retainedHash
|
|
204
|
+
? retainedHash.toUpperCase()
|
|
205
|
+
: null;
|
|
206
|
+
if (hash) {
|
|
207
|
+
const other = await this.db.get<{ id: string }>(
|
|
208
|
+
`SELECT id FROM action_proposals WHERE owner_did=? AND transaction_hash=? AND id<>?`,
|
|
209
|
+
[ownerDid, hash, id],
|
|
210
|
+
);
|
|
211
|
+
if (other)
|
|
212
|
+
throw new ActionProposalConflict(
|
|
213
|
+
'Transaction already recorded for another proposal',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
await this.db.run(
|
|
217
|
+
`UPDATE action_proposals SET lifecycle=?,status=?,transaction_hash=? WHERE id=? AND owner_did=?`,
|
|
218
|
+
[JSON.stringify(lifecycle), lifecycle.status, hash, id, ownerDid],
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async create(
|
|
223
|
+
input: ActionProposalSubject,
|
|
224
|
+
receiptOrigin: ActionReceiptOrigin | null = null,
|
|
225
|
+
): Promise<ActionProposal> {
|
|
226
|
+
const subject = ActionProposalSubjectSchema.parse(
|
|
227
|
+
JSON.parse(canonicalizeIxoJson(input)),
|
|
228
|
+
);
|
|
229
|
+
const digest = await digestActionProposal(subject);
|
|
230
|
+
return this.db.transaction(async () => {
|
|
231
|
+
const existing = await this.db.get<{ id: string }>(
|
|
232
|
+
`SELECT id FROM action_proposals WHERE owner_did=? AND invocation_id=?`,
|
|
233
|
+
[subject.ownerDid, subject.origin.invocationId],
|
|
234
|
+
);
|
|
235
|
+
if (existing) {
|
|
236
|
+
const prior = await this.get(subject.ownerDid, existing.id);
|
|
237
|
+
if (!prior) throw new ActionProposalConflict('Proposal missing');
|
|
238
|
+
// A resumed tool call returns its original immutable proposal: the
|
|
239
|
+
// retry minted its own id (and memo) and timestamps, everything else
|
|
240
|
+
// must be what the first call prepared.
|
|
241
|
+
const retry = {
|
|
242
|
+
...subject,
|
|
243
|
+
id: prior.id,
|
|
244
|
+
createdAt: prior.createdAt,
|
|
245
|
+
expiresAt: prior.expiresAt,
|
|
246
|
+
action: {
|
|
247
|
+
...subject.action,
|
|
248
|
+
inputs: { ...subject.action.inputs, memo: prior.id },
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
if ((await digestActionProposal(retry)) !== prior.digest)
|
|
252
|
+
throw new ActionProposalConflict(
|
|
253
|
+
'Invocation already prepared different values',
|
|
254
|
+
);
|
|
255
|
+
return prior;
|
|
256
|
+
}
|
|
257
|
+
if (Date.parse(subject.expiresAt) <= this.now())
|
|
258
|
+
throw new ActionProposalConflict('Proposal expired');
|
|
259
|
+
await this.assertCapacity(
|
|
260
|
+
subject.ownerDid,
|
|
261
|
+
subject.origin.sessionId,
|
|
262
|
+
subject.origin.runId,
|
|
263
|
+
);
|
|
264
|
+
await this.db.run(
|
|
265
|
+
`INSERT INTO action_proposals (id,owner_did,invocation_id,session_id,run_id,subject,digest,lifecycle,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
|
266
|
+
[
|
|
267
|
+
subject.id,
|
|
268
|
+
subject.ownerDid,
|
|
269
|
+
subject.origin.invocationId,
|
|
270
|
+
subject.origin.sessionId,
|
|
271
|
+
subject.origin.runId,
|
|
272
|
+
JSON.stringify(subject),
|
|
273
|
+
digest,
|
|
274
|
+
JSON.stringify({ status: 'prepared' }),
|
|
275
|
+
'prepared',
|
|
276
|
+
Date.parse(subject.createdAt),
|
|
277
|
+
],
|
|
278
|
+
);
|
|
279
|
+
await this.db.run(
|
|
280
|
+
`INSERT INTO action_proposal_receipts (proposal_id,owner_did,origin,state) VALUES (?,?,?,?)`,
|
|
281
|
+
[
|
|
282
|
+
subject.id,
|
|
283
|
+
subject.ownerDid,
|
|
284
|
+
receiptOrigin ? JSON.stringify(receiptOrigin) : null,
|
|
285
|
+
receiptOrigin ? 'waiting' : 'unavailable',
|
|
286
|
+
],
|
|
287
|
+
);
|
|
288
|
+
return ActionProposalSchema.parse({
|
|
289
|
+
...subject,
|
|
290
|
+
digest,
|
|
291
|
+
lifecycle: { status: 'prepared' },
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A looping or injected run must not mint cards without bound: at most
|
|
298
|
+
* `ACTION_PROPOSALS_PER_RUN` per run and `ACTION_PROPOSALS_OPEN_PER_OWNER`
|
|
299
|
+
* still awaiting the owner. A resumed tool call never reaches here (it
|
|
300
|
+
* returns its original card above).
|
|
301
|
+
*/
|
|
302
|
+
private async assertCapacity(
|
|
303
|
+
ownerDid: string,
|
|
304
|
+
sessionId: string,
|
|
305
|
+
runId: string,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
const run = await this.db.get<{ n: number }>(
|
|
308
|
+
`SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND session_id=? AND run_id=?`,
|
|
309
|
+
[ownerDid, sessionId, runId],
|
|
310
|
+
);
|
|
311
|
+
if ((run?.n ?? 0) >= ACTION_PROPOSALS_PER_RUN)
|
|
312
|
+
throw new ActionProposalConflict(
|
|
313
|
+
'Too many action cards in this request; finish the open ones first',
|
|
314
|
+
);
|
|
315
|
+
const open = await this.db.get<{ n: number }>(
|
|
316
|
+
`SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND ((status='prepared' AND created_at>?) OR status='claimed' OR (status='unknown' AND transaction_hash IS NULL))`,
|
|
317
|
+
[ownerDid, this.now() - OPEN_PREPARED_MS],
|
|
318
|
+
);
|
|
319
|
+
if ((open?.n ?? 0) >= ACTION_PROPOSALS_OPEN_PER_OWNER)
|
|
320
|
+
throw new ActionProposalConflict(
|
|
321
|
+
'Too many action cards are open; finish or decline them first',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async get(ownerDid: string, id: string): Promise<ActionProposal | null> {
|
|
326
|
+
const row = await this.db.get<{
|
|
327
|
+
subject: string;
|
|
328
|
+
digest: string;
|
|
329
|
+
lifecycle: string;
|
|
330
|
+
}>(
|
|
331
|
+
`SELECT subject,digest,lifecycle FROM action_proposals WHERE id=? AND owner_did=?`,
|
|
332
|
+
[id, ownerDid],
|
|
333
|
+
);
|
|
334
|
+
return row
|
|
335
|
+
? assertActionProposalUnchanged({
|
|
336
|
+
...JSON.parse(row.subject),
|
|
337
|
+
digest: row.digest,
|
|
338
|
+
lifecycle: JSON.parse(row.lifecycle),
|
|
339
|
+
})
|
|
340
|
+
: null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The receipts due now, at most four, each moved to its next attempt (or
|
|
345
|
+
* abandoned on the last one) before it is handed out, so a crash between
|
|
346
|
+
* here and the post retries on schedule instead of immediately. A row this
|
|
347
|
+
* build cannot read any more is quarantined rather than retried for ever.
|
|
348
|
+
*/
|
|
349
|
+
async takeReceipts(
|
|
350
|
+
ownerDid: string,
|
|
351
|
+
): Promise<{ proposal: ActionProposal; origin: ActionReceiptOrigin }[]> {
|
|
352
|
+
return this.db.transaction(async () => {
|
|
353
|
+
const now = this.now();
|
|
354
|
+
const rows = await this.db.exec<{
|
|
355
|
+
proposal_id: string;
|
|
356
|
+
origin: string;
|
|
357
|
+
attempts: number;
|
|
358
|
+
}>(
|
|
359
|
+
`SELECT proposal_id,origin,attempts FROM action_proposal_receipts WHERE owner_did=? AND state='pending' AND next_attempt<=? ORDER BY next_attempt,proposal_id LIMIT 4`,
|
|
360
|
+
[ownerDid, now],
|
|
361
|
+
);
|
|
362
|
+
const receipts: {
|
|
363
|
+
proposal: ActionProposal;
|
|
364
|
+
origin: ActionReceiptOrigin;
|
|
365
|
+
}[] = [];
|
|
366
|
+
for (const row of rows) {
|
|
367
|
+
let proposal: ActionProposal | null;
|
|
368
|
+
try {
|
|
369
|
+
proposal = await this.get(ownerDid, row.proposal_id);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
await this.db.run(
|
|
372
|
+
`UPDATE action_proposal_receipts SET state='quarantined' WHERE owner_did=? AND proposal_id=?`,
|
|
373
|
+
[ownerDid, row.proposal_id],
|
|
374
|
+
);
|
|
375
|
+
console.error(
|
|
376
|
+
`[action-proposals] receipt ${row.proposal_id} quarantined: ${error instanceof Error ? error.message : String(error)}`,
|
|
377
|
+
);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (!proposal) {
|
|
381
|
+
// A receipt whose card is gone would stay due for ever.
|
|
382
|
+
await this.db.run(
|
|
383
|
+
`UPDATE action_proposal_receipts SET state='quarantined' WHERE owner_did=? AND proposal_id=?`,
|
|
384
|
+
[ownerDid, row.proposal_id],
|
|
385
|
+
);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const attempts = row.attempts + 1;
|
|
389
|
+
await this.db.run(
|
|
390
|
+
attempts >= RECEIPT_MAX_ATTEMPTS
|
|
391
|
+
? `UPDATE action_proposal_receipts SET state='abandoned',attempts=? WHERE owner_did=? AND proposal_id=?`
|
|
392
|
+
: `UPDATE action_proposal_receipts SET next_attempt=?,attempts=? WHERE owner_did=? AND proposal_id=?`,
|
|
393
|
+
attempts >= RECEIPT_MAX_ATTEMPTS
|
|
394
|
+
? [attempts, ownerDid, row.proposal_id]
|
|
395
|
+
: [
|
|
396
|
+
now +
|
|
397
|
+
backoff(
|
|
398
|
+
RECEIPT_RETRY_BASE_MS,
|
|
399
|
+
RECEIPT_RETRY_MAX_MS,
|
|
400
|
+
row.attempts,
|
|
401
|
+
),
|
|
402
|
+
attempts,
|
|
403
|
+
ownerDid,
|
|
404
|
+
row.proposal_id,
|
|
405
|
+
],
|
|
406
|
+
);
|
|
407
|
+
receipts.push({ proposal, origin: JSON.parse(row.origin) });
|
|
408
|
+
}
|
|
409
|
+
return receipts;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async nextReceiptAttempt(ownerDid: string): Promise<number | undefined> {
|
|
413
|
+
const row = await this.db.get<{ due: number | null }>(
|
|
414
|
+
`SELECT MIN(next_attempt) AS due FROM action_proposal_receipts WHERE owner_did=? AND state='pending'`,
|
|
415
|
+
[ownerDid],
|
|
416
|
+
);
|
|
417
|
+
return row?.due ?? undefined;
|
|
418
|
+
}
|
|
419
|
+
async receiptMirrored(ownerDid: string, id: string): Promise<void> {
|
|
420
|
+
await this.db.run(
|
|
421
|
+
`UPDATE action_proposal_receipts SET state='delivered' WHERE owner_did=? AND proposal_id=? AND state IN ('pending','abandoned')`,
|
|
422
|
+
[ownerDid, id],
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
async forRun(
|
|
426
|
+
ownerDid: string,
|
|
427
|
+
sessionId: string,
|
|
428
|
+
runId: string,
|
|
429
|
+
): Promise<ActionProposal[]> {
|
|
430
|
+
const rows = await this.db.exec<{ id: string }>(
|
|
431
|
+
`SELECT id FROM action_proposals WHERE owner_did=? AND session_id=? AND run_id=? ORDER BY verify_after,id LIMIT 32`,
|
|
432
|
+
[ownerDid, sessionId, runId],
|
|
433
|
+
);
|
|
434
|
+
const proposals: ActionProposal[] = [];
|
|
435
|
+
for (const row of rows) {
|
|
436
|
+
const p = await this.get(ownerDid, row.id);
|
|
437
|
+
if (p) proposals.push(p);
|
|
438
|
+
}
|
|
439
|
+
return proposals;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Cards with a recorded transaction the chain has not confirmed yet, whose
|
|
443
|
+
* cooldown has passed and whose attempts are not used up, oldest first.
|
|
444
|
+
*/
|
|
445
|
+
async dueVerifications(
|
|
446
|
+
ownerDid: string,
|
|
447
|
+
limit = 2,
|
|
448
|
+
): Promise<ActionProposal[]> {
|
|
449
|
+
const rows = await this.db.exec<{ id: string }>(
|
|
450
|
+
`SELECT id FROM action_proposals WHERE owner_did=? AND status='unknown' AND transaction_hash IS NOT NULL AND verify_after<=? AND verify_attempts<? ORDER BY verify_after,id LIMIT ?`,
|
|
451
|
+
[ownerDid, this.now(), VERIFY_MAX_ATTEMPTS, limit],
|
|
452
|
+
);
|
|
453
|
+
const proposals: ActionProposal[] = [];
|
|
454
|
+
for (const row of rows) {
|
|
455
|
+
try {
|
|
456
|
+
const p = await this.get(ownerDid, row.id);
|
|
457
|
+
if (p) proposals.push(p);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
// A row this build cannot read must not fail the alarm every tick:
|
|
460
|
+
// its attempts are spent and it drops out of the schedule.
|
|
461
|
+
await this.db.run(
|
|
462
|
+
`UPDATE action_proposals SET verify_attempts=? WHERE owner_did=? AND id=?`,
|
|
463
|
+
[VERIFY_MAX_ATTEMPTS, ownerDid, row.id],
|
|
464
|
+
);
|
|
465
|
+
console.error(
|
|
466
|
+
`[action-proposals] verification of ${row.id} quarantined: ${error instanceof Error ? error.message : String(error)}`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return proposals;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The verifier proved the recorded hash is another transaction (another
|
|
474
|
+
* card's memo or none): the owner may record the right one with a later
|
|
475
|
+
* reconcile instead of the card staying `unknown` for ever.
|
|
476
|
+
*/
|
|
477
|
+
async markTransactionRejected(
|
|
478
|
+
ownerDid: string,
|
|
479
|
+
id: string,
|
|
480
|
+
transactionHash: string,
|
|
481
|
+
): Promise<void> {
|
|
482
|
+
// Guarded on the hash: a verification still in flight for a hash the
|
|
483
|
+
// owner has since replaced must not reject its successor.
|
|
484
|
+
await this.db.run(
|
|
485
|
+
`UPDATE action_proposals SET transaction_hash_rejected=1 WHERE owner_did=? AND id=? AND status='unknown' AND transaction_hash=?`,
|
|
486
|
+
[ownerDid, id, transactionHash],
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* The recorded transaction carries this card's memo but other messages:
|
|
491
|
+
* an effect the owner bound to the card that went wrong. The hash stays
|
|
492
|
+
* listed and may not be replaced, verification stops, the card stays
|
|
493
|
+
* `unknown` with `executionBlockedAt`; the owner revokes it and prepares
|
|
494
|
+
* another.
|
|
495
|
+
*/
|
|
496
|
+
async blockExecution(
|
|
497
|
+
ownerDid: string,
|
|
498
|
+
id: string,
|
|
499
|
+
reason: string,
|
|
500
|
+
transactionHash: string,
|
|
501
|
+
): Promise<void> {
|
|
502
|
+
const blocked = await this.db.transaction(async () => {
|
|
503
|
+
const p = await this.get(ownerDid, id);
|
|
504
|
+
if (
|
|
505
|
+
!p ||
|
|
506
|
+
p.lifecycle.status !== 'unknown' ||
|
|
507
|
+
p.lifecycle.transactionHash !== transactionHash ||
|
|
508
|
+
p.lifecycle.executionBlockedAt
|
|
509
|
+
)
|
|
510
|
+
return false;
|
|
511
|
+
await this.writeLifecycle(ownerDid, id, {
|
|
512
|
+
...p.lifecycle,
|
|
513
|
+
executionBlockedAt: new Date(this.now()).toISOString(),
|
|
514
|
+
});
|
|
515
|
+
await this.db.run(
|
|
516
|
+
`UPDATE action_proposals SET transaction_hash_rejected=0,verify_attempts=? WHERE owner_did=? AND id=?`,
|
|
517
|
+
[VERIFY_MAX_ATTEMPTS, ownerDid, id],
|
|
518
|
+
);
|
|
519
|
+
return true;
|
|
520
|
+
});
|
|
521
|
+
if (blocked)
|
|
522
|
+
console.error(`[action-proposals] ${id} execution blocked: ${reason}`);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* The lookup just made found no transaction, inside the grace window
|
|
526
|
+
* after the hash arrived: give the attempt back and look again after the
|
|
527
|
+
* first-lookup delay. Guarded on the hash. Returns whether it applied.
|
|
528
|
+
*/
|
|
529
|
+
async deferNotFound(
|
|
530
|
+
ownerDid: string,
|
|
531
|
+
id: string,
|
|
532
|
+
transactionHash: string,
|
|
533
|
+
): Promise<boolean> {
|
|
534
|
+
const now = this.now();
|
|
535
|
+
const result = await this.db.run(
|
|
536
|
+
`UPDATE action_proposals SET verify_attempts=MAX(verify_attempts-1,0),verify_after=? WHERE owner_did=? AND id=? AND status='unknown' AND transaction_hash=? AND hash_recorded_at>?`,
|
|
537
|
+
[
|
|
538
|
+
now + VERIFY_FIRST_DELAY_MS,
|
|
539
|
+
ownerDid,
|
|
540
|
+
id,
|
|
541
|
+
transactionHash.toUpperCase(),
|
|
542
|
+
now - VERIFY_NOT_FOUND_GRACE_MS,
|
|
543
|
+
],
|
|
544
|
+
);
|
|
545
|
+
return result.changes === 1;
|
|
546
|
+
}
|
|
547
|
+
/** When the alarm should next look a recorded transaction up, if ever. */
|
|
548
|
+
async nextVerificationAt(ownerDid: string): Promise<number | undefined> {
|
|
549
|
+
const row = await this.db.get<{ due: number | null }>(
|
|
550
|
+
`SELECT MIN(verify_after) AS due FROM action_proposals WHERE owner_did=? AND status='unknown' AND transaction_hash IS NOT NULL AND verify_attempts<?`,
|
|
551
|
+
[ownerDid, VERIFY_MAX_ATTEMPTS],
|
|
552
|
+
);
|
|
553
|
+
return row?.due ?? undefined;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Close the cards claimed more than `ACTION_PROPOSAL_EXECUTION_WINDOW_MS`
|
|
557
|
+
* ago that never recorded a transaction (a wallet cancel, a lost claim
|
|
558
|
+
* reply): `revoked`, ended now. Bounded per call; returns how many closed.
|
|
559
|
+
*/
|
|
560
|
+
async closeUnexecuted(ownerDid: string): Promise<number> {
|
|
561
|
+
const now = this.now();
|
|
562
|
+
return this.db.transaction(async () => {
|
|
563
|
+
// Due rows only, oldest claim first: the same filter the deadline
|
|
564
|
+
// below reads, so a row this sweep cannot close never keeps the
|
|
565
|
+
// deadline in the past.
|
|
566
|
+
const rows = await this.db.exec<{ id: string }>(
|
|
567
|
+
`SELECT id FROM action_proposals WHERE owner_did=? AND status IN ('claimed','unknown') AND transaction_hash IS NULL AND unexecuted_close=0 AND ${UNEXECUTED_CLAIMED_AT}<=? ORDER BY ${UNEXECUTED_CLAIMED_AT},id LIMIT 16`,
|
|
568
|
+
[
|
|
569
|
+
ownerDid,
|
|
570
|
+
new Date(now - ACTION_PROPOSAL_EXECUTION_WINDOW_MS).toISOString(),
|
|
571
|
+
],
|
|
572
|
+
);
|
|
573
|
+
let closed = 0;
|
|
574
|
+
for (const row of rows) {
|
|
575
|
+
let p: ActionProposal | null;
|
|
576
|
+
try {
|
|
577
|
+
p = await this.get(ownerDid, row.id);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
// Unreadable: out of this sweep's schedule (the prune drops it),
|
|
580
|
+
// so its claim no longer holds the next deadline in the past.
|
|
581
|
+
await this.db.run(
|
|
582
|
+
`UPDATE action_proposals SET unexecuted_close=2 WHERE owner_did=? AND id=?`,
|
|
583
|
+
[ownerDid, row.id],
|
|
584
|
+
);
|
|
585
|
+
console.error(
|
|
586
|
+
`[action-proposals] unexecuted close of ${row.id} quarantined: ${error instanceof Error ? error.message : String(error)}`,
|
|
587
|
+
);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (
|
|
591
|
+
!p ||
|
|
592
|
+
(p.lifecycle.status !== 'claimed' &&
|
|
593
|
+
p.lifecycle.status !== 'unknown') ||
|
|
594
|
+
('transactionHash' in p.lifecycle && p.lifecycle.transactionHash)
|
|
595
|
+
)
|
|
596
|
+
continue;
|
|
597
|
+
await this.writeLifecycle(ownerDid, row.id, {
|
|
598
|
+
status: 'revoked',
|
|
599
|
+
endedAt: new Date(now).toISOString(),
|
|
600
|
+
});
|
|
601
|
+
await this.db.run(
|
|
602
|
+
`UPDATE action_proposals SET unexecuted_close=1,unexecuted_claimed_at=? WHERE owner_did=? AND id=?`,
|
|
603
|
+
[p.lifecycle.claimedAt, ownerDid, row.id],
|
|
604
|
+
);
|
|
605
|
+
closed++;
|
|
606
|
+
}
|
|
607
|
+
return closed;
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
/** When the next hash-less claimed card's execution window ends, if any. */
|
|
611
|
+
async nextUnexecutedCloseAt(ownerDid: string): Promise<number | undefined> {
|
|
612
|
+
const row = await this.db.get<{ claimed: string | null }>(
|
|
613
|
+
`SELECT MIN(${UNEXECUTED_CLAIMED_AT}) AS claimed FROM action_proposals WHERE owner_did=? AND status IN ('claimed','unknown') AND transaction_hash IS NULL AND unexecuted_close=0`,
|
|
614
|
+
[ownerDid],
|
|
615
|
+
);
|
|
616
|
+
const claimed = row?.claimed ? Date.parse(row.claimed) : NaN;
|
|
617
|
+
return Number.isFinite(claimed)
|
|
618
|
+
? claimed + ACTION_PROPOSAL_EXECUTION_WINDOW_MS
|
|
619
|
+
: undefined;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Drop finished cards past the retention window, with their receipts, and
|
|
623
|
+
* unresolved ones (claimed, hash-less or out of lookups, blocked) past the
|
|
624
|
+
* longer window; a card whose transaction is still being looked up stays,
|
|
625
|
+
* as does one whose receipt is still being posted.
|
|
626
|
+
*/
|
|
627
|
+
async prune(ownerDid: string): Promise<number> {
|
|
628
|
+
const now = this.now();
|
|
629
|
+
return this.db.transaction(async () => {
|
|
630
|
+
const rows = await this.db.exec<{ id: string }>(
|
|
631
|
+
`SELECT p.id FROM action_proposals p LEFT JOIN action_proposal_receipts r ON r.proposal_id=p.id WHERE p.owner_did=? AND p.created_at<? AND (r.state IS NULL OR r.state<>'pending') AND (p.status IN ('prepared','succeeded','failed','rejected','revoked','superseded') OR (p.created_at<? AND (p.status='claimed' OR (p.status='unknown' AND (p.transaction_hash IS NULL OR p.verify_attempts>=?))))) LIMIT 64`,
|
|
632
|
+
[
|
|
633
|
+
ownerDid,
|
|
634
|
+
now - ACTION_PROPOSAL_RETENTION_MS,
|
|
635
|
+
now - ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS,
|
|
636
|
+
VERIFY_MAX_ATTEMPTS,
|
|
637
|
+
],
|
|
638
|
+
);
|
|
639
|
+
for (const row of rows) {
|
|
640
|
+
await this.db.run(
|
|
641
|
+
`DELETE FROM action_proposal_receipts WHERE owner_did=? AND proposal_id=?`,
|
|
642
|
+
[ownerDid, row.id],
|
|
643
|
+
);
|
|
644
|
+
await this.db.run(
|
|
645
|
+
`DELETE FROM action_proposals WHERE owner_did=? AND id=?`,
|
|
646
|
+
[ownerDid, row.id],
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
return rows.length;
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
async outcomesForRun(
|
|
653
|
+
ownerDid: string,
|
|
654
|
+
sessionId: string,
|
|
655
|
+
runId: string,
|
|
656
|
+
): Promise<ActionOutcome[]> {
|
|
657
|
+
return (await this.forRun(ownerDid, sessionId, runId)).map((p) =>
|
|
658
|
+
actionOutcome(p, this.now()),
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Take the one chain lookup the cooldown allows: the next one may run only
|
|
663
|
+
* after a wait that doubles with every attempt made so far.
|
|
664
|
+
*/
|
|
665
|
+
async claimVerification(ownerDid: string, id: string): Promise<boolean> {
|
|
666
|
+
const now = this.now();
|
|
667
|
+
const row = await this.db.get<{ verify_attempts: number }>(
|
|
668
|
+
'SELECT verify_attempts FROM action_proposals WHERE id=? AND owner_did=? AND verify_after<=?',
|
|
669
|
+
[id, ownerDid, now],
|
|
670
|
+
);
|
|
671
|
+
if (!row) return false;
|
|
672
|
+
const result = await this.db.run(
|
|
673
|
+
'UPDATE action_proposals SET verify_after=?,verify_attempts=verify_attempts+1 WHERE id=? AND owner_did=? AND verify_after<=?',
|
|
674
|
+
[
|
|
675
|
+
now +
|
|
676
|
+
backoff(
|
|
677
|
+
VERIFY_RETRY_BASE_MS,
|
|
678
|
+
VERIFY_RETRY_MAX_MS,
|
|
679
|
+
row.verify_attempts,
|
|
680
|
+
),
|
|
681
|
+
id,
|
|
682
|
+
ownerDid,
|
|
683
|
+
now,
|
|
684
|
+
],
|
|
685
|
+
);
|
|
686
|
+
return result.changes === 1;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async recordVerified(
|
|
690
|
+
ownerDid: string,
|
|
691
|
+
id: string,
|
|
692
|
+
verified: VerifiedActionTransaction,
|
|
693
|
+
): Promise<ActionProposal | null> {
|
|
694
|
+
return this.db.transaction(async () => {
|
|
695
|
+
const p = await this.get(ownerDid, id);
|
|
696
|
+
if (!p) return null;
|
|
697
|
+
if (
|
|
698
|
+
p.lifecycle.status !== 'unknown' ||
|
|
699
|
+
p.lifecycle.transactionHash !== verified.transactionHash
|
|
700
|
+
)
|
|
701
|
+
throw new ActionProposalConflict(
|
|
702
|
+
'Transaction is not pending verification',
|
|
703
|
+
);
|
|
704
|
+
const lifecycle: ActionProposal['lifecycle'] = {
|
|
705
|
+
status: verified.code === 0 ? 'succeeded' : 'failed',
|
|
706
|
+
claimedAt: p.lifecycle.claimedAt,
|
|
707
|
+
...verified,
|
|
708
|
+
};
|
|
709
|
+
await this.writeLifecycle(ownerDid, id, lifecycle);
|
|
710
|
+
await this.db.run(
|
|
711
|
+
`UPDATE action_proposal_receipts SET state='pending',next_attempt=? WHERE owner_did=? AND proposal_id=? AND state='waiting'`,
|
|
712
|
+
[this.now(), ownerDid, id],
|
|
713
|
+
);
|
|
714
|
+
return ActionProposalSchema.parse({ ...p, lifecycle });
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async transition(
|
|
719
|
+
ownerDid: string,
|
|
720
|
+
id: string,
|
|
721
|
+
command: ProposalCommand,
|
|
722
|
+
): Promise<ActionProposal | null> {
|
|
723
|
+
return this.db.transaction(async () => {
|
|
724
|
+
const p = await this.get(ownerDid, id);
|
|
725
|
+
if (!p) return null;
|
|
726
|
+
const at = new Date(this.now()).toISOString();
|
|
727
|
+
let lifecycle: ActionProposal['lifecycle'];
|
|
728
|
+
if (command.kind === 'reconcile') {
|
|
729
|
+
if (
|
|
730
|
+
(p.lifecycle.status === 'succeeded' ||
|
|
731
|
+
p.lifecycle.status === 'failed') &&
|
|
732
|
+
(!command.transactionHash ||
|
|
733
|
+
p.lifecycle.transactionHash ===
|
|
734
|
+
command.transactionHash.toUpperCase())
|
|
735
|
+
)
|
|
736
|
+
return p;
|
|
737
|
+
if (p.lifecycle.status === 'revoked' && command.transactionHash) {
|
|
738
|
+
// Closed by `closeUnexecuted` (the window passed with no hash) and
|
|
739
|
+
// the transaction landed after all: the hash is accepted and the
|
|
740
|
+
// card goes back to `unknown` on the normal verification path.
|
|
741
|
+
// The memo still binds the transaction to this card.
|
|
742
|
+
const closed = await this.db.get<{
|
|
743
|
+
unexecuted_close: number;
|
|
744
|
+
unexecuted_claimed_at: string | null;
|
|
745
|
+
}>(
|
|
746
|
+
`SELECT unexecuted_close,unexecuted_claimed_at FROM action_proposals WHERE owner_did=? AND id=?`,
|
|
747
|
+
[ownerDid, id],
|
|
748
|
+
);
|
|
749
|
+
if (closed?.unexecuted_close === 1 && closed.unexecuted_claimed_at) {
|
|
750
|
+
lifecycle = {
|
|
751
|
+
status: 'unknown',
|
|
752
|
+
claimedAt: closed.unexecuted_claimed_at,
|
|
753
|
+
transactionHash: command.transactionHash.toUpperCase(),
|
|
754
|
+
};
|
|
755
|
+
await this.writeLifecycle(ownerDid, id, lifecycle);
|
|
756
|
+
await this.db.run(
|
|
757
|
+
`UPDATE action_proposals SET unexecuted_close=0,transaction_hash_rejected=0,verify_after=?,verify_attempts=0,hash_recorded_at=? WHERE owner_did=? AND id=?`,
|
|
758
|
+
[this.now() + VERIFY_FIRST_DELAY_MS, this.now(), ownerDid, id],
|
|
759
|
+
);
|
|
760
|
+
return { ...p, lifecycle };
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (
|
|
764
|
+
p.lifecycle.status !== 'claimed' &&
|
|
765
|
+
p.lifecycle.status !== 'unknown'
|
|
766
|
+
)
|
|
767
|
+
throw new ActionProposalConflict('Proposal was not claimed');
|
|
768
|
+
if (
|
|
769
|
+
p.lifecycle.status === 'unknown' &&
|
|
770
|
+
p.lifecycle.executionBlockedAt
|
|
771
|
+
) {
|
|
772
|
+
// The listed hash may be asked about again (Portal recovery); no
|
|
773
|
+
// other hash may take its place.
|
|
774
|
+
if (
|
|
775
|
+
!command.transactionHash ||
|
|
776
|
+
p.lifecycle.transactionHash ===
|
|
777
|
+
command.transactionHash.toUpperCase()
|
|
778
|
+
)
|
|
779
|
+
return p;
|
|
780
|
+
throw new ActionProposalConflict('Execution is blocked');
|
|
781
|
+
}
|
|
782
|
+
let replacing = false;
|
|
783
|
+
const firstHash =
|
|
784
|
+
!!command.transactionHash &&
|
|
785
|
+
!(p.lifecycle.status === 'unknown' && p.lifecycle.transactionHash);
|
|
786
|
+
if (
|
|
787
|
+
p.lifecycle.status === 'unknown' &&
|
|
788
|
+
p.lifecycle.transactionHash &&
|
|
789
|
+
command.transactionHash &&
|
|
790
|
+
p.lifecycle.transactionHash !== command.transactionHash.toUpperCase()
|
|
791
|
+
) {
|
|
792
|
+
const row = await this.db.get<{ transaction_hash_rejected: number }>(
|
|
793
|
+
`SELECT transaction_hash_rejected FROM action_proposals WHERE owner_did=? AND id=?`,
|
|
794
|
+
[ownerDid, id],
|
|
795
|
+
);
|
|
796
|
+
if (!row?.transaction_hash_rejected)
|
|
797
|
+
throw new ActionProposalConflict(
|
|
798
|
+
'Transaction reference already recorded',
|
|
799
|
+
);
|
|
800
|
+
replacing = true;
|
|
801
|
+
}
|
|
802
|
+
const hash =
|
|
803
|
+
command.transactionHash ??
|
|
804
|
+
(p.lifecycle.status === 'unknown'
|
|
805
|
+
? p.lifecycle.transactionHash
|
|
806
|
+
: undefined);
|
|
807
|
+
lifecycle = {
|
|
808
|
+
status: 'unknown',
|
|
809
|
+
claimedAt: p.lifecycle.claimedAt,
|
|
810
|
+
...(hash ? { transactionHash: hash.toUpperCase() } : {}),
|
|
811
|
+
};
|
|
812
|
+
if (replacing || firstHash)
|
|
813
|
+
// A fresh hash gets a fresh verification schedule, its first
|
|
814
|
+
// lookup no sooner than `VERIFY_FIRST_DELAY_MS` (the Portal may
|
|
815
|
+
// report it before broadcasting). The same hash again changes
|
|
816
|
+
// nothing.
|
|
817
|
+
await this.db.run(
|
|
818
|
+
`UPDATE action_proposals SET transaction_hash_rejected=0,verify_after=?,verify_attempts=0,hash_recorded_at=? WHERE owner_did=? AND id=?`,
|
|
819
|
+
[this.now() + VERIFY_FIRST_DELAY_MS, this.now(), ownerDid, id],
|
|
820
|
+
);
|
|
821
|
+
} else {
|
|
822
|
+
if (p.digest !== command.digest)
|
|
823
|
+
throw new ActionProposalConflict('Confirmed values changed');
|
|
824
|
+
if (
|
|
825
|
+
command.kind === 'revoke' &&
|
|
826
|
+
p.lifecycle.status === 'unknown' &&
|
|
827
|
+
p.lifecycle.executionBlockedAt
|
|
828
|
+
) {
|
|
829
|
+
// A blocked card never confirms or fails: revoking it is the
|
|
830
|
+
// owner's way to close it, expiry or not. Its memo-bound hash stays
|
|
831
|
+
// in the indexed column (the `revoked` variant has no field for
|
|
832
|
+
// it), so no other card can record it and the audit trail holds.
|
|
833
|
+
await this.writeLifecycle(
|
|
834
|
+
ownerDid,
|
|
835
|
+
id,
|
|
836
|
+
{ status: 'revoked', endedAt: at },
|
|
837
|
+
p.lifecycle.transactionHash,
|
|
838
|
+
);
|
|
839
|
+
return { ...p, lifecycle: { status: 'revoked', endedAt: at } };
|
|
840
|
+
} else {
|
|
841
|
+
if (p.lifecycle.status !== 'prepared')
|
|
842
|
+
throw new ActionProposalConflict('Proposal is already consumed');
|
|
843
|
+
if (Date.parse(p.expiresAt) <= this.now())
|
|
844
|
+
throw new ActionProposalConflict('Proposal expired');
|
|
845
|
+
lifecycle =
|
|
846
|
+
command.kind === 'claim'
|
|
847
|
+
? { status: 'claimed', claimedAt: at }
|
|
848
|
+
: {
|
|
849
|
+
status:
|
|
850
|
+
command.kind === 'reject'
|
|
851
|
+
? 'rejected'
|
|
852
|
+
: command.kind === 'revoke'
|
|
853
|
+
? 'revoked'
|
|
854
|
+
: 'superseded',
|
|
855
|
+
endedAt: at,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
await this.writeLifecycle(ownerDid, id, lifecycle);
|
|
860
|
+
return { ...p, lifecycle };
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|