@elisym/cli 0.30.0 → 0.31.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/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { ReadableStream } from 'node:stream/web';
3
3
  import { readFileSync, existsSync, readdirSync, statSync, renameSync, chmodSync, mkdirSync, writeFileSync, appendFileSync, unlinkSync, rmdirSync, realpathSync } from 'node:fs';
4
4
  import { dirname, join, resolve, basename, extname, relative, sep } from 'node:path';
5
- import { SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, splAssetsForNetwork, formatSol, formatAssetAmount, signerFromSecretKeyBase58, resolveUsdcAsset, ElisymClient, KIND_EXTERNAL_IDENTITIES, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, DEFAULTS, createBlossomTransport, generateSolanaWallet, verifyAgentIdentities, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, utf8ByteLength, readAcceptedTransports, resolveDelegationAsset, MAX_PROOF_TTL_SECS, PROOF_CLOCK_SKEW_SECS, verifyDelegationAuthProof, deriveOwnerDelegationAta, getDelegation, buildDelegatedTransfer, buildSignedPull, sendConfirmToTerminal, parseDelegatedPayment, confirmPullToTerminal, isDefinitelyUnpaid, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, NATIVE_SOL, GIST_ID_REGEX, TWEET_ID_REGEX } from '@elisym/sdk';
5
+ import { DEFAULTS, SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, splAssetsForNetwork, formatSol, formatAssetAmount, signerFromSecretKeyBase58, resolveUsdcAsset, ElisymClient, KIND_EXTERNAL_IDENTITIES, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, createBlossomTransport, generateSolanaWallet, verifyAgentIdentities, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, utf8ByteLength, readAcceptedTransports, resolveDelegationAsset, MAX_PROOF_TTL_SECS, PROOF_CLOCK_SKEW_SECS, verifyDelegationAuthProof, deriveOwnerDelegationAta, getDelegation, buildDelegatedTransfer, buildSignedPull, sendConfirmToTerminal, parseDelegatedPayment, confirmPullToTerminal, isDefinitelyUnpaid, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, NATIVE_SOL, GIST_ID_REGEX, TWEET_ID_REGEX } from '@elisym/sdk';
6
6
  import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, readMediaCache, loadPoliciesFromDir, ensureGitignoreHasIrohEntry, ensureGitignoreHasX402Entries, ensureGitignoreHasSessionsEntry, ensureGitignoreHasDelegationNoncesEntry, readAgentPublic, lookupCachedUrl, newCacheEntry, writeMediaCache } from '@elisym/sdk/agent-store';
7
7
  import { isAddress, createSolanaRpc, address, signature } from '@solana/kit';
8
8
  import { generateSecretKey, getPublicKey, nip19, verifyEvent } from 'nostr-tools';
