@stellar-agent-services/dev-mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +50 -2
- package/dist/docs.d.ts +35 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +92 -0
- package/dist/docs.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +125 -0
- package/dist/server.js.map +1 -0
- package/package.json +40 -4
- package/repository/AGENTS.md +162 -0
- package/repository/CONTRIBUTING.md +95 -0
- package/repository/IMPLEMENTATION_PLAN.md +560 -0
- package/repository/README.md +80 -0
- package/repository/SECURITY.md +22 -0
- package/repository/apps/wallet/README.md +78 -0
- package/repository/docs/client-integration/README-AI.md +495 -0
- package/repository/docs/client-integration/README.md +138 -0
- package/repository/docs/dx-improvements.md +670 -0
- package/repository/docs/erc-compatibility.md +163 -0
- package/repository/docs/job-recovery.md +201 -0
- package/repository/docs/payment-client.md +290 -0
- package/repository/docs/provider-setup-guide.md +348 -0
- package/repository/docs/provider-standard.md +728 -0
- package/repository/docs/recovery/README-AI.md +288 -0
- package/repository/docs/recovery/README.md +119 -0
- package/repository/docs/sow-closeout.md +67 -0
- package/repository/docs/standard/README-AI.md +614 -0
- package/repository/docs/standard/README.md +108 -0
- package/repository/docs/verification.md +504 -0
- package/repository/examples/paid-service/README.md +33 -0
- package/repository/examples/python-upstream/README.md +44 -0
- package/repository/llms.txt +65 -0
- package/repository/packages/create-stellar-service/README.md +143 -0
- package/repository/packages/dev-mcp/README.md +51 -0
- package/repository/packages/payment-client/README.md +98 -0
- package/repository/packages/protocol/README.md +15 -0
- package/repository/packages/protocol/schemas/manifest.schema.json +272 -0
- package/repository/packages/protocol/test-vectors/README.md +45 -0
- package/repository/packages/provider-kit/README.md +509 -0
- package/repository/packages/provider-kit/schemas/gateway-config.schema.json +140 -0
- package/repository/packages/verifier/README.md +131 -0
- package/repository/plugins/stellar-agent-services/README.md +31 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# Job Recovery: implementation reference for AI coding agents
|
|
2
|
+
|
|
3
|
+
Use this file to change, extend, review or debug Job Recovery in this repository: the provider's reconciliation
|
|
4
|
+
of `payment_uncertain` jobs, the ledger clock, and the payment client's recovery paths.
|
|
5
|
+
|
|
6
|
+
- **Normative source:** [provider-standard.md §5](../provider-standard.md#5-jobs). If this file and the spec
|
|
7
|
+
disagree, the spec wins. Cite rules by id (`[J13]`). v1 is frozen: additions must be optional fields or SHOULD
|
|
8
|
+
rules.
|
|
9
|
+
- **Explanatory doc:** [job-recovery.md](../job-recovery.md). **Human summary:** [README.md](README.md).
|
|
10
|
+
- **State:** implemented and tested offline (`npm test`). Checked read-only against the public testnet
|
|
11
|
+
RPC. Not yet done: the recorded testnet demo (one interrupted job recovered with exactly one on-chain transfer).
|
|
12
|
+
|
|
13
|
+
## 0. Invariants: never break these
|
|
14
|
+
|
|
15
|
+
1. **Never charge twice.** A `payment_uncertain` job is never settled again [J13]. Recovery never re-sends
|
|
16
|
+
`PAYMENT-SIGNATURE`; check-status and get-result never charge [J11].
|
|
17
|
+
2. **Fail closed on money.** `not_settled` needs proof of absence (§4.3). Any doubt is `undecided`, and the job
|
|
18
|
+
stays `payment_uncertain`. Never report a paid job as unpaid.
|
|
19
|
+
3. **Identify a payment by `(payer, nonce)`, never by amount and recipient.** One payer may pay one provider the
|
|
20
|
+
same price for many jobs. Soroban accepts an `(address, nonce)` pair once, so that pair identifies exactly one
|
|
21
|
+
payment.
|
|
22
|
+
4. **Every reconciliation write is compare-and-set from `payment_uncertain`.** A late facilitator answer,
|
|
23
|
+
another process or a second run must not overturn a decision.
|
|
24
|
+
5. **The provider holds no secret key.** Chain access is read-only Soroban RPC.
|
|
25
|
+
6. **Tests never touch the network.** Test providers pass `rpc: false` or a fake `ChainReader`
|
|
26
|
+
(`packages/provider-kit/test/support/chain.ts`).
|
|
27
|
+
|
|
28
|
+
## 1. File map
|
|
29
|
+
|
|
30
|
+
| File | Role |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `packages/protocol/src/networks.ts` | `SOROBAN_RPC_URL`: testnet `https://soroban-testnet.stellar.org`, pubnet `undefined` (no public SDF RPC) |
|
|
33
|
+
| `packages/provider-kit/src/payments/chain.ts` | `ChainReader` type, `rpcChain()`, `LedgerClock`, `ledgerClock()` |
|
|
34
|
+
| `packages/provider-kit/src/payments/onchain.ts` | `findPayment()`, `PaymentLookup`, `PaymentSearch`, `SEARCH_WINDOW_LEDGERS`, `EXPIRATION_TOLERANCE_LEDGERS` |
|
|
35
|
+
| `packages/provider-kit/src/jobs/reconcile.ts` | `reconcile()`: lists `payment_uncertain` jobs, calls `findPayment`, applies decisions |
|
|
36
|
+
| `packages/provider-kit/src/jobs/recovery.ts` | `sweep()`: now also fails on-success jobs by expiration ledger (`SweepOptions.clock`) |
|
|
37
|
+
| `packages/provider-kit/src/jobs/runner.ts` | `paymentDeadline()`: on-success deadline from the expiration ledger; `PaidRun.clock` |
|
|
38
|
+
| `packages/provider-kit/src/http/router.ts` | Step 7: refuses a signature that expires too far out; `RouterContext.clock` |
|
|
39
|
+
| `packages/provider-kit/src/provider.ts` | `ProviderConfig.rpc`; builds chain and clock; background `reconcileJobs()` after each sweep |
|
|
40
|
+
| `packages/provider-kit/src/payments/payload-checks.ts` | `addressCredentials()` is now exported (shared with `onchain.ts`) |
|
|
41
|
+
| `packages/provider-kit/src/gateway/config.ts`, `schemas/gateway-config.schema.json` | `rpcUrl` (allows `env:NAME`) maps to `ProviderConfig.rpc` |
|
|
42
|
+
| `packages/provider-kit/src/cli/reconcile.ts`, `cli/main.ts`, `cli/shared.ts` | `provider-kit reconcile` command; new flags `--store`, `--rpc` |
|
|
43
|
+
| `packages/payment-client/src/client.ts` | 409 handling: `isInProgress()`, `whileSettling()`, changes in `payOnce`, `follow`, `getResult`, `recover` |
|
|
44
|
+
| `packages/payment-client/src/onchain.ts` | Uses `SOROBAN_RPC_URL` from protocol (its local copy was removed) |
|
|
45
|
+
| `examples/paid-service/src/server.ts`, `.env.example` | Optional `SOROBAN_RPC_URL` passed as `rpc` |
|
|
46
|
+
|
|
47
|
+
## 2. Spec mapping
|
|
48
|
+
|
|
49
|
+
| Rule | Where it is enforced |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| [J5] same id + binding + token returns the existing job, no settle | `router.ts` `existingJob()` (existing code) |
|
|
52
|
+
| [J7] `409 request_in_progress` while claimed or settled | Provider: `router.ts`, for internal status `pending`. Client: `isInProgress()` means "ask again later". |
|
|
53
|
+
| [J11] check-status and get-result never charge | `router.ts` `handleGetJob()`; client `recover()` only calls get-result |
|
|
54
|
+
| [J12] final states never change | Store `update()` refuses final `from`; reconcile is compare-and-set from `payment_uncertain` |
|
|
55
|
+
| [J13] uncertain decided on chain, never re-charged | `jobs/reconcile.ts` + `payments/onchain.ts` |
|
|
56
|
+
| [J14] output withheld while uncertain | Existing router logic; reconcile keeps the stored output for on-success |
|
|
57
|
+
| [J16] receipt confirmed on chain | Client `confirm()` (existing); reconcile also requires a matching `transfer` event |
|
|
58
|
+
| [S2] payment expires about `maxTimeoutSeconds` after signing | `runner.ts` `paymentDeadline()`, `recovery.ts` sweep, router step-7 check |
|
|
59
|
+
| [P9] one signed payment buys at most one job | Fingerprint `sha256(JCS({network, payer, nonce}))` (existing) |
|
|
60
|
+
| [Q8], [Q9] retries and polling pace | Client follows `Retry-After` for 409, 429 and 503 |
|
|
61
|
+
|
|
62
|
+
## 3. Data the provider stores (existing, relied on)
|
|
63
|
+
|
|
64
|
+
`StoredJob.claim: PaymentClaim` = `{ fingerprint, payer, nonce, expirationLedger }`, written at claim time from
|
|
65
|
+
`checkPayment()`. `nonce` is the payer auth entry's `credentials.nonce().toString()` (decimal int64).
|
|
66
|
+
`expirationLedger` is `signatureExpirationLedger`, the last ledger at which the signature is valid.
|
|
67
|
+
|
|
68
|
+
`StoredJob.settlement?: { reason, transaction? }` is set when the job became `payment_uncertain`. `transaction`
|
|
69
|
+
is the hash the facilitator named, if any.
|
|
70
|
+
|
|
71
|
+
Internal status `pending`: an upfront job claimed, with settlement in flight. It has no job view; requests get 409.
|
|
72
|
+
|
|
73
|
+
## 4. Reconciliation algorithm
|
|
74
|
+
|
|
75
|
+
### 4.1 `reconcile(store, options): Promise<Reconciled[]>` (`jobs/reconcile.ts`)
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
options: PaymentSearch & { dryRun?: boolean; limit?: number } // limit default 500 (CLI uses 1000)
|
|
79
|
+
PaymentSearch: { chain: ChainReader; network: StellarNetwork; asset: string } // asset = manifest USDC SAC
|
|
80
|
+
|
|
81
|
+
1. jobs = store.list({ statuses: ["payment_uncertain"], createdBefore: now + 1 s, limit }) // oldest first
|
|
82
|
+
2. if none: return [] // no RPC call at all
|
|
83
|
+
3. health = chain.getHealth() // once per run
|
|
84
|
+
on error: every job → undecided("the chain could not be read: …"), no writes
|
|
85
|
+
4. for each job:
|
|
86
|
+
if job.claim.expirationLedger < health.oldestLedger:
|
|
87
|
+
undecided("older than the RPC's history") // no RPC call, so a backlog costs nothing
|
|
88
|
+
else lookup = findPayment(job.claim, job.settlement?.transaction, options, health)
|
|
89
|
+
apply(lookup) // §4.4
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`Reconciled = { jobId, service, outcome: "settled" | "not_settled" | "undecided", status, transaction?, reason? }`.
|
|
93
|
+
`status` is the status afterwards (in a dry run, the status it would get).
|
|
94
|
+
|
|
95
|
+
### 4.2 `findPayment(claim, known, search, health?)` (`payments/onchain.ts`)
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
check(hash): getTransaction(hash) → paymentIn(...) // each hash checked once (Set)
|
|
99
|
+
|
|
100
|
+
1. if known: r = check(known); if r: return r // settled or undecided; a FAILED tx falls through
|
|
101
|
+
2. range = health ?? getHealth()
|
|
102
|
+
end = claim.expirationLedger
|
|
103
|
+
from = max(1, end - SEARCH_WINDOW_LEDGERS) // 320
|
|
104
|
+
start = max(from, range.oldestLedger)
|
|
105
|
+
latest = range.latestLedger
|
|
106
|
+
3. if start <= min(end, latest):
|
|
107
|
+
filters = [{ type: "contract", contractIds: [asset],
|
|
108
|
+
topics: [[sym("transfer"), addr(payer), "*", "*"], // SAC: 4 topics
|
|
109
|
+
[sym("transfer"), addr(payer), "*"]] }] // other SEP-41 tokens: 3 topics
|
|
110
|
+
request = { filters, startLedger: start, endLedger: min(end, latest) + 1, limit: 100 } // endLedger is exclusive
|
|
111
|
+
loop pages (max 10, else undecided "more transfers than reconciliation searches"):
|
|
112
|
+
response = getEvents(request); latest = min(latest, response.latestLedger)
|
|
113
|
+
for event: if event.ledger > end: stop
|
|
114
|
+
r = check(event.txHash); if r: return r
|
|
115
|
+
if stopped, or events < 100: break
|
|
116
|
+
request = { filters, cursor: response.cursor, limit: 100 } // cursor mode can't take endLedger
|
|
117
|
+
4. if latest <= end: undecided("the payment can still land until ledger {end} closes")
|
|
118
|
+
5. if start > from: undecided("ledgers {from}–{start-1} are older than the RPC's history")
|
|
119
|
+
6. return not_settled("no transaction used the payment before it expired at ledger {end}")
|
|
120
|
+
Any exception: undecided("the chain could not be read: …")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`latest` is the **minimum** of every reported latest ledger: several RPC nodes behind one URL may lag
|
|
124
|
+
differently, and coverage is only as far as the least advanced node.
|
|
125
|
+
|
|
126
|
+
**Do not filter on `event.inSuccessfulContractCall`.** Current RPCs leave this deprecated field unset
|
|
127
|
+
(`undefined`). An earlier version of this code skipped every real event because of it; the live testnet check
|
|
128
|
+
caught it. The transaction's own `SUCCESS` status decides.
|
|
129
|
+
|
|
130
|
+
### 4.3 `paymentIn(tx, hash, claim, search)`: is this transaction the payment?
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
tx.status !== SUCCESS → undefined (not the payment; keep looking)
|
|
134
|
+
signedTransfer(tx.envelopeXdr, claim, asset):
|
|
135
|
+
operations: fee-bump → innerTx().v1().tx().operations(); v1 → v1().tx(); else v0().tx()
|
|
136
|
+
for each invokeHostFunction op, for each auth entry:
|
|
137
|
+
credentials = addressCredentials(entry.credentials()) // address or addressV2
|
|
138
|
+
skip unless address == claim.payer && nonce == claim.nonce
|
|
139
|
+
rootInvocation must be contractFn on `asset`, function "transfer", args [from, to, amount],
|
|
140
|
+
with from == payer, to a string and amount a bigint → { to, amount }
|
|
141
|
+
anything else with this (payer, nonce): undefined (the nonce was spent elsewhere, not this payment)
|
|
142
|
+
no matching entry → undefined
|
|
143
|
+
transfersFrom(tx.events.contractEventsXdr.flat(), asset, payer) has no event with the same to and amount
|
|
144
|
+
→ undecided("used the payment, but … no transfer …")
|
|
145
|
+
otherwise → settled, receipt = { network, asset, amount, payTo: to, payer, transaction: hash,
|
|
146
|
+
settledAt: ISO(tx.createdAt * 1000) }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The transfer event data is an `i128`, or a map with `amount` for a muxed payee (CAP-67). Both are handled.
|
|
150
|
+
|
|
151
|
+
### 4.4 Applying a decision
|
|
152
|
+
|
|
153
|
+
| Lookup | `charge` | Patch, compare-and-set from `payment_uncertain` |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| `settled` | `upfront` | `{ status: "queued", payment: receipt }`. The store enqueues it; the provider wakes the lane and a worker leases it. `input` is still stored (it's dropped only at a final status). |
|
|
156
|
+
| `settled` | `on-success` | `{ status: "succeeded", payment: receipt }`. The output was stored before settling. If `output` is missing (a broken store), report `undecided`: never report a paid job as unpaid. |
|
|
157
|
+
| `not_settled` | any | `{ status: "failed", error: jobError("payment_failed", …) }`. `chargedFor()` gives `false`. |
|
|
158
|
+
| `undecided` | any | No write. |
|
|
159
|
+
|
|
160
|
+
When the compare-and-set misses (the job changed meanwhile), the reported status is re-read from the store, and
|
|
161
|
+
the earlier decision stands.
|
|
162
|
+
|
|
163
|
+
### 4.5 Why the 320-ledger window is sound
|
|
164
|
+
|
|
165
|
+
- The x402 facilitator refuses `signatureExpirationLedger > currentLedger + ceil(maxTimeout / estLedgerSecs) + 2`
|
|
166
|
+
at verify.
|
|
167
|
+
- The provider adds its own check at router step 7, only when the clock answers:
|
|
168
|
+
`expirationLedger > latest + maxTimeoutSeconds + EXPIRATION_TOLERANCE_LEDGERS (10)` → 402 with
|
|
169
|
+
`invalid_exact_stellar_payload_signature_expiration_too_far`, before the facilitator sees the payment. This
|
|
170
|
+
counts a ledger as at least 1 s.
|
|
171
|
+
- `maxTimeoutSeconds` is at most 300 (§4.1), so `E ≤ claimLedger + 310`, so `E − 320 ≤ claimLedger − 10`.
|
|
172
|
+
- A payment lands after verification. Before verification, a used nonce would fail the facilitator's
|
|
173
|
+
simulation, and the job would never be claimed.
|
|
174
|
+
- If the clock is down at claim time, the window relies on the facilitator's check alone.
|
|
175
|
+
|
|
176
|
+
## 5. Ledger clock (`payments/chain.ts`)
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
interface LedgerClock { latest(): Promise<number>; closesAt(ledger: number): Promise<number /* epoch ms */> }
|
|
180
|
+
ledgerClock(chain: Pick<ChainReader, "getLatestLedger" | "getLedgers">): LedgerClock
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- **A reading:** `getLatestLedger()`, then `getLedgers({ startLedger: latest - 100, pagination: { limit: 1 } })`.
|
|
184
|
+
`ledgerMs = (latestLedgerCloseTime - sample.ledgerCloseTime) * 1000 / (latestLedger - sample.sequence)`, kept
|
|
185
|
+
only within [1000, 60000]. Default 5000. If `getLedgers` fails, the latest ledger and the default duration
|
|
186
|
+
serve. If there is no usable close time, `Date.now()` stands in.
|
|
187
|
+
- `closesAt(L) = closedAt + (L - sequence) * ledgerMs`. `latest()` is the reading's real sequence, never
|
|
188
|
+
extrapolated.
|
|
189
|
+
- **Cache 5 s, timeout 3 s per reading.** A failed reading rejects until the next one is due, and warns once per
|
|
190
|
+
failure streak. A slow RPC never holds a request or job for more than 3 s.
|
|
191
|
+
- **Users:**
|
|
192
|
+
- `runner.ts` `paymentDeadline(job, run) = min(createdAt + max(1, maxTimeout − 10) s, closesAt(E) − 10 s)`.
|
|
193
|
+
If the clock fails, the claim-time deadline alone applies.
|
|
194
|
+
- `recovery.ts` `sweep()`: an on-success job, `queued` or `running`, with no `settlingSince`, fails `timeout`
|
|
195
|
+
uncharged when `createdAt < now − (maxTimeout + 60) s` **or** `clock.latest() > claim.expirationLedger`.
|
|
196
|
+
With the clock, the list bound is `now + 1 s`, so jobs created this millisecond are included.
|
|
197
|
+
- `router.ts` step-7 expiration check (§4.5).
|
|
198
|
+
- Measured live on testnet: about 4.8 s per ledger. SDF testnet RPC history is 120,960 ledgers (about 7 days).
|
|
199
|
+
|
|
200
|
+
## 6. Configuration
|
|
201
|
+
|
|
202
|
+
| Where | Field | Behavior |
|
|
203
|
+
| --- | --- | --- |
|
|
204
|
+
| `createProvider` | `rpc?: string \| ChainReader \| false` | `string`/`undefined` → `rpcChain(network, url, { dev })`; `undefined` on pubnet → no chain, plus a startup warning. A `ChainReader` is used as is. `false` → no chain reads at all (tests). |
|
|
205
|
+
| gateway config | `rpcUrl` | String; `env:NAME` allowed. Becomes `rpc`. |
|
|
206
|
+
| example env | `SOROBAN_RPC_URL` | Optional; becomes `rpc`. |
|
|
207
|
+
| `rpcChain()` | URL rules | `https:` only; `http://localhost`, `127.0.0.1` or `[::1]` only with `dev: true`. `rpc.Server` timeout 15 s. |
|
|
208
|
+
|
|
209
|
+
Background job in `provider.ts`: `reconcileJobs()` runs after the startup sweep and after every sweep interval
|
|
210
|
+
(30 s), never overlapping (a `reconciling` promise guards it), and never blocking `ready()`. It logs `settled` and
|
|
211
|
+
`not_settled` decisions with `console.error`; undecided jobs are not logged again. `close()` awaits it before
|
|
212
|
+
closing the store.
|
|
213
|
+
|
|
214
|
+
## 7. CLI
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
provider-kit reconcile (--config <file> | --store <file> --network <testnet|pubnet>) [--rpc <url>] [--dry-run] [--dev]
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- `--config` loads the gateway config and opens its store. `rpcUrl` comes from the config unless `--rpc` is
|
|
221
|
+
given.
|
|
222
|
+
- `--store` must point to an existing file; a typo is an error, not an empty store. `--network` accepts
|
|
223
|
+
`testnet`, `mainnet`, `pubnet`, `stellar:testnet` or `stellar:pubnet`.
|
|
224
|
+
- With no RPC (pubnet without `--rpc`), it stops with a usage error.
|
|
225
|
+
- **Exit codes:** 0 when every job is decided or there are none; 1 when any job is undecided, or on errors; 2 on
|
|
226
|
+
a usage error.
|
|
227
|
+
- It looks at the oldest 1000 jobs and says so when it hits that limit.
|
|
228
|
+
- It reads the chain, writes only the job store (compare-and-set) and signs nothing. It is safe beside a running
|
|
229
|
+
provider sharing the SQLite file.
|
|
230
|
+
|
|
231
|
+
## 8. Payment client changes (`packages/payment-client/src/client.ts`)
|
|
232
|
+
|
|
233
|
+
- `isInProgress(response)` is `status === 409`. The spec uses 409 only for `request_in_progress`.
|
|
234
|
+
- **`payOnce`:** after the busy branch, a 409 on the paid create-job runs
|
|
235
|
+
`whileSettling(baseUrl, jobId, token, response)`. A job view → `finish()`. Still 409 after `followSeconds` →
|
|
236
|
+
journal `uncertain`, then throw `PaymentUncertainError("the provider is still settling the payment")`.
|
|
237
|
+
Before this change, a 409 was journaled `uncertain` at once.
|
|
238
|
+
- **`whileSettling`:** cancels the body, waits `pollMs ?? Retry-After ?? 2000`, and calls get-result until it
|
|
239
|
+
answers something other than 409, 429 or 503. Returns `undefined` when the next wait would pass the
|
|
240
|
+
`followSeconds` deadline (default 120).
|
|
241
|
+
- **`follow`:** 409, 429 and 503 while polling mean "ask again later" [Q9], [J7]. Their bodies are cancelled.
|
|
242
|
+
- **`getResult`:** no longer throws on 409.
|
|
243
|
+
- **`recover`:** a 409 runs `whileSettling`. If it is still settling, it throws `PaymentUncertainError`. It never
|
|
244
|
+
signs.
|
|
245
|
+
- The journal sequence on the happy 409 path is `intent, approved, sending, settled, result`.
|
|
246
|
+
|
|
247
|
+
## 9. Tests
|
|
248
|
+
|
|
249
|
+
| File | Covers |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
| `packages/provider-kit/test/reconcile.test.ts` (24) | Settled via the named tx (no event search); settled via events while ignoring another job's equal transfer; `not_settled` with `charged: false`; undecided while the payment can still land; window older than history; backlog costs only one `getHealth`; window edge (`E − 320`); paging (150 other transfers, 2 `getEvents` calls); facilitator retry after a FAILED tx; fee-bump envelope; payment used without a matching transfer → undecided; RPC down; dry run; compare-and-set race; provider auto-reconcile at startup running the paid job; on-success timed by ledger (expires in 1 ledger → `timeout`, not settled); time left → succeeds; far expiration → 402 before verify; RPC down → claim-time fallback; sweep with clock; `ledgerClock` estimate, cache, default duration, warn-once; `rpcChain` URL rules |
|
|
252
|
+
| `packages/provider-kit/test/support/chain.ts` | Fake `ChainReader`: ledgers 5 s apart (the latest closed now), transactions, transfer events, cursors, `state.down`, `calls` counter. Like real RPCs, its events carry **no** `inSuccessfulContractCall`. |
|
|
253
|
+
| `packages/provider-kit/test/gateway.test.ts` (+1) | `rpcUrl` with `env:` and the https rule |
|
|
254
|
+
| `packages/payment-client/test/flow.test.ts` (+2) | 409 during settlement is followed, one signature, one settle; settlement outlasting the wait → `PaymentUncertainError`, then `recover()` works after release |
|
|
255
|
+
| Harnesses | `rpc: false` in `provider-kit/test/support/harness.ts`, `capacity.test.ts`, `payment-client/test/support/harness.ts` and the gateway Python test |
|
|
256
|
+
|
|
257
|
+
**Live read-only checks** (scratch scripts, not in the repo), against `https://soroban-testnet.stellar.org`:
|
|
258
|
+
|
|
259
|
+
- the RPC accepts the event filter format;
|
|
260
|
+
- an expired, never-paid claim → `not_settled`; an unexpired one → `undecided`;
|
|
261
|
+
- a real fee-bumped x402-style transfer is found from `(payer, nonce, expirationLedger)` alone, without its hash;
|
|
262
|
+
- the same payer with another nonce → `not_settled`.
|
|
263
|
+
|
|
264
|
+
**Commands:** `npm run typecheck`, `npm test`. CI also runs the `apps/wallet` check and build.
|
|
265
|
+
|
|
266
|
+
## 10. Known limits and open points
|
|
267
|
+
|
|
268
|
+
- **One RPC is trusted.** Decisions trust the configured RPC's answers (there is no multi-source quorum). Use an
|
|
269
|
+
RPC you operate or trust on mainnet.
|
|
270
|
+
- **History:** a job whose ledgers are older than the RPC's history stays `undecided`. Use
|
|
271
|
+
`provider-kit reconcile --rpc <rpc with more history>`.
|
|
272
|
+
- **Duplicate reads:** every provider process sharing a store reconciles, so RPC reads are duplicated. Writes
|
|
273
|
+
are compare-and-set, so this is safe.
|
|
274
|
+
- **No CLI unit test:** `provider-kit reconcile` is a thin wrapper, smoke-tested by hand (empty store, missing
|
|
275
|
+
store, usage errors, pubnet without RPC).
|
|
276
|
+
- **Tests aren't typechecked:** `tsc -b` covers `src` only, and several existing tests have type errors (for
|
|
277
|
+
example `payment-client/test/support/chain.ts`). The new test files typecheck cleanly.
|
|
278
|
+
- **Still to do:** the recorded testnet demo. Steps are in [job-recovery.md](../job-recovery.md#response-loss-drill).
|
|
279
|
+
|
|
280
|
+
## 11. When you change this code
|
|
281
|
+
|
|
282
|
+
- New chain reads go through `ChainReader` (and the fake in `test/support/chain.ts`), never a raw `fetch`.
|
|
283
|
+
- Keep `findPayment` pure: no store access. Keep all writes in `reconcile.ts`, compare-and-set only.
|
|
284
|
+
- Any new "can't tell" branch returns `undecided`, never `not_settled`.
|
|
285
|
+
- If `SEARCH_WINDOW_LEDGERS` or the step-7 check changes, re-derive §4.5 and update the comments in
|
|
286
|
+
`onchain.ts` and `router.ts`.
|
|
287
|
+
- Tests: add a case to `reconcile.test.ts` using `signedPayment()` (a real offline-signed payment through
|
|
288
|
+
`checkPayment`) and `landed()` (a fake transaction). Keep `rpc: false` in every other provider test.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Job Recovery, the short version
|
|
2
|
+
|
|
3
|
+
> **TL;DR**
|
|
4
|
+
> If the internet breaks after you pay, you still get your result.
|
|
5
|
+
> You **never pay twice**.
|
|
6
|
+
> If nobody knows whether the money moved, **we ask the blockchain**.
|
|
7
|
+
|
|
8
|
+
Full details: [job-recovery.md](../job-recovery.md) · For AI agents: [README-AI.md](README-AI.md)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 🧃 The idea in one picture
|
|
13
|
+
|
|
14
|
+
You buy a coffee. You pay. The Wi-Fi drops before the receipt prints.
|
|
15
|
+
|
|
16
|
+
- ❌ Bad shop: "Pay again."
|
|
17
|
+
- ✅ Our shop: "Show me your order number. Here's your coffee."
|
|
18
|
+
|
|
19
|
+
**The order number = the job id.** The client saves it *before* paying, so it is never lost.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 🔘 Three buttons
|
|
24
|
+
|
|
25
|
+
| Button | What it does | Costs money? |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| **create-job** | Start a job (and pay) | Yes, once |
|
|
28
|
+
| **check-status** | "Is it done?" | Never |
|
|
29
|
+
| **get-result** | "Give me the answer" | Never |
|
|
30
|
+
|
|
31
|
+
Lost the connection? Press **get-result** again. Free.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 😬 The scary case: "payment_uncertain"
|
|
36
|
+
|
|
37
|
+
Sometimes the payment service doesn't answer in time.
|
|
38
|
+
Did the money move? **Nobody knows.**
|
|
39
|
+
|
|
40
|
+
The rule: **don't guess, and don't charge again.** Look at the blockchain instead.
|
|
41
|
+
|
|
42
|
+
| The blockchain says | What happens |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| 💸 The money moved | The job runs, or the result is shown |
|
|
45
|
+
| 🚫 The money never moved, and now it can't | Job fails, **you are not charged** |
|
|
46
|
+
| 🤷 Can't tell yet | Wait and look again later |
|
|
47
|
+
|
|
48
|
+
This check is called **reconciliation**.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 🛠️ What I built
|
|
53
|
+
|
|
54
|
+
1. **The wallet stops panicking.**
|
|
55
|
+
"Still settling" (HTTP `409`) used to look like an error. Now the wallet just waits and keeps asking.
|
|
56
|
+
|
|
57
|
+
2. **Providers check the blockchain by themselves.**
|
|
58
|
+
Every 30 seconds they look at "uncertain" jobs and decide them.
|
|
59
|
+
|
|
60
|
+
3. **A command for operators.**
|
|
61
|
+
`provider-kit reconcile`. Add `--dry-run` to see what would happen without changing anything.
|
|
62
|
+
|
|
63
|
+
4. **A ledger clock.**
|
|
64
|
+
A payment expires at a *blockchain block* (a "ledger"), not at a clock time.
|
|
65
|
+
Jobs are now timed by that block, so nobody works on a payment that already expired.
|
|
66
|
+
|
|
67
|
+
5. **Docs and tests.**
|
|
68
|
+
27 new tests. All 491 pass.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 🔍 How we find the right payment
|
|
73
|
+
|
|
74
|
+
The same person can pay the same price many times.
|
|
75
|
+
So "100 USDC from Alice" is **not** proof for *this* job.
|
|
76
|
+
|
|
77
|
+
Every payment has a **one-time number** (a nonce) inside its signature.
|
|
78
|
+
The blockchain accepts each one **once**.
|
|
79
|
+
→ We look for that exact number. No mix-ups.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 🐛 Bug I caught with real data
|
|
84
|
+
|
|
85
|
+
I tested against the real Stellar testnet (read-only).
|
|
86
|
+
The code skipped every real event, because the network stopped sending one old field.
|
|
87
|
+
**Effect:** a paid job could have been marked "not charged". Now it's fixed and tested.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 🛡️ Why nobody pays twice
|
|
92
|
+
|
|
93
|
+
- The wallet saves the job id **before** paying.
|
|
94
|
+
- One job id = one job, forever.
|
|
95
|
+
- One signed payment = one job, forever.
|
|
96
|
+
- Unsure? **Never charge again**: ask the blockchain.
|
|
97
|
+
- Recovery **only reads**. It never pays.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## ✅ What's left
|
|
102
|
+
|
|
103
|
+
- [ ] Record the recovery demo on testnet (video plus proof of one single transfer)
|
|
104
|
+
- [ ] Commit this work and open a PR
|
|
105
|
+
- [ ] Mainnet providers must set their own RPC URL (testnet uses a free public one)
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 📚 Words
|
|
110
|
+
|
|
111
|
+
| Word | Means |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| **Job** | One paid request to a service |
|
|
114
|
+
| **Settle** | The payment actually moves on chain |
|
|
115
|
+
| **Ledger** | One block of the Stellar blockchain, about every 5 seconds |
|
|
116
|
+
| **RPC** | The server we ask about the blockchain |
|
|
117
|
+
| **Nonce** | A one-time number inside a payment signature |
|
|
118
|
+
| **on-success** | You pay only if the job works |
|
|
119
|
+
| **upfront** | You pay first, then the job runs |
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SOW closeout and pilot handoff
|
|
2
|
+
|
|
3
|
+
Reviewed on 2026-11-06 against the [source SOW](https://docs.google.com/document/d/1kIJ5nkwIVaXqHqcNHwuI5JN0Ap6g86wzncMQya6Kv88/edit)
|
|
4
|
+
and this checkout. The [implementation plan](../IMPLEMENTATION_PLAN.md) records the build; this page records the
|
|
5
|
+
remaining acceptance work. Prepared templates are not evidence of completed pilot work.
|
|
6
|
+
|
|
7
|
+
## Readiness
|
|
8
|
+
|
|
9
|
+
| Area | Available | Still needed for SOW acceptance |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| D1 | Standard, client, spending controls, publicly hosted approval/limit-block video, screenshots and payment proof | Chapter Lead review |
|
|
12
|
+
| D2 | Kit, guide, stable Oracle sample, all C1–C7 passed, anchored receipt/five badges, recovery and payment proofs | Chapter Lead review |
|
|
13
|
+
| D3 | Scaffolder and onboarding guide | Independent integrations, user flows, findings/fixes and completed pilot report |
|
|
14
|
+
| Distribution | Six npm packages at 0.1.0 and a successful public release | A later release for the changes in this checkout |
|
|
15
|
+
| Discovery | Newer implementation notes report testnet registry scanning and manifest service matching | Confirm the released discovery path in the pilot and show the complete find-to-result flow |
|
|
16
|
+
|
|
17
|
+
Public release and CI references, and local check results, belong in [readiness evidence](../evidence/readiness.md).
|
|
18
|
+
The latest observed daily live workflow failed its paid testnet call; do not treat ordinary CI as a live payment
|
|
19
|
+
proof. A trusted registry/risk-source adapter remains application integration work; configured verification limits
|
|
20
|
+
now block new payments unless a trusted source can satisfy them [V1].
|
|
21
|
+
|
|
22
|
+
## Acceptance decisions for the owner
|
|
23
|
+
|
|
24
|
+
The source SOW conflicts: D3 says three independent providers and five users, while the objective and evidence
|
|
25
|
+
section say two providers and two users; its exclusions cap providers at two. The working target is the sample
|
|
26
|
+
plus two independent providers, and five distinct pilot users. This interpretation still needs Chapter Lead
|
|
27
|
+
agreement. Five flows by one person do not establish five users.
|
|
28
|
+
|
|
29
|
+
Also confirm that testnet evidence is accepted. Record the agreement and sprint start date in the pilot report;
|
|
30
|
+
the SOW's submission date does not establish the sprint deadline.
|
|
31
|
+
|
|
32
|
+
## Completed reference deployment and evidence
|
|
33
|
+
|
|
34
|
+
The [sample runs on the existing Oracle A1 VM](../deploy/d2/oracle/README.md), with separate process, user,
|
|
35
|
+
loopback port, SQLite data and HTTPS origin. No new cloud resources were allocated. Its public testnet owner
|
|
36
|
+
is separate from the existing mainnet Logo Maker wallet. The mainnet manifest hash remained unchanged.
|
|
37
|
+
|
|
38
|
+
The sample is registered as testnet agent 163. [Registry evidence](../evidence/validation/README.md) includes
|
|
39
|
+
confirmed registration, request and response transactions, the request document, receipt hash and on-chain
|
|
40
|
+
score/tag. All C1–C7 checks passed. The project-operated validator is separate from the provider and disposable
|
|
41
|
+
payer; it is not an independent auditor. Signing keys stay in local Stellar CLI secure storage.
|
|
42
|
+
|
|
43
|
+
Review these completed reference artifacts:
|
|
44
|
+
|
|
45
|
+
1. [D1 approval, spending block, payment and result recording](../evidence/d1/README.md).
|
|
46
|
+
2. [Registered sample receipt and five badges](../evidence/receipts/README.md), with failures/skips from historical runs preserved.
|
|
47
|
+
3. [D2 same-job recovery recording and bounded single-transfer proof](../evidence/d2/README.md) [J11], [J13], [J16].
|
|
48
|
+
4. [Linux client before/after evidence](../evidence/d2/oracle-clean-client.json): npm 0.1.0 returned uncertainty;
|
|
49
|
+
its original job later reconciled. The unreleased polling fix completed a distinct testnet call and
|
|
50
|
+
confirmed its transfer. This does not claim a scheduled CI rerun or an independent clean-host integration.
|
|
51
|
+
5. [Public evidence page](https://algorialabs.github.io/stellar-agent-services/) and [publication checks](../evidence/publication.json).
|
|
52
|
+
|
|
53
|
+
The [Oracle runbook](../deploy/d2/oracle/README.md) records maintenance and rollback. For another provider,
|
|
54
|
+
follow the [setup guide](provider-setup-guide.md), use its own public payout, and have its owner sign registry
|
|
55
|
+
steps in their wallet. Never deploy an owner signing key. Mainnet proof remains deferred until funded;
|
|
56
|
+
all completed reference payments and registry records are explicitly testnet.
|
|
57
|
+
|
|
58
|
+
## Run the independent pilot
|
|
59
|
+
|
|
60
|
+
Use [the pilot checklist](../evidence/pilot/README.md) and [report template](../evidence/pilot/report.md). Recruit
|
|
61
|
+
providers who control their own code, payout wallet and deployment. Have them follow the guide from a clean
|
|
62
|
+
environment; record assistance and blockers. Each provider needs a real paid testnet service call and its own
|
|
63
|
+
linked evidence. Capture discovery, approval, payment and result for each user; at least one demonstrates recovery.
|
|
64
|
+
|
|
65
|
+
Resolve blocking issues, retain before/after observations and rerun only affected flows. Submit the evidence index
|
|
66
|
+
with completed videos, receipts, transaction references, registry records and report, then request Chapter Lead
|
|
67
|
+
acceptance against SOW §6.2.
|