@elisym/cli 0.29.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 +1038 -107
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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 = [];
|