@@ -1499,6 +1499,8 @@ var init_init = __esm({
1499
1499
  var MAX_CONCURRENT_JOBS = 10;
1500
1500
  var RECOVERY_MAX_RETRIES = 5;
1501
1501
  var RECOVERY_INTERVAL_SECS = 60;
1502
+ var MAX_PAID_AGE_MS = 24 * 60 * 60 * 1e3;
1503
+ var LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
1502
1504
  var WATCHDOG_PROBE_INTERVAL_MS = 5 * 60 * 1e3;
1503
1505
  var WATCHDOG_PROBE_TIMEOUT_MS = 1e4;
1504
1506
  var WATCHDOG_SELF_PING_INTERVAL_MS = 10 * 60 * 1e3;
@@ -3283,8 +3285,15 @@ var VALID_TRANSITIONS = {
3283
3285
  delivered: [],
3284
3286
  failed: []
3285
3287
  };
3288
+ var MAX_DOUBLE_SETTLE_WARNINGS = 20;
3286
3289
  var JobLedger = class {
3287
3290
  entries = /* @__PURE__ */ new Map();
3291
+ /**
3292
+ * settlement signature -> the job that consumed it. Derived state, rebuilt
3293
+ * from `entries` on every {@link load} - the entries themselves are the
3294
+ * durable record.
3295
+ */
3296
+ paymentSignatureOwners = /* @__PURE__ */ new Map();
3288
3297
  path;
3289
3298
  /**
3290
3299
  * @param ledgerPath absolute path to the ledger file
@@ -3299,8 +3308,23 @@ var JobLedger = class {
3299
3308
  try {
3300
3309
  const raw = readFileSync(this.path, "utf-8");
3301
3310
  const data = JSON.parse(raw);
3311
+ let unusable = 0;
3302
3312
  for (const [id, entry] of Object.entries(data)) {
3303
- this.entries.set(id, entry);
3313
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
3314
+ unusable += 1;
3315
+ continue;
3316
+ }
3317
+ const candidate = entry;
3318
+ if (typeof candidate.job_id !== "string" || candidate.job_id.length === 0) {
3319
+ unusable += 1;
3320
+ continue;
3321
+ }
3322
+ this.entries.set(id, candidate);
3323
+ }
3324
+ if (unusable > 0) {
3325
+ console.warn(
3326
+ ` ! Ledger load warning: skipped ${unusable} unusable ${unusable === 1 ? "entry" : "entries"} in ${this.path} - not an object, or carrying no job id. They cannot be routed or recovered, and the next write will not preserve them: copy the file before restarting if its history matters.`
3327
+ );
3304
3328
  }
3305
3329
  } catch (e) {
3306
3330
  if (e?.code !== "ENOENT") {
@@ -3313,6 +3337,7 @@ var JobLedger = class {
3313
3337
  }
3314
3338
  }
3315
3339
  }
3340
+ this.indexPaymentSignatures();
3316
3341
  }
3317
3342
  flush() {
3318
3343
  const dir = dirname(this.path);
@@ -3320,8 +3345,8 @@ var JobLedger = class {
3320
3345
  const obj = Object.fromEntries(this.entries);
3321
3346
  const tmp = this.path + ".tmp";
3322
3347
  writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: LEDGER_FILE_MODE });
3348
+ chmodSync(tmp, LEDGER_FILE_MODE);
3323
3349
  renameSync(tmp, this.path);
3324
- chmodSync(this.path, LEDGER_FILE_MODE);
3325
3350
  }
3326
3351
  recordPaid(entry) {
3327
3352
  if (this.entries.has(entry.job_id)) {
@@ -3376,6 +3401,121 @@ var JobLedger = class {
3376
3401
  this.flush();
3377
3402
  }
3378
3403
  }
3404
+ /**
3405
+ * Build the settlement-signature index from the loaded entries. A signature
3406
+ * recorded on two entries is the on-disk fingerprint of a double settle -
3407
+ * shout about it rather than silently picking a winner, but once per SIGNATURE
3408
+ * and capped overall: a corrupt ledger repeating one signature across
3409
+ * thousands of entries buried every other startup line under ~30 000
3410
+ * identical warnings. The suppressed count is reported at the end.
3411
+ */
3412
+ indexPaymentSignatures() {
3413
+ const warnedSignatures = /* @__PURE__ */ new Set();
3414
+ let suppressed = 0;
3415
+ for (const entry of this.entries.values()) {
3416
+ const paymentSignature = entry.payment_signature;
3417
+ if (typeof paymentSignature !== "string" || paymentSignature.length === 0) {
3418
+ continue;
3419
+ }
3420
+ const owner = this.paymentSignatureOwners.get(paymentSignature);
3421
+ if (owner !== void 0 && owner !== entry.job_id) {
3422
+ if (warnedSignatures.has(paymentSignature)) {
3423
+ continue;
3424
+ }
3425
+ warnedSignatures.add(paymentSignature);
3426
+ if (warnedSignatures.size > MAX_DOUBLE_SETTLE_WARNINGS) {
3427
+ suppressed += 1;
3428
+ continue;
3429
+ }
3430
+ console.warn(
3431
+ ` ! DOUBLE SETTLE in the ledger: on-chain settlement ${paymentSignature} is recorded for BOTH job ${owner} and job ${entry.job_id}. One transaction must settle only one job - audit both before trusting this agent's payment history.`
3432
+ );
3433
+ continue;
3434
+ }
3435
+ this.paymentSignatureOwners.set(paymentSignature, entry.job_id);
3436
+ }
3437
+ if (suppressed > 0) {
3438
+ console.warn(
3439
+ ` ! DOUBLE SETTLE: ${suppressed} further duplicated settlement signature(s) not listed. This ledger is corrupt - audit it in full.`
3440
+ );
3441
+ }
3442
+ }
3443
+ /** The job that consumed `paymentSignature`, or undefined when unclaimed. */
3444
+ paymentSignatureOwner(paymentSignature) {
3445
+ return this.paymentSignatureOwners.get(paymentSignature);
3446
+ }
3447
+ /**
3448
+ * Bind an on-chain settlement signature to exactly one job - the whole of
3449
+ * "one transaction settles one job" for the FLAT paid path. It exists because
3450
+ * the SDK verifier is stateless by contract (see
3451
+ * `PaymentStrategy.verifyPayment`), so one transfer carrying N job references
3452
+ * verifies for all N and only the provider can pick the single job it settles.
3453
+ *
3454
+ * The exactly-once property comes from this method being SYNCHRONOUS: index
3455
+ * read and write-back happen in one uninterrupted turn of the event loop, so
3456
+ * N concurrent jobs presenting the same signature serialize and exactly one
3457
+ * wins - the same shape as {@link UsedNonceStore}'s `has` -> `markUsed` pair.
3458
+ * Never make this async.
3459
+ *
3460
+ * SCOPE: one ledger file, i.e. one agent directory, in one process. Two
3461
+ * `elisym start` processes sharing a directory are deliberately not
3462
+ * serialized against each other (no cross-process lock) - run one per agent
3463
+ * directory. Separate directories are separate providers with separate
3464
+ * wallets, so they have nothing to de-duplicate in common.
3465
+ *
3466
+ * A re-claim by the SAME job always succeeds: live re-confirmation and crash
3467
+ * recovery re-verifying its own payment must both keep working.
3468
+ *
3469
+ * A job that claims a SECOND, DIFFERENT signature RELEASES the first one's
3470
+ * index mark, because the entry carries exactly one `payment_signature` and
3471
+ * the index must not outlive the entry field that justifies it - the prune
3472
+ * path relies on "every mark corresponds to a persisted `payment_signature`".
3473
+ * Releasing it is safe, not generous: a second claim is only reachable while
3474
+ * the job is still unconfirmed (`net_amount` unset, which is the only state
3475
+ * that re-runs verification), so the released signature settled nothing and
3476
+ * is exactly as unowned as it was before this job ever looked at it.
3477
+ *
3478
+ * That release is guarded on OWNERSHIP, and so is its rollback. On a ledger
3479
+ * that already double-settles (two entries carrying one signature -
3480
+ * `indexPaymentSignatures` warns and keeps the first as owner), this entry's
3481
+ * `payment_signature` can name a mark another job holds. Releasing it
3482
+ * unguarded would free a settlement the rightful owner still needs, and
3483
+ * re-assigning it unguarded on a failed flush would hand that owner's mark to
3484
+ * this job - refusing the owner its own settlement until the next restart.
3485
+ */
3486
+ claimPaymentSignature(paymentSignature, jobId) {
3487
+ const entry = this.entries.get(jobId);
3488
+ if (entry === void 0) {
3489
+ return "unknown-job";
3490
+ }
3491
+ const owner = this.paymentSignatureOwners.get(paymentSignature);
3492
+ if (owner !== void 0 && owner !== jobId) {
3493
+ return "consumed-by-other";
3494
+ }
3495
+ const previousSignature = entry.payment_signature;
3496
+ const previousOwner = owner;
3497
+ const releasedSignature = typeof previousSignature === "string" && previousSignature !== paymentSignature && this.paymentSignatureOwners.get(previousSignature) === jobId ? previousSignature : void 0;
3498
+ this.paymentSignatureOwners.set(paymentSignature, jobId);
3499
+ if (releasedSignature !== void 0) {
3500
+ this.paymentSignatureOwners.delete(releasedSignature);
3501
+ }
3502
+ entry.payment_signature = paymentSignature;
3503
+ try {
3504
+ this.flush();
3505
+ } catch {
3506
+ entry.payment_signature = previousSignature;
3507
+ if (previousOwner === void 0) {
3508
+ this.paymentSignatureOwners.delete(paymentSignature);
3509
+ } else {
3510
+ this.paymentSignatureOwners.set(paymentSignature, previousOwner);
3511
+ }
3512
+ if (releasedSignature !== void 0) {
3513
+ this.paymentSignatureOwners.set(releasedSignature, jobId);
3514
+ }
3515
+ return "not-persisted";
3516
+ }
3517
+ return "claimed";
3518
+ }
3379
3519
  /** Attempt a state transition. Returns the entry if valid, undefined otherwise. */
3380
3520
  transition(jobId, to) {
3381
3521
  const entry = this.entries.get(jobId);
@@ -3448,10 +3588,6 @@ var JobLedger = class {
3448
3588
  allEntries() {
3449
3589
  return [...this.entries.values()];
3450
3590
  }
3451
- /** Remove old delivered/failed entries (default: 7 days). */
3452
- gc(maxAgeSecs = 7 * 24 * 60 * 60) {
3453
- this.pruneOldEntries(maxAgeSecs * 1e3);
3454
- }
3455
3591
  /**
3456
3592
  * Drop terminal entries (`delivered` / `failed`) whose `created_at`
3457
3593
  * predates `now - retentionMs`. Stuck non-terminal entries are never
@@ -3462,18 +3598,48 @@ var JobLedger = class {
3462
3598
  */
3463
3599
  pruneOldEntries(retentionMs) {
3464
3600
  const cutoff = Math.floor(Date.now() / 1e3) - Math.floor(retentionMs / 1e3);
3601
+ const prunedSignatures = /* @__PURE__ */ new Set();
3465
3602
  let deleted = 0;
3466
3603
  for (const [id, entry] of this.entries) {
3467
3604
  if ((entry.status === "delivered" || entry.status === "failed") && entry.created_at < cutoff) {
3468
3605
  this.entries.delete(id);
3606
+ if (typeof entry.payment_signature === "string" && entry.payment_signature.length > 0) {
3607
+ prunedSignatures.add(entry.payment_signature);
3608
+ }
3469
3609
  deleted += 1;
3470
3610
  }
3471
3611
  }
3612
+ if (prunedSignatures.size > 0) {
3613
+ this.releasePrunedPaymentSignatures(prunedSignatures);
3614
+ }
3472
3615
  if (deleted > 0) {
3473
3616
  this.flush();
3474
3617
  }
3475
3618
  return deleted;
3476
3619
  }
3620
+ /**
3621
+ * Release the index marks of PRUNED entries, so the index cannot outgrow the
3622
+ * ledger. Every mark corresponds to a persisted `payment_signature` (a claim
3623
+ * whose flush failed rolls both back), so the entries are the whole story.
3624
+ * Safe: `LEDGER_RETENTION_MS` is far past Solana's ~2-3 day history horizon,
3625
+ * so a pruned signature can no longer verify for any job. A signature still
3626
+ * carried by a SURVIVING entry (the fingerprint of a double settle) is kept
3627
+ * whoever owns it - pruning one side must never hand the transaction to a
3628
+ * third job.
3629
+ */
3630
+ releasePrunedPaymentSignatures(prunedSignatures) {
3631
+ const survivingSignatures = /* @__PURE__ */ new Set();
3632
+ for (const entry of this.entries.values()) {
3633
+ if (typeof entry.payment_signature === "string") {
3634
+ survivingSignatures.add(entry.payment_signature);
3635
+ }
3636
+ }
3637
+ for (const paymentSignature of prunedSignatures) {
3638
+ if (!survivingSignatures.has(paymentSignature)) {
3639
+ this.paymentSignatureOwners.delete(paymentSignature);
3640
+ }
3641
+ }
3642
+ }
3477
3643
  };
3478
3644
  var NONCE_STORE_MAX_ENTRIES = 1e4;
3479
3645
  var UsedNonceStore = class {
@@ -3700,6 +3866,664 @@ var MIME_BY_EXT = {
3700
3866
  function mimeFromPath(path) {
3701
3867
  return MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
3702
3868
  }
3869
+ var REFERENCE_SCAN_WINDOW = DEFAULTS.VERIFY_SIGNATURE_LIMIT;
3870
+ var REFERENCE_SCAN_LIST_ATTEMPTS = 3;
3871
+ var REFERENCE_SCAN_LIST_RETRY_DELAY_MS = 400;
3872
+ var REFERENCE_SCAN_VERIFY_RETRIES = REFERENCE_SCAN_LIST_ATTEMPTS;
3873
+ var OWN_SETTLEMENT_VERIFY_RETRIES = REFERENCE_SCAN_VERIFY_RETRIES + 2;
3874
+ var REFERENCE_SCAN_DEADLINE_MS = 3e4;
3875
+ var CLUSTER_GENESIS_HASHES = {
3876
+ mainnet: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
3877
+ devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG"
3878
+ };
3879
+ var ADDRESS_HISTORY_PROBE_ADDRESS = "11111111111111111111111111111111";
3880
+ var RECOVERY_DEFER_BACKOFF_TICKS = [1, 2, 5, 15, 30, 60];
3881
+ var RECOVERY_DEFER_JITTER_TICKS = 0.5;
3882
+ var TERMINAL_CONFIRMATION_MIN_RUNG = 3;
3883
+ var RECOVERY_SCAN_BUDGET_DIVISOR = 2;
3884
+ function recoveryScanBudgetPerTick(maxConcurrentJobs) {
3885
+ return Math.max(1, Math.floor(maxConcurrentJobs / RECOVERY_SCAN_BUDGET_DIVISOR));
3886
+ }
3887
+ function recoveryDeferDelayMs(attempts, recoveryIntervalSecs, jitter) {
3888
+ const rung = Math.min(attempts, RECOVERY_DEFER_BACKOFF_TICKS.length) - 1;
3889
+ const ticks = RECOVERY_DEFER_BACKOFF_TICKS[rung] ?? 1;
3890
+ const intervalMs = recoveryIntervalSecs * 1e3;
3891
+ const jitterMs = jitter * RECOVERY_DEFER_JITTER_TICKS * intervalMs;
3892
+ return ticks * intervalMs + jitterMs;
3893
+ }
3894
+ function waitMs(ms) {
3895
+ return new Promise((resolve4) => {
3896
+ setTimeout(resolve4, ms);
3897
+ });
3898
+ }
3899
+ function needsPaymentScan(entry, delegated) {
3900
+ return entry.status === "paid" && !entry.net_amount && entry.payment_request !== void 0 && !delegated;
3901
+ }
3902
+ function claimRefusalSentence(claim) {
3903
+ switch (claim) {
3904
+ case "consumed-by-other":
3905
+ return "the transaction carrying this job reference was already consumed by another job, so it is not attributable here";
3906
+ case "not-persisted":
3907
+ return "the claim on that settlement could not be written to disk, so the payment was refused rather than risk delivering twice for one transfer";
3908
+ case "unknown-job":
3909
+ return "this job has no ledger entry, so the claim had nowhere durable to live";
3910
+ }
3911
+ }
3912
+ var UNNAMED_SETTLEMENT_SENTENCE = "the payment verified without a settlement signature, so it cannot be bound to one job";
3913
+ var RecoveryDeferrals = class {
3914
+ /**
3915
+ * `recoveryIntervalSecs` is read through a function because the backoff
3916
+ * ladder is counted in TICKS and the runtime owns the tick length.
3917
+ * `random` returns `[0, 1)`; injected so the jitter is testable.
3918
+ */
3919
+ constructor(recoveryIntervalSecs, random = Math.random) {
3920
+ this.recoveryIntervalSecs = recoveryIntervalSecs;
3921
+ this.random = random;
3922
+ }
3923
+ deferrals = /* @__PURE__ */ new Map();
3924
+ /** How many entries are currently held back. */
3925
+ get size() {
3926
+ return this.deferrals.size;
3927
+ }
3928
+ /** This entry's deferral state, or undefined when it is not held back. */
3929
+ peek(jobId) {
3930
+ return this.deferrals.get(jobId);
3931
+ }
3932
+ /**
3933
+ * Record that a recovery tick ended INCONCLUSIVE for this entry and push its
3934
+ * next attempt out by the backoff ladder.
3935
+ *
3936
+ * A deferral is cheap to decide and expensive to repeat: a full reference
3937
+ * scan (an RPC listing plus a transaction fetch per candidate) holding a
3938
+ * `p-limit` slot and a queue slot shared with live intake. Repeating that
3939
+ * every 60s for 24h is how a handful of stuck entries turns into "Server
3940
+ * overloaded" for paying customers. Retries are still NOT burned - a deferral
3941
+ * is never evidence against the customer - the wait simply lengthens.
3942
+ *
3943
+ * `sawNoPayment` carries forward the one observation that can ever become
3944
+ * terminal, and is cleared by any other outcome - that is what "two
3945
+ * CONSECUTIVE clean empty scans" means.
3946
+ */
3947
+ note(jobId, sawNoPayment = false) {
3948
+ const previous = this.deferrals.get(jobId);
3949
+ const attempts = (previous?.attempts ?? 0) + 1;
3950
+ const delayMs = recoveryDeferDelayMs(
3951
+ sawNoPayment ? Math.max(attempts, TERMINAL_CONFIRMATION_MIN_RUNG) : attempts,
3952
+ this.recoveryIntervalSecs(),
3953
+ this.random() * 2 - 1
3954
+ );
3955
+ this.deferrals.set(jobId, {
3956
+ attempts,
3957
+ nextAttemptAt: Date.now() + delayMs,
3958
+ firstDeferredAt: previous?.firstDeferredAt ?? Date.now(),
3959
+ sawNoPayment
3960
+ });
3961
+ }
3962
+ /**
3963
+ * Record a scan that found an empty reference INSIDE the payment window this
3964
+ * provider itself advertised.
3965
+ *
3966
+ * Deliberately off the backoff ladder. The ladder counts inconclusive looks -
3967
+ * moments where the chain, the RPC or our own disk let us down - and "the
3968
+ * customer still has eight of their ten minutes left" is none of those: it is
3969
+ * the expected state of an ordinary job that nobody has paid yet. Counting it
3970
+ * cost twice over. A customer whose wallet confirms four minutes in was not
3971
+ * looked at again until minute nine, because the ladder had already climbed to
3972
+ * its 5-tick rung while they were still entitled to pay. And a job nobody ever
3973
+ * pays reached its expiry already parked on a 15- or 30-tick rung, so the two
3974
+ * consecutive scans that close it landed the better part of an hour later -
3975
+ * holding a `paid` entry, and its recovery slot, for all of it.
3976
+ *
3977
+ * So: hold the entry back for one tick, leave `attempts` where it is, and let
3978
+ * the ladder start climbing only once the window is actually over. The cost is
3979
+ * one `getSignaturesForAddress` per tick per unpaid job for the length of the
3980
+ * window (ten minutes by default) - an empty scan fetches no transactions -
3981
+ * and the per-tick scan budget still bounds how many run at once.
3982
+ *
3983
+ * `sawNoPayment` is cleared for the same reason it is cleared on any other
3984
+ * inconclusive outcome: this scan must never count toward the two consecutive
3985
+ * sightings that fail a job, because it was taken before the customer's
3986
+ * deadline.
3987
+ */
3988
+ noteAwaitingWindow(jobId) {
3989
+ const previous = this.deferrals.get(jobId);
3990
+ const delayMs = recoveryDeferDelayMs(1, this.recoveryIntervalSecs(), this.random() * 2 - 1);
3991
+ this.deferrals.set(jobId, {
3992
+ attempts: previous?.attempts ?? 0,
3993
+ nextAttemptAt: Date.now() + delayMs,
3994
+ firstDeferredAt: previous?.firstDeferredAt ?? Date.now(),
3995
+ sawNoPayment: false
3996
+ });
3997
+ }
3998
+ /**
3999
+ * Park an entry whose payment re-scan did not fit in this tick's scan budget.
4000
+ *
4001
+ * NOT a deferral: nothing was attempted, so the ladder does not advance and
4002
+ * the `sawNoPayment` observation is neither set nor cleared. The wait is
4003
+ * deliberately SUB-TICK, so a parked entry is due again by the next tick
4004
+ * rather than pushed minutes into the future for a queue that was merely
4005
+ * busy. What spreads a restart's backlog across ticks is the per-tick budget
4006
+ * itself (see {@link recoveryScanBudgetPerTick}), not this wait; the jitter
4007
+ * only stops a whole parked batch from becoming due on the same millisecond.
4008
+ */
4009
+ parkForCapacity(jobId) {
4010
+ const previous = this.deferrals.get(jobId);
4011
+ const jitteredMs = this.random() * this.recoveryIntervalSecs() * 1e3;
4012
+ this.deferrals.set(jobId, {
4013
+ attempts: previous?.attempts ?? 0,
4014
+ nextAttemptAt: Date.now() + jitteredMs,
4015
+ firstDeferredAt: previous?.firstDeferredAt ?? Date.now(),
4016
+ sawNoPayment: previous?.sawNoPayment ?? false
4017
+ });
4018
+ }
4019
+ /**
4020
+ * Forget this entry's deferral state entirely. Called when the entry stops
4021
+ * being deferred at all - its payment confirmed - so the backlog summary's
4022
+ * "oldest deferred" cannot keep ageing on an entry nobody is holding back.
4023
+ */
4024
+ clear(jobId) {
4025
+ this.deferrals.delete(jobId);
4026
+ }
4027
+ /** Whether the LAST attempt on this entry saw a complete, empty reference history. */
4028
+ sawNoPayment(jobId) {
4029
+ return this.deferrals.get(jobId)?.sawNoPayment === true;
4030
+ }
4031
+ /** Whether this entry is still inside its deferral backoff window. */
4032
+ isDeferred(jobId) {
4033
+ const deferral = this.deferrals.get(jobId);
4034
+ return deferral !== void 0 && Date.now() < deferral.nextAttemptAt;
4035
+ }
4036
+ /**
4037
+ * Drop the state of entries that have left the pending set (delivered,
4038
+ * failed, pruned), keeping this map bounded by the pending set rather than by
4039
+ * the ledger's whole history.
4040
+ */
4041
+ sweep(stillPending) {
4042
+ if (this.deferrals.size === 0) {
4043
+ return;
4044
+ }
4045
+ for (const jobId of [...this.deferrals.keys()]) {
4046
+ if (!stillPending.has(jobId)) {
4047
+ this.deferrals.delete(jobId);
4048
+ }
4049
+ }
4050
+ }
4051
+ /**
4052
+ * One line per tick summing up the backlog, or `null` when there is nothing
4053
+ * to say. A pinned entry otherwise produces the same per-entry line as a
4054
+ * flaky RPC, roughly thirty times a day, and an operator has no way to tell
4055
+ * "one job is stuck" from "the chain is unreachable". The oldest age is the
4056
+ * number that matters: it says how close the backlog is to the 24h cutoff.
4057
+ */
4058
+ summaryLine() {
4059
+ if (this.deferrals.size === 0) {
4060
+ return null;
4061
+ }
4062
+ let oldestFirstDeferredAt = Number.POSITIVE_INFINITY;
4063
+ for (const deferral of this.deferrals.values()) {
4064
+ oldestFirstDeferredAt = Math.min(oldestFirstDeferredAt, deferral.firstDeferredAt);
4065
+ }
4066
+ const oldestMinutes = Math.floor((Date.now() - oldestFirstDeferredAt) / 6e4);
4067
+ const noun = this.deferrals.size === 1 ? "job is" : "jobs are";
4068
+ return `Recovery: ${this.deferrals.size} ${noun} deferred awaiting payment confirmation (oldest deferred ${oldestMinutes}m ago; the 24h cutoff closes them).`;
4069
+ }
4070
+ };
4071
+ var PaymentRecovery = class {
4072
+ constructor(ledger, network, fetchProtocolConfig, strategy) {
4073
+ this.ledger = ledger;
4074
+ this.network = network;
4075
+ this.fetchProtocolConfig = fetchProtocolConfig;
4076
+ this.strategy = strategy;
4077
+ }
4078
+ /**
4079
+ * Whether the RPC endpoint has earned the right to end a paying customer's
4080
+ * job. Process-wide and cached, because it is a property of the URL this
4081
+ * process was started with, not of any one scan.
4082
+ *
4083
+ * Only the two STABLE answers are cached. `wrong-cluster` cannot change
4084
+ * without a restart, and `sound` is not worth re-proving on every verdict.
4085
+ * Everything else (an unreachable endpoint, a throttled probe, a node that
4086
+ * refuses address history) stays `unchecked` and is re-probed next tick: an
4087
+ * endpoint that is merely down must not be condemned permanently, and one
4088
+ * that never answers simply never unlocks the verdict.
4089
+ */
4090
+ endpointSoundness = "unchecked";
4091
+ /**
4092
+ * The probe currently in flight, so several entries reaching a verdict on the
4093
+ * same tick share ONE pair of RPC calls instead of each making its own before
4094
+ * the first has had a chance to cache its answer.
4095
+ */
4096
+ endpointProbe;
4097
+ /** Reason tags already shouted about, so the loud line is loud exactly once each. */
4098
+ warnedEndpointReasons = /* @__PURE__ */ new Set();
4099
+ /** Shout about an endpoint we will not end jobs on - once per distinct reason. */
4100
+ warnUntrustedEndpoint(reason, log, detail) {
4101
+ if (this.warnedEndpointReasons.has(reason)) {
4102
+ return;
4103
+ }
4104
+ this.warnedEndpointReasons.add(reason);
4105
+ log(
4106
+ ` ! WARNING: the Solana RPC endpoint cannot be trusted to decide that a customer did not pay: ${detail} Paid jobs whose payment cannot be found will NOT be closed as unpaid - they stay recoverable until the 24h cutoff and then fail as "the agent did not recover". Check SOLANA_RPC_URL and that it serves the ${this.network} cluster with transaction history enabled.`
4107
+ );
4108
+ }
4109
+ /**
4110
+ * Whether this endpoint may be believed when it says a reference is empty.
4111
+ *
4112
+ * The terminal no-payment verdict is the one place the provider tells a paying
4113
+ * customer their money bought nothing, and every input to it comes from a
4114
+ * single RPC client named by an environment variable nothing checks. Two
4115
+ * consecutive empty listings from the WRONG CLUSTER, or from a node that does
4116
+ * not serve address history at all, are two copies of the same meaningless
4117
+ * answer - and would fail every paying customer of that agent.
4118
+ *
4119
+ * So before the verdict is allowed at all: the endpoint must report the
4120
+ * genesis hash of the cluster this agent is configured for, and must answer a
4121
+ * `getSignaturesForAddress` query. Neither costs anything after the first
4122
+ * success - the answer is cached for the process.
4123
+ *
4124
+ * Fails SAFE: anything short of both checks passing means "keep deferring",
4125
+ * which ends at the 24h cutoff as "the agent did not recover" rather than as a
4126
+ * false accusation. An operator running against a local validator (whose
4127
+ * genesis is its own) therefore never gets the fast verdict; that is the
4128
+ * intended trade.
4129
+ */
4130
+ endpointMayIssueTerminalVerdict(rpc, log) {
4131
+ if (this.endpointSoundness === "sound") {
4132
+ return Promise.resolve(true);
4133
+ }
4134
+ if (this.endpointSoundness === "wrong-cluster") {
4135
+ return Promise.resolve(false);
4136
+ }
4137
+ if (this.endpointProbe === void 0) {
4138
+ const probe = this.probeEndpointSoundness(rpc, log);
4139
+ this.endpointProbe = probe;
4140
+ const release = () => {
4141
+ if (this.endpointProbe === probe) {
4142
+ this.endpointProbe = void 0;
4143
+ }
4144
+ };
4145
+ void probe.then(release, release);
4146
+ }
4147
+ return this.endpointProbe;
4148
+ }
4149
+ /** The two RPC calls behind {@link endpointMayIssueTerminalVerdict}. */
4150
+ async probeEndpointSoundness(rpc, log) {
4151
+ try {
4152
+ const expectedGenesisHash = CLUSTER_GENESIS_HASHES[this.network];
4153
+ let genesisHash;
4154
+ try {
4155
+ genesisHash = await rpc.getGenesisHash().send();
4156
+ } catch (e) {
4157
+ this.warnUntrustedEndpoint(
4158
+ "unreachable",
4159
+ log,
4160
+ `it did not answer getGenesisHash (${e?.message ?? "unknown error"}).`
4161
+ );
4162
+ return false;
4163
+ }
4164
+ if (genesisHash !== expectedGenesisHash) {
4165
+ this.endpointSoundness = "wrong-cluster";
4166
+ this.warnUntrustedEndpoint(
4167
+ "wrong-cluster",
4168
+ log,
4169
+ `it reports genesis ${genesisHash}, but this agent is configured for ${this.network}, whose genesis is ${expectedGenesisHash}.`
4170
+ );
4171
+ return false;
4172
+ }
4173
+ try {
4174
+ await rpc.getSignaturesForAddress(address(ADDRESS_HISTORY_PROBE_ADDRESS), {
4175
+ limit: 1,
4176
+ commitment: "confirmed"
4177
+ }).send();
4178
+ } catch (e) {
4179
+ this.warnUntrustedEndpoint(
4180
+ "no-history",
4181
+ log,
4182
+ `it is on the right cluster but did not answer an address-history query (${e?.message ?? "unknown error"}).`
4183
+ );
4184
+ return false;
4185
+ }
4186
+ this.endpointSoundness = "sound";
4187
+ return true;
4188
+ } catch (e) {
4189
+ this.warnUntrustedEndpoint(
4190
+ "unreachable",
4191
+ log,
4192
+ `the endpoint check itself failed (${e?.message ?? "unknown error"}).`
4193
+ );
4194
+ return false;
4195
+ }
4196
+ }
4197
+ /**
4198
+ * Bind a verified settlement signature to this job, exactly once - see
4199
+ * `JobLedger.claimPaymentSignature`. Anything but `claimed` means the job is
4200
+ * NOT paid here and now; none of the three refusals is on its own evidence
4201
+ * that the customer failed to pay. Diagnostics log the FULL signature, both
4202
+ * job ids and the customer pubkey - all public values, and an operator
4203
+ * chasing a disputed payment should not have to match truncated prefixes.
4204
+ */
4205
+ claimSettlementSignature(txSignature, job, log) {
4206
+ const owner = this.ledger.paymentSignatureOwner(txSignature);
4207
+ const outcome = this.ledger.claimPaymentSignature(txSignature, job.jobId);
4208
+ if (outcome === "consumed-by-other") {
4209
+ log(
4210
+ `[${job.jobId.slice(0, 8)}] REFUSED: settlement ${txSignature} was already consumed by job ${owner ?? "unknown"}. One transaction settles one job, so it is not attributable to job ${job.jobId} (customer ${job.customerId}).`
4211
+ );
4212
+ } else if (outcome === "not-persisted") {
4213
+ log(
4214
+ `[${job.jobId.slice(0, 8)}] REFUSED: could not WRITE the claim on settlement ${txSignature} for job ${job.jobId} (customer ${job.customerId}) - refusing the payment rather than risk delivering twice for one transfer. Check disk space and permissions at the agent directory; the job retries on the next recovery tick.`
4215
+ );
4216
+ } else if (outcome === "unknown-job") {
4217
+ log(
4218
+ `[${job.jobId.slice(0, 8)}] REFUSED: job ${job.jobId} (customer ${job.customerId}) has no ledger entry, so the claim on settlement ${txSignature} has nowhere durable to live. This is a provider bug, not a disk problem - every paid job is recorded before payment collection. Report it with this log line.`
4219
+ );
4220
+ }
4221
+ return outcome;
4222
+ }
4223
+ /**
4224
+ * List the transactions that could possibly be a payment for this request:
4225
+ * one `getSignaturesForAddress` window against the reference key, newest
4226
+ * first, minus the transactions that FAILED on chain. A failed transaction
4227
+ * moved no money, so it is neither a payment nor evidence that anyone touched
4228
+ * this reference - counting it would let ~5000 lamports of deliberately
4229
+ * failing transaction (or an honest customer whose transfer simply failed)
4230
+ * turn a job that should close in a minute into a 24h wait.
4231
+ *
4232
+ * Listed at `confirmed`, the level the SDK verifier reads transactions at.
4233
+ * The RPC default `finalized` would hide a confirmed-but-not-yet-finalized
4234
+ * payment from the one check that decides whether anything is there.
4235
+ *
4236
+ * RETRIED (`REFERENCE_SCAN_LIST_ATTEMPTS`): the terminal "nobody paid" verdict
4237
+ * rests entirely on this one call, and the public RPC is known to throttle and
4238
+ * lag this exact index. A single un-retried shot deciding whether a customer
4239
+ * loses their money is not a trade worth making; a false deferral costs
4240
+ * latency, a false "unpaid" destroys money. Retrying stops at the scan
4241
+ * `deadline`: past it the scan is going to return inconclusive anyway, and
4242
+ * the attempts left would only hold a shared slot.
4243
+ *
4244
+ * `windowFull` reports whether the RAW listing came back at the window size.
4245
+ * A full window means the history is TRUNCATED - a payment can be hiding
4246
+ * behind the newer transactions - so the caller must not read "nothing
4247
+ * verified" as "nobody paid". Measured before the failed-transaction filter,
4248
+ * because the limit applies to the raw page.
4249
+ *
4250
+ * An error is never "the customer did not pay" - the caller keeps the job
4251
+ * recoverable.
4252
+ */
4253
+ async listReferenceCandidates(reference, rpc, deadline) {
4254
+ let lastMessage = "unknown error";
4255
+ let attempts = 0;
4256
+ for (let attempt = 0; attempt < REFERENCE_SCAN_LIST_ATTEMPTS; attempt++) {
4257
+ attempts = attempt + 1;
4258
+ try {
4259
+ const listed = await rpc.getSignaturesForAddress(reference, {
4260
+ limit: REFERENCE_SCAN_WINDOW,
4261
+ commitment: "confirmed"
4262
+ }).send();
4263
+ return {
4264
+ signatures: listed.filter((candidate) => !candidate.err).map((candidate) => candidate.signature),
4265
+ windowFull: listed.length >= REFERENCE_SCAN_WINDOW
4266
+ };
4267
+ } catch (e) {
4268
+ lastMessage = e?.message ?? "unknown error";
4269
+ if (Date.now() >= deadline) {
4270
+ break;
4271
+ }
4272
+ if (attempt < REFERENCE_SCAN_LIST_ATTEMPTS - 1) {
4273
+ await waitMs(REFERENCE_SCAN_LIST_RETRY_DELAY_MS);
4274
+ }
4275
+ }
4276
+ }
4277
+ return {
4278
+ error: `could not reach the chain to list the reference after ${attempts} of ${REFERENCE_SCAN_LIST_ATTEMPTS} attempts: ${lastMessage}`
4279
+ };
4280
+ }
4281
+ /**
4282
+ * Provider-side reference scan: list the reference's transactions and verify
4283
+ * the first one this job may actually own.
4284
+ *
4285
+ * The SDK's own reference path returns the newest VERIFYING transaction in
4286
+ * its window and cannot be asked for the next one. Fine for a customer
4287
+ * confirming their own payment; fatal for a provider, because a stranger who
4288
+ * attaches this job's reference to their own newer transfer makes the genuine
4289
+ * payment invisible to that path and the job dies at the 24h cutoff with the
4290
+ * money already taken. Walking the window and skipping the signatures the
4291
+ * ledger gave to OTHER jobs - which only the provider can know - is what
4292
+ * reaches such a payment.
4293
+ *
4294
+ * EXACTLY WHAT THAT RECOVERS, and no more: a payment masked by transactions
4295
+ * THIS ledger has already consumed, lying INSIDE one window. A mask built from
4296
+ * transactions the ledger knows nothing about is not skipped, only walked past
4297
+ * (each is fetched and rejected, which makes the scan inconclusive rather than
4298
+ * terminal); and a mask longer than the window pushes the payment off the page
4299
+ * entirely, where nothing here can see it. Both of those stay recoverable
4300
+ * until the 24h cutoff rather than being wrongly closed, which is the property
4301
+ * that actually protects the customer's money.
4302
+ *
4303
+ * BUDGET: `REFERENCE_SCAN_VERIFY_RETRIES` per candidate and the caller's
4304
+ * `deadline` for the whole scan, after which it returns inconclusive. Without
4305
+ * both, a rate-limited RPC holds a `p-limit` slot shared with live intake for
4306
+ * the SDK default of 10 retries x 3s x a full window - over ten minutes for
4307
+ * one deferred entry.
4308
+ *
4309
+ * TERMINAL ("none") REQUIRES ALL THREE, and the caller adds two more:
4310
+ * 1. the listing SUCCEEDED and came back SHORT of the window, so this is the
4311
+ * reference's whole history rather than a truncated page. A flood that
4312
+ * fills the window could be hiding the payment behind it, which is
4313
+ * exactly how an attacker would manufacture a "nobody paid" verdict;
4314
+ * 2. NO candidate failed to verify. A candidate that did not verify is not
4315
+ * evidence: the SDK reports "this is not a payment for this request" and
4316
+ * "I could not reach the chain for this transaction" as the same
4317
+ * `{verified: false, error}`, and an unreachable RPC must never be
4318
+ * evidence of non-payment;
4319
+ * 3. no candidate was SKIPPED for belonging to another job. A skip means
4320
+ * something did touch this reference and we chose not to look at it, so
4321
+ * the scan saw less than the whole truth.
4322
+ * The caller then requires the payment request's own expiry to have passed and
4323
+ * a second consecutive sighting. Anything else is inconclusive: a false
4324
+ * deferral costs latency, a false "unpaid" destroys the customer's money.
4325
+ *
4326
+ * What "none" therefore means is narrow and honest: after the failed-on-chain
4327
+ * transactions are dropped, the reference's entire history is EMPTY - there
4328
+ * was nothing at all to look at.
4329
+ */
4330
+ async verifyByReferenceScan(reference, rpc, jobId, deadline, verifySignature) {
4331
+ const listed = await this.listReferenceCandidates(reference, rpc, deadline);
4332
+ if ("error" in listed) {
4333
+ return { outcome: "inconclusive", error: listed.error };
4334
+ }
4335
+ let skippedConsumed = false;
4336
+ let unverifiableCandidate;
4337
+ for (const candidate of listed.signatures) {
4338
+ if (Date.now() > deadline) {
4339
+ return { outcome: "inconclusive", error: "the reference scan ran out of time" };
4340
+ }
4341
+ const owner = this.ledger.paymentSignatureOwner(candidate);
4342
+ if (owner !== void 0 && owner !== jobId) {
4343
+ skippedConsumed = true;
4344
+ continue;
4345
+ }
4346
+ const verified = await verifySignature(candidate, REFERENCE_SCAN_VERIFY_RETRIES);
4347
+ if (verified.verified) {
4348
+ return { outcome: "verified", txSignature: candidate };
4349
+ }
4350
+ unverifiableCandidate ??= verified.error ?? "no reason given";
4351
+ }
4352
+ if (listed.windowFull) {
4353
+ return {
4354
+ outcome: "inconclusive",
4355
+ error: `the reference carries at least ${REFERENCE_SCAN_WINDOW} transactions, so its history is truncated and a payment could be hidden behind the flood`
4356
+ };
4357
+ }
4358
+ if (skippedConsumed) {
4359
+ return {
4360
+ outcome: "inconclusive",
4361
+ error: "the reference carries a transaction another job already settled, so this scan did not see the whole picture"
4362
+ };
4363
+ }
4364
+ if (unverifiableCandidate !== void 0) {
4365
+ return {
4366
+ outcome: "inconclusive",
4367
+ error: `the reference carries a transaction that did not verify as a payment for this job (${unverifiableCandidate}) - indistinguishable, in what the verifier reports, from one we simply failed to read`
4368
+ };
4369
+ }
4370
+ return { outcome: "none" };
4371
+ }
4372
+ /**
4373
+ * Re-verify an on-chain payment during crash recovery.
4374
+ *
4375
+ * Two outcomes are terminal, and they mean opposite things. `corrupt-state` is
4376
+ * OUR state being unusable - no amount of waiting repairs it, so the caller
4377
+ * closes the job at once rather than hold it for 24h. `no-payment` is a scan
4378
+ * that saw the reference's WHOLE history and found nothing capable of being a
4379
+ * payment; even that only fails the job once the request's own expiry has
4380
+ * passed AND on a second consecutive sighting (the caller's check), because
4381
+ * one empty listing from a throttling RPC is not worth a customer's money.
4382
+ * Everything else defers: recovery cannot tell a non-paying customer apart
4383
+ * from a shutdown abort, a flaky RPC, or a stranger's transaction carrying
4384
+ * this job's (publicly readable) reference. The 24h `MAX_PAID_AGE_MS` cutoff
4385
+ * bounds the wait and closes the job as "the agent did not recover", never as
4386
+ * a false "the customer did not pay".
4387
+ *
4388
+ * Evidence order matters: a job that already CLAIMED a settlement is
4389
+ * re-verified against that exact signature first, and an empty scan can never
4390
+ * outrank it - the ledger is better evidence than an RPC that has aged the
4391
+ * transaction out of its history window.
4392
+ *
4393
+ * Limitation: Solana transaction data expires after ~2-3 days (recent blockhash
4394
+ * window). If the agent was down longer, a confirmed payment may not be found
4395
+ * on-chain. For mainnet: use monitoring, avoid extended downtime, or configure
4396
+ * an archive RPC via SOLANA_RPC_URL.
4397
+ */
4398
+ async reVerifyPayment(entry, paymentRequestJson, priceSubunits, log, signal) {
4399
+ const shortId = entry.job_id.slice(0, 8);
4400
+ let request;
4401
+ let reference;
4402
+ try {
4403
+ request = JSON.parse(paymentRequestJson);
4404
+ reference = address(request.reference);
4405
+ } catch (e) {
4406
+ log(
4407
+ `[${shortId}] Recovery: the persisted payment request is unusable (${e?.message ?? "unknown error"}) - this is provider-side state, not a chain or customer problem.`
4408
+ );
4409
+ return "corrupt-state";
4410
+ }
4411
+ const deadline = Date.now() + REFERENCE_SCAN_DEADLINE_MS;
4412
+ try {
4413
+ const rpc = createSolanaRpc(getRpcUrl(this.network));
4414
+ const protocolConfig = await this.fetchProtocolConfig();
4415
+ const verify = async (txSignature2, retries) => {
4416
+ const verification = this.strategy.verifyPayment(rpc, request, protocolConfig, {
4417
+ txSignature: txSignature2,
4418
+ retries
4419
+ });
4420
+ if (!signal) {
4421
+ return verification;
4422
+ }
4423
+ let abortHandler;
4424
+ const abortPromise = new Promise((_, reject) => {
4425
+ abortHandler = () => {
4426
+ const err = new Error("The operation was aborted");
4427
+ err.name = "AbortError";
4428
+ reject(err);
4429
+ };
4430
+ if (signal.aborted) {
4431
+ abortHandler();
4432
+ return;
4433
+ }
4434
+ signal.addEventListener("abort", abortHandler, { once: true });
4435
+ });
4436
+ try {
4437
+ return await Promise.race([verification, abortPromise]);
4438
+ } finally {
4439
+ if (abortHandler) {
4440
+ signal.removeEventListener("abort", abortHandler);
4441
+ }
4442
+ }
4443
+ };
4444
+ const ownSignature = entry.payment_signature;
4445
+ let txSignature;
4446
+ if (ownSignature !== void 0) {
4447
+ const own = await verify(ownSignature, OWN_SETTLEMENT_VERIFY_RETRIES);
4448
+ if (own.verified) {
4449
+ txSignature = ownSignature;
4450
+ } else {
4451
+ log(
4452
+ `[${shortId}] Recovery: the settlement this job already claimed (${ownSignature}) no longer verifies (${own.error ?? "unknown"}); falling back to the reference scan.`
4453
+ );
4454
+ }
4455
+ }
4456
+ if (txSignature === void 0) {
4457
+ const scan = await this.verifyByReferenceScan(
4458
+ reference,
4459
+ rpc,
4460
+ entry.job_id,
4461
+ deadline,
4462
+ verify
4463
+ );
4464
+ if (scan.outcome === "none") {
4465
+ if (ownSignature !== void 0) {
4466
+ log(
4467
+ `[${shortId}] Recovery: the reference scan is empty, but this job already owns settlement ${ownSignature} - treating the empty scan as an RPC history gap, not as non-payment. Deferring.`
4468
+ );
4469
+ return "deferred";
4470
+ }
4471
+ const notBefore = terminalVerdictNotBefore(request, entry.created_at);
4472
+ if (Date.now() < notBefore) {
4473
+ const secondsLeft = Math.ceil((notBefore - Date.now()) / 1e3);
4474
+ log(
4475
+ `[${shortId}] Recovery: the reference is still empty, but the payment request this provider issued does not expire for another ${secondsLeft}s - a customer is entitled to the whole window. Waiting out the window.`
4476
+ );
4477
+ return "awaiting-window";
4478
+ }
4479
+ if (!await this.endpointMayIssueTerminalVerdict(rpc, log)) {
4480
+ log(
4481
+ `[${shortId}] Recovery: the reference scan is empty, but this RPC endpoint has not proved it is the ${this.network} cluster and serves address history - refusing to call that non-payment. Deferring.`
4482
+ );
4483
+ return "deferred";
4484
+ }
4485
+ log(
4486
+ `[${shortId}] Recovery: a complete scan of this reference, after the payment request expired, found no payment for this job.`
4487
+ );
4488
+ return "no-payment";
4489
+ }
4490
+ if (scan.outcome === "inconclusive") {
4491
+ log(
4492
+ `[${shortId}] Recovery: payment could not be confirmed (${scan.error}); deferring to the next tick.`
4493
+ );
4494
+ return "deferred";
4495
+ }
4496
+ txSignature = scan.txSignature;
4497
+ }
4498
+ const claim = this.claimSettlementSignature(
4499
+ txSignature,
4500
+ { jobId: entry.job_id, customerId: entry.customer_id },
4501
+ log
4502
+ );
4503
+ if (claim !== "claimed") {
4504
+ log(
4505
+ `[${shortId}] Recovery: ${claimRefusalSentence(claim)}; deferring. The job stays paid and the next tick looks again.`
4506
+ );
4507
+ return "deferred";
4508
+ }
4509
+ const fee = calculateProtocolFee(priceSubunits, protocolConfig.feeBps);
4510
+ const netAmount = priceSubunits - fee;
4511
+ this.ledger.updatePayment(entry.job_id, netAmount);
4512
+ log(`[${shortId}] Recovery: payment re-verified (${netAmount} subunits)`);
4513
+ return "verified";
4514
+ } catch (e) {
4515
+ log(`[${shortId}] Recovery: payment re-verification error: ${e.message}; deferring.`);
4516
+ return "deferred";
4517
+ }
4518
+ }
4519
+ };
4520
+ function terminalVerdictNotBefore(request, entryCreatedAt) {
4521
+ const requestCreatedAt = Number(request.created_at);
4522
+ const expirySecs = Number(request.expiry_secs);
4523
+ const createdAt = Number.isFinite(requestCreatedAt) && requestCreatedAt > 0 ? requestCreatedAt : entryCreatedAt;
4524
+ const window = Number.isFinite(expirySecs) && expirySecs > 0 ? expirySecs : DEFAULTS.PAYMENT_EXPIRY_SECS;
4525
+ return (createdAt + window) * 1e3;
4526
+ }
3703
4527
  var SESSIONS_DIR_NAME = ".sessions";
3704
4528
  var SESSION_MAX_CONCURRENT_JOBS = 2;
3705
4529
  var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -4468,8 +5292,6 @@ var X402PermanentError = class extends Error {
4468
5292
  // src/runtime.ts
4469
5293
  var payment = new SolanaPaymentStrategy();
4470
5294
  var LEDGER_GC_INTERVAL_MS = 60 * 60 * 1e3;
4471
- var LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
4472
- var MAX_PAID_AGE_MS = 24 * 60 * 60 * 1e3;
4473
5295
  var SIG_PATH_TIMEOUT_MS = 60 * 1e3;
4474
5296
  var SESSION_SUMMARIZE_SYSTEM = "Summarize the following conversation between a user and an assistant so the conversation can continue with your summary standing in for the older turns. Preserve facts, decisions, names, numbers, constraints, and open questions. Reply with the summary only.";
4475
5297
  function buildUserRecord(data, fileName) {
@@ -4538,9 +5360,13 @@ var SeedFailedError = class extends Error {
4538
5360
  this.name = "SeedFailedError";
4539
5361
  }
4540
5362
  };
5363
+ var RECOVERY_NO_PAYMENT_CUSTOMER_MESSAGE = "Job permanently failed: no payment for this job was found on-chain after the payment request expired.";
5364
+ var RECOVERY_UNVERIFIABLE_CUSTOMER_MESSAGE = "Job permanently failed: the provider could not verify payment for this job. If you paid, contact the provider with your transaction signature.";
5365
+ var RECOVERY_NO_SKILL_CUSTOMER_MESSAGE = "Job permanently failed: this provider no longer offers a skill for this job. If you paid, contact the provider with your transaction signature.";
5366
+ var RECOVERY_INPUT_UNAVAILABLE_CUSTOMER_MESSAGE = "Job permanently failed: the input file for this job could not be retrieved after an agent restart - contact the provider to resolve.";
4541
5367
  var PaymentTimeoutError = class extends Error {
4542
5368
  constructor() {
4543
- super("Payment verification timed out; awaiting late confirmation.");
5369
+ super("Payment timeout: no payment received before the deadline.");
4544
5370
  this.name = "PaymentTimeoutError";
4545
5371
  }
4546
5372
  };
@@ -4580,6 +5406,17 @@ function customerSafeMessage(error) {
4580
5406
  }
4581
5407
  return "Internal processing error";
4582
5408
  }
5409
+ function parseRawEventDelegated(rawEventJson) {
5410
+ if (rawEventJson === void 0) {
5411
+ return false;
5412
+ }
5413
+ try {
5414
+ const raw = JSON.parse(rawEventJson);
5415
+ return Array.isArray(raw.tags) && raw.tags.some((tag) => Array.isArray(tag) && tag[0] === "payment" && tag[1] === "delegated");
5416
+ } catch {
5417
+ return false;
5418
+ }
5419
+ }
4583
5420
  function bodyLooksLikeBilling3(body) {
4584
5421
  const lower = body.toLowerCase();
4585
5422
  return BILLING_BODY_MARKERS3.some((marker) => lower.includes(marker));
@@ -4627,6 +5464,12 @@ var AgentRuntime = class {
4627
5464
  this.nonceStore = nonceStore;
4628
5465
  this.limit = pLimit(config.maxConcurrentJobs);
4629
5466
  this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
5467
+ this.paymentRecovery = new PaymentRecovery(
5468
+ ledger,
5469
+ config.network,
5470
+ () => this.fetchProtocolConfig(),
5471
+ payment
5472
+ );
4630
5473
  }
4631
5474
  limit;
4632
5475
  inFlight = /* @__PURE__ */ new Set();
@@ -4637,6 +5480,27 @@ var AgentRuntime = class {
4637
5480
  recoveryInterval = null;
4638
5481
  gcInterval = null;
4639
5482
  stopped = false;
5483
+ /**
5484
+ * Backoff bookkeeping for `paid` entries whose payment recovery could not
5485
+ * conclude. Public so an operator-facing surface (and the tests) can read the
5486
+ * backlog without reaching into the runtime's internals.
5487
+ */
5488
+ recoveryDeferrals = new RecoveryDeferrals(() => this.config.recoveryIntervalSecs);
5489
+ /** Settlement binding and payment re-verification - see `payment-recovery.ts`. */
5490
+ paymentRecovery;
5491
+ /**
5492
+ * Size of the deferral backlog at the end of the previous tick, so the
5493
+ * transition back to zero is logged exactly once. Without it the summary goes
5494
+ * quiet on the tick the backlog clears and an operator never sees it clear -
5495
+ * the one line they were waiting for.
5496
+ */
5497
+ lastDeferralBacklog = 0;
5498
+ /**
5499
+ * Memoised `raw_event_json` delegated discriminator, keyed on the ledger entry
5500
+ * OBJECT - see {@link rawEventIsDelegated}. Weak so it is bounded by the
5501
+ * entries the ledger is holding, not by everything this process has ever seen.
5502
+ */
5503
+ rawEventDelegatedCache = /* @__PURE__ */ new WeakMap();
4640
5504
  /** Per-customer sliding-window rate limiter for free skills. */
4641
5505
  freeCustomerLimiter = createSlidingWindowLimiter({
4642
5506
  windowMs: RATE_LIMIT_WINDOW_MS,
@@ -5632,24 +6496,80 @@ var AgentRuntime = class {
5632
6496
  }
5633
6497
  }
5634
6498
  }
6499
+ /**
6500
+ * Whether this tick would spend an RPC reference scan on the entry. The
6501
+ * delegated discriminator is the runtime's (delegated entries reconcile
6502
+ * through the pull path, which never reads the reference); the rest of the
6503
+ * predicate is `needsPaymentScan`.
6504
+ */
6505
+ needsPaymentReVerification(entry) {
6506
+ return needsPaymentScan(entry, this.isDelegatedEntry(entry));
6507
+ }
6508
+ /**
6509
+ * Close a recovered job AND tell the customer why.
6510
+ *
6511
+ * Recovery's verdicts are the last word on a job the customer already paid
6512
+ * for (or believes they did), so a silent `markFailed` leaves them watching a
6513
+ * job that will never answer. The 24h cutoff has always sent a notice; every
6514
+ * other terminal recovery verdict sends one too.
6515
+ */
6516
+ async failRecoveredJob(entry, job, message) {
6517
+ this.ledger.markFailed(entry.job_id);
6518
+ await this.transport.sendFeedback(job, { type: "error", message }).catch(() => {
6519
+ });
6520
+ }
6521
+ /**
6522
+ * One line per tick that sums up the deferral backlog, including the tick it
6523
+ * finally clears - an operator watching a stuck agent needs to see the end of
6524
+ * it, and a summary that simply stops appearing is indistinguishable from an
6525
+ * agent that stopped logging.
6526
+ */
6527
+ logDeferralSummary(log) {
6528
+ const backlog = this.recoveryDeferrals.size;
6529
+ const summary = this.recoveryDeferrals.summaryLine();
6530
+ if (summary !== null) {
6531
+ log(summary);
6532
+ } else if (this.lastDeferralBacklog > 0) {
6533
+ log("Recovery: no jobs are deferred awaiting payment confirmation any more.");
6534
+ }
6535
+ this.lastDeferralBacklog = backlog;
6536
+ }
5635
6537
  /**
5636
6538
  * Delegated discriminator for recovery routing. The ledger flag is primary;
5637
6539
  * the persisted raw event's top-level `payment` tag is the fallback for an
5638
6540
  * entry whose flag write was lost.
6541
+ *
6542
+ * The flag is re-read every call - `markDelegated` can set it on an entry a
6543
+ * recovery tick has already looked at - while the FALLBACK is memoised, since
6544
+ * `raw_event_json` never changes for a given entry object.
5639
6545
  */
5640
6546
  isDelegatedEntry(entry) {
5641
6547
  if (entry.delegated === true) {
5642
6548
  return true;
5643
6549
  }
5644
- if (entry.raw_event_json === void 0) {
5645
- return false;
5646
- }
5647
- try {
5648
- const raw = JSON.parse(entry.raw_event_json);
5649
- return Array.isArray(raw.tags) && raw.tags.some((tag) => Array.isArray(tag) && tag[0] === "payment" && tag[1] === "delegated");
5650
- } catch {
5651
- return false;
6550
+ return this.rawEventIsDelegated(entry);
6551
+ }
6552
+ /**
6553
+ * The `raw_event_json` half of {@link isDelegatedEntry}, memoised per entry
6554
+ * OBJECT.
6555
+ *
6556
+ * The per-tick scan budget asks this of every non-deferred pending entry, and
6557
+ * an entry over budget is parked and asked again next tick, so an uncached
6558
+ * version re-parses every pending job's whole payload once a minute for as
6559
+ * long as the backlog lasts - a full JSON parse of the customer's input (up to
6560
+ * the 64KB inline cap) per entry per tick, to read one tag. The persisted
6561
+ * event is immutable for the life of an entry object, so one parse is the
6562
+ * whole answer; a ledger reload produces fresh objects and re-parses, which is
6563
+ * correct.
6564
+ */
6565
+ rawEventIsDelegated(entry) {
6566
+ const memoised = this.rawEventDelegatedCache.get(entry);
6567
+ if (memoised !== void 0) {
6568
+ return memoised;
5652
6569
  }
6570
+ const delegated = parseRawEventDelegated(entry.raw_event_json);
6571
+ this.rawEventDelegatedCache.set(entry, delegated);
6572
+ return delegated;
5653
6573
  }
5654
6574
  /**
5655
6575
  * Reconcile a delegated `paid` entry to its terminal - the FIRST action in
@@ -6085,6 +7005,7 @@ var AgentRuntime = class {
6085
7005
  const sigPathTimeoutMs = Math.min(deadlineMs, SIG_PATH_TIMEOUT_MS);
6086
7006
  let sigOutcome;
6087
7007
  let refOutcome;
7008
+ let paymentRefusal;
6088
7009
  let result;
6089
7010
  try {
6090
7011
  result = await new Promise((resolve4, reject) => {
@@ -6112,6 +7033,25 @@ var AgentRuntime = class {
6112
7033
  resolve4(lastResult);
6113
7034
  }
6114
7035
  };
7036
+ const accept = (verified, pathLabel, askedSignature) => {
7037
+ if (settled) {
7038
+ return;
7039
+ }
7040
+ const txSignature = askedSignature ?? verified.txSignature;
7041
+ if (txSignature === void 0) {
7042
+ paymentRefusal = { consumedByOther: false, sentence: UNNAMED_SETTLEMENT_SENTENCE };
7043
+ lose({ verified: false }, `${pathLabel}: ${UNNAMED_SETTLEMENT_SENTENCE}`);
7044
+ return;
7045
+ }
7046
+ const claim = this.paymentRecovery.claimSettlementSignature(txSignature, job, log);
7047
+ if (claim !== "claimed") {
7048
+ const sentence = claimRefusalSentence(claim);
7049
+ paymentRefusal = { consumedByOther: claim === "consumed-by-other", sentence };
7050
+ lose({ verified: false }, `${pathLabel}: settlement refused - ${sentence}`);
7051
+ return;
7052
+ }
7053
+ win(verified);
7054
+ };
6115
7055
  this.transport.waitForPaymentSignature(job.jobId, job.customerId, verifyAbort.signal, sigPathTimeoutMs).then(async (sig) => {
6116
7056
  if (settled) {
6117
7057
  return;
@@ -6127,7 +7067,7 @@ var AgentRuntime = class {
6127
7067
  txSignature: sig
6128
7068
  });
6129
7069
  if (verified.verified) {
6130
- win(verified);
7070
+ accept(verified, "sig path", sig);
6131
7071
  } else {
6132
7072
  const reason = verified.error ?? "unknown";
6133
7073
  sigOutcome = { gotSignature: true, error: reason };
@@ -6145,7 +7085,7 @@ var AgentRuntime = class {
6145
7085
  });
6146
7086
  payment.verifyPayment(rpc, request, protocolConfig).then((verified) => {
6147
7087
  if (verified.verified) {
6148
- win(verified);
7088
+ accept(verified, "ref path");
6149
7089
  } else {
6150
7090
  const reason = verified.error ?? "unknown";
6151
7091
  refOutcome = { error: reason };
@@ -6196,23 +7136,28 @@ var AgentRuntime = class {
6196
7136
  if (result.verified) {
6197
7137
  return { netAmount, paymentRequest: requestJson };
6198
7138
  }
6199
- const refDefinitiveNoPay = refOutcome?.error === "No matching transaction found for reference key";
6200
- const sigGotSignature = sigOutcome?.gotSignature === true;
6201
- const customerAbandoned = !sigGotSignature && refDefinitiveNoPay;
6202
- if (customerAbandoned) {
7139
+ if (paymentRefusal?.consumedByOther === true) {
6203
7140
  log(
6204
- `[${job.jobId.slice(0, 8)}] Payment not received; on-chain scan found no matching transaction - job abandoned by customer.`
7141
+ `[${job.jobId.slice(0, 8)}] Payment not accepted: ${paymentRefusal.sentence}. The job stays recoverable - recovery keeps looking for a transfer of its own.`
7142
+ );
7143
+ } else if (paymentRefusal !== void 0) {
7144
+ log(
7145
+ `[${job.jobId.slice(0, 8)}] Payment verified but NOT accepted: ${paymentRefusal.sentence}. The job stays recoverable and the next recovery tick retries it - see the REFUSED line above for what to fix.`
6205
7146
  );
6206
7147
  } else {
7148
+ let sigReport;
7149
+ if (sigOutcome === void 0) {
7150
+ sigReport = "signature path: no result";
7151
+ } else if (!sigOutcome.gotSignature) {
7152
+ sigReport = sigOutcome.error ? `signature path: no payment-completed feedback (${sigOutcome.error})` : "signature path: no payment-completed feedback";
7153
+ } else {
7154
+ sigReport = `signature path: the customer asserted a signature that did not verify (${sigOutcome.error ?? "unknown"})`;
7155
+ }
7156
+ const refReport = refOutcome === void 0 ? "no result" : refOutcome.error ?? "unknown";
6207
7157
  log(
6208
- `[${job.jobId.slice(0, 8)}] WARNING: Payment verification timed out. Customer may have paid on-chain. Check address ${this.config.solanaAddress} manually.`
7158
+ `[${job.jobId.slice(0, 8)}] WARNING: Payment verification timed out - reference path: ${refReport}; ${sigReport}. The customer may still have paid on-chain; recovery makes the final call. Check address ${this.config.solanaAddress} manually if it does not.`
6209
7159
  );
6210
7160
  }
6211
- await this.transport.sendFeedback(job, { type: "error", message: "payment timeout" }).catch(() => {
6212
- });
6213
- if (customerAbandoned) {
6214
- throw new Error("Payment timeout");
6215
- }
6216
7161
  throw new PaymentTimeoutError();
6217
7162
  }
6218
7163
  /**
@@ -6255,16 +7200,18 @@ var AgentRuntime = class {
6255
7200
  }
6256
7201
  async recoverPendingJobs() {
6257
7202
  const pending = this.ledger.pendingJobs().filter((e) => !this.inFlight.has(e.job_id));
7203
+ this.recoveryDeferrals.sweep(new Set(this.ledger.pendingJobs().map((entry) => entry.job_id)));
6258
7204
  if (this.sessionStore !== void 0) {
6259
7205
  this.sessionStore.syncRecoveryRegistrations(
6260
7206
  this.collectRecoverySessionRefs(pending),
6261
7207
  (jobId) => this.ledger.getStatus(jobId) === "paid"
6262
7208
  );
6263
7209
  }
7210
+ const log = this.callbacks.onLog ?? console.log;
7211
+ this.logDeferralSummary(log);
6264
7212
  if (pending.length === 0) {
6265
7213
  return;
6266
7214
  }
6267
- const log = this.callbacks.onLog ?? console.log;
6268
7215
  log(`Recovering ${pending.length} pending jobs...`);
6269
7216
  if (this.healthMonitor) {
6270
7217
  const snap = this.healthMonitor.snapshot();
@@ -6288,6 +7235,8 @@ var AgentRuntime = class {
6288
7235
  }
6289
7236
  }
6290
7237
  }
7238
+ const scanBudget = recoveryScanBudgetPerTick(this.config.maxConcurrentJobs);
7239
+ let scansStartedThisTick = 0;
6291
7240
  for (const entry of pending) {
6292
7241
  const ageMs = (Math.floor(Date.now() / 1e3) - entry.created_at) * 1e3;
6293
7242
  const expired = ageMs > MAX_PAID_AGE_MS;
@@ -6320,6 +7269,16 @@ var AgentRuntime = class {
6320
7269
  if (!entry.raw_event_json) {
6321
7270
  continue;
6322
7271
  }
7272
+ if (this.recoveryDeferrals.isDeferred(entry.job_id)) {
7273
+ continue;
7274
+ }
7275
+ if (this.needsPaymentReVerification(entry)) {
7276
+ if (scansStartedThisTick >= scanBudget) {
7277
+ this.recoveryDeferrals.parkForCapacity(entry.job_id);
7278
+ continue;
7279
+ }
7280
+ scansStartedThisTick += 1;
7281
+ }
6323
7282
  if (this.pending >= this.maxQueueSize) {
6324
7283
  break;
6325
7284
  }
@@ -6386,9 +7345,54 @@ var AgentRuntime = class {
6386
7345
  const skill = this.skills.route(entry.tags);
6387
7346
  if (!skill) {
6388
7347
  log(`[${entry.job_id.slice(0, 8)}] Recovery: no skill for tags, marking failed`);
6389
- this.ledger.markFailed(entry.job_id);
7348
+ await this.failRecoveredJob(entry, fakeJob, RECOVERY_NO_SKILL_CUSTOMER_MESSAGE);
6390
7349
  return;
6391
7350
  }
7351
+ if (skill.priceSubunits > 0 && !entry.net_amount) {
7352
+ if (entry.payment_request) {
7353
+ const reVerification = await this.paymentRecovery.reVerifyPayment(
7354
+ entry,
7355
+ entry.payment_request,
7356
+ skill.priceSubunits,
7357
+ log,
7358
+ recoveryAbort.signal
7359
+ );
7360
+ if (reVerification === "deferred") {
7361
+ this.recoveryDeferrals.note(entry.job_id);
7362
+ return;
7363
+ }
7364
+ if (reVerification === "awaiting-window") {
7365
+ this.recoveryDeferrals.noteAwaitingWindow(entry.job_id);
7366
+ return;
7367
+ }
7368
+ if (reVerification === "corrupt-state") {
7369
+ log(
7370
+ `[${entry.job_id.slice(0, 8)}] Recovery: the persisted payment request is unusable, so this job can never be verified - marking failed. This is a provider-side state problem; audit the ledger entry.`
7371
+ );
7372
+ await this.failRecoveredJob(entry, fakeJob, RECOVERY_UNVERIFIABLE_CUSTOMER_MESSAGE);
7373
+ return;
7374
+ }
7375
+ if (reVerification === "no-payment") {
7376
+ if (!this.recoveryDeferrals.sawNoPayment(entry.job_id)) {
7377
+ log(
7378
+ `[${entry.job_id.slice(0, 8)}] Recovery: the reference's whole history is empty, but a single listing is not enough to close a paid job - confirming on the next attempt.`
7379
+ );
7380
+ this.recoveryDeferrals.note(entry.job_id, true);
7381
+ return;
7382
+ }
7383
+ log(
7384
+ `[${entry.job_id.slice(0, 8)}] Recovery: two consecutive complete scans found no payment on this reference - marking failed.`
7385
+ );
7386
+ await this.failRecoveredJob(entry, fakeJob, RECOVERY_NO_PAYMENT_CUSTOMER_MESSAGE);
7387
+ return;
7388
+ }
7389
+ this.recoveryDeferrals.clear(entry.job_id);
7390
+ } else {
7391
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: payment not confirmed, marking failed`);
7392
+ await this.failRecoveredJob(entry, fakeJob, RECOVERY_UNVERIFIABLE_CUSTOMER_MESSAGE);
7393
+ return;
7394
+ }
7395
+ }
6392
7396
  const healthPair = resolveHealthPair(skill);
6393
7397
  if (this.healthMonitor && healthPair) {
6394
7398
  try {
@@ -6423,24 +7427,6 @@ var AgentRuntime = class {
6423
7427
  }
6424
7428
  }
6425
7429
  this.ledger.incrementRetry(entry.job_id);
6426
- if (skill.priceSubunits > 0 && !entry.net_amount) {
6427
- if (entry.payment_request) {
6428
- const verified = await this.reVerifyPayment(
6429
- entry,
6430
- skill.priceSubunits,
6431
- log,
6432
- recoveryAbort.signal
6433
- );
6434
- if (!verified) {
6435
- this.ledger.markFailed(entry.job_id);
6436
- return;
6437
- }
6438
- } else {
6439
- log(`[${entry.job_id.slice(0, 8)}] Recovery: payment not confirmed, marking failed`);
6440
- this.ledger.markFailed(entry.job_id);
6441
- return;
6442
- }
6443
- }
6444
7430
  let recoveryInputFile;
6445
7431
  let recoverySession;
6446
7432
  try {
@@ -6458,7 +7444,7 @@ var AgentRuntime = class {
6458
7444
  );
6459
7445
  } catch {
6460
7446
  log(`[${entry.job_id.slice(0, 8)}] Recovery: input file unavailable, marking failed`);
6461
- this.ledger.markFailed(entry.job_id);
7447
+ await this.failRecoveredJob(entry, fakeJob, RECOVERY_INPUT_UNAVAILABLE_CUSTOMER_MESSAGE);
6462
7448
  return;
6463
7449
  }
6464
7450
  const recoveryBudgetMs = this.resolveExecutionBudgetMs(skill);
@@ -6540,61 +7526,6 @@ var AgentRuntime = class {
6540
7526
  this.jobAbortControllers.delete(recoveryAbort);
6541
7527
  }
6542
7528
  }
6543
- /**
6544
- * Re-verify an on-chain payment during crash recovery.
6545
- *
6546
- * Limitation: Solana transaction data expires after ~2-3 days (recent blockhash window).
6547
- * If the agent was down longer, a confirmed payment may not be found on-chain and the
6548
- * job will be marked failed. For mainnet: use monitoring, avoid extended downtime, or
6549
- * configure an archive RPC via SOLANA_RPC_URL.
6550
- */
6551
- async reVerifyPayment(entry, priceSubunits, log, signal) {
6552
- try {
6553
- const request = JSON.parse(entry.payment_request);
6554
- const rpc = createSolanaRpc(getRpcUrl(this.config.network));
6555
- const protocolConfig = await this.fetchProtocolConfig();
6556
- let result;
6557
- if (signal) {
6558
- let abortHandler;
6559
- const abortPromise = new Promise((_, reject) => {
6560
- abortHandler = () => {
6561
- const err = new Error("The operation was aborted");
6562
- err.name = "AbortError";
6563
- reject(err);
6564
- };
6565
- if (signal.aborted) {
6566
- abortHandler();
6567
- return;
6568
- }
6569
- signal.addEventListener("abort", abortHandler, { once: true });
6570
- });
6571
- try {
6572
- result = await Promise.race([
6573
- payment.verifyPayment(rpc, request, protocolConfig),
6574
- abortPromise
6575
- ]);
6576
- } finally {
6577
- if (abortHandler) {
6578
- signal.removeEventListener("abort", abortHandler);
6579
- }
6580
- }
6581
- } else {
6582
- result = await payment.verifyPayment(rpc, request, protocolConfig);
6583
- }
6584
- if (result.verified) {
6585
- const fee = calculateProtocolFee(priceSubunits, protocolConfig.feeBps);
6586
- const netAmount = priceSubunits - fee;
6587
- this.ledger.updatePayment(entry.job_id, netAmount, entry.payment_request);
6588
- log(`[${entry.job_id.slice(0, 8)}] Recovery: payment re-verified (${netAmount} subunits)`);
6589
- return true;
6590
- }
6591
- log(`[${entry.job_id.slice(0, 8)}] Recovery: payment not found on-chain, marking failed`);
6592
- return false;
6593
- } catch (e) {
6594
- log(`[${entry.job_id.slice(0, 8)}] Recovery: payment re-verification error: ${e.message}`);
6595
- return false;
6596
- }
6597
- }
6598
7529
  };
6599
7530
  var SkillRegistry = class {
6600
7531
  skills = [];