Repository navigation
feat(lore-0034): add the receiver authorizer and drop UptoProxy nonce storage - #181
Conversation
stkrolikiewicz
left a comment
There was a problem hiding this comment.
A few things outside the diff that this PR made stale:
lore/1-tasks/active/0034_FEATURE_upstream-upto-stellar/drafts/issue-proposal.md:36still cites the retiredCAL7SBTO…V2VCand the 58-scenario run. If it goes upstream with the spec, maintainers see two canonical testnet addresses.deploy/README.md:19still saysUPTO_PROXY_CONTRACT_IDintestnet.env.exampleis the task 0035 deployment.packages/signer-pool/test/live/testnet-smoke.ts:123still builds the receiver entry withsorobanCredentialsSourceAccount, the shape this PR removes from the e2esettleCallbecause the pool refuses it. AgainstCDPHI4JY…EA2Xthe smoke script won't settle.
| ### Phase 4: Submission | ||
|
|
||
| 1. Durably record `(from, nonce)` as settled **before** submitting, and never delete it. | ||
| 1. Durably record `(from, authorization nonce)` of the client's entry **before** submitting, and refuse a second `/settle` for an entry already recorded. The host would refuse the second transaction anyway, but only after the facilitator has paid its fee; the record stops concurrent `/settle` calls on one payload from both being sent. |
There was a problem hiding this comment.
Record-before-submit plus "refuse a second /settle for an entry already recorded" leaves no path for a settlement that fails.
A failed or refused transaction rolls back the host's nonce, so the client's entry is still valid on chain until its expiration ledger. With this rule, though, no retry gets through, and in upto the seller has already served.
The exact code already does better (apps/facilitator/src/settlement/settle.ts, UNSENT_REASONS, 0073): a record that ended with nothing sent reopens on the identical payload. Two things for the spec:
- Describe that reopen here (nothing sent → the record can be reopened while the entry is valid), instead of a flat refusal.
- Sent and then failed:
exactanswers "sign a new payment", which doesn't work forupto, because the client has no reason to re-sign after being served. Should a sent-and-faileduptorecord reopen while the client's entry is unexpired? Worth deciding before 0015.
Same rule in contracts/upto-proxy/README.md:254.
There was a problem hiding this comment.
Agreed on both points. Spec Phase 4 step 1 now refuses a second /settle only while the record is in progress, settled or uncertain. It reopens for the same client entry and actual amount, while that entry is unexpired:
- SHOULD when nothing was sent, as
exactdoes since 0073; - MAY after a final
FAILED, which rolls the host nonce back. The seller has already served, so the client has no reason to sign again.
A reopen repeats the enforcing simulation first, and an uncertain outcome never reopens. Security item 9 adds a bound on resends per entry. The same rule is in contracts/upto-proxy/README.md step 4, ADR 0010 and the threat model. The resend limit and how a reopen claims the record are left for 0015 (noted in the 0034 task). e871ad2
| - **The token's own entries.** A first allowance or balance entry, or a token instance that extends its own TTL in the same transaction. The proxy doesn't bound this. | ||
|
|
||
| Measured on testnet on 2026-10-09: a normal settlement cost 29,000–47,000 stroops; the costliest settlement without token rent is about 215,000; a settlement in which a SAC instance also extended itself by 54,689 ledgers cost 330,032, and up to about 480,000 is possible for a SAC instance close to expiry. `exact`'s 50,000-stroop default is far too low for `upto`. We suggest a separate `upto` ceiling of about 500,000 stroops, and that facilitators keep the instances of the tokens they accept extended outside settlements, so token rent rarely lands on a payment. | ||
| Measured on testnet on 2026-11-12, against the current deployment: a settlement at the maximum cost 20,126–21,789 stroops and a zero settlement about 16,500. Measured on 2026-10-09, against the previous deployment: a normal settlement cost 29,000–47,000 stroops; the costliest settlement without token rent is about 215,000; a settlement in which a SAC instance also extended itself by 54,689 ledgers cost 330,032, and up to about 480,000 is possible for a SAC instance close to expiry. `exact`'s 50,000-stroop default is far too low for `upto`. We suggest a separate `upto` ceiling of about 500,000 stroops, and that facilitators keep the instances of the tokens they accept extended outside settlements, so token rent rarely lands on a payment. |
There was a problem hiding this comment.
These figures don't match the 61/61 run the spec cites (line 428). docs/upto-proxy-testnet-report.md and testnet-results.json for 2026-11-12 20:58 UTC show 21,399–23,061 stroops at the ceiling and 17,653–17,770 for zero. 20,126–21,789 / ~16,500 look like the earlier 58/58 run.
There was a problem hiding this comment.
Right, those were from the earlier 58/58 run. Now 21,399–23,061 at the ceiling and 17,653–17,770 for zero, from the 61/61 run. e871ad2
| "settles a self-managed payment relayed by another facilitator", | ||
| "success, the seller's key authorizes, the other facilitator pays", | ||
| async (r) => { | ||
| const t = terms(kind, { facilitator: world.seller.publicKey() }); |
There was a problem hiding this comment.
UptoTerms.facilitator now holds the receiver authorizer, so here the seller is passed as facilitator while otherSubmitter submits, and checkSettled compares event.data.receiver_authorizer with t.facilitator. Rename the field to receiverAuthorizer (in e2e/src/upto.ts) to match the contract, the event and the spec?
There was a problem hiding this comment.
Done: UptoTerms.receiverAuthorizer. I kept the scenario names, so they still match the testnet report. ed11c0b
| // Self-managed mode: the seller's own account is the receiver authorizer. It signs its entry for | ||
| // the actual amount without simulation, and the other facilitator, which never saw the payment | ||
| // before, relays it through its own channel and fee bump. | ||
| it("settles a self-managed payment relayed by another facilitator", () => |
There was a problem hiding this comment.
Nit: this scenario repeats the body of settleAndCheck (checkClientAuth, balances, submit, checkSettled, deltas) and changes only the submitter, the authorizer and the expected payer and channels. Those could be options on settleAndCheck.
There was a problem hiding this comment.
Done: settleAndCheck takes { authorizer, via, payer, channels }, defaulting to the delegated shape, and the relay scenario is a single call now. The e2e suite passed 61/61 again on 2026-11-13 against the same proxy; the results are in results/testnet-results-2026-11-13.json, and the report still cites the 2026-11-12 run. ed11c0b
|
Thanks, all three fixed in e871ad2 and ed11c0b:
|
stkrolikiewicz
left a comment
There was a problem hiding this comment.
Thanks, all seven addressed. The reopen rule in Phase 4 reads well; the resend bound and how a reopen claims the record are fine to settle in 0015. The smoke script change is type-checked only, so it is worth one run before it is relied on.
The maintainer review of the upstream spec PR landed with two comments, so the task is active again. Record both decisions (receiver authorizer with self-managed and facilitator-delegated modes; no x402 nonce storage in UptoProxy), the open points okarcz decided, and the choices that emerged while writing the spec.
The bound key is now `receiver_authorizer`: the facilitator's key (facilitator-delegated) or the resource server's own key (self-managed). Its require_auth already covered actual_amount, so only the name changes, in the argument and the upto_settled event. Single use is Soroban's auth nonce. The contract no longer stores (from, nonce): `nonce` is a signed payment ID, a second entry with the same ID is a second payment the client signed and settles. BREAKING CHANGE: `is_nonce_used` and error 7 (`NonceUsed`) are gone; the `facilitator` argument and event field are `receiver_authorizer`. New WASM, new testnet address.
The signer pool refuses source-account auth entries, which kept the suite from settling. The receiver authorizer now signs its own address-credential entry for the call, the self-managed shape. The NonceUsed scenario becomes "settles a reused payment ID under a new signature". 58/58 on testnet against CDPHI4JY…EA2X.
Contract README, ADR 0010 (D6 amended, D12 added), threat model, the 2026-11-12 testnet run, the new testnet address and the scheme_upto_stellar.md draft: both receiver authorizer modes, payload.receiverAuthorization, single use through the auth nonce.
The seller's own account is the receiver authorizer: it signs its entry for the actual amount without simulation, and the other facilitator relays it through its own channel and fee bump. The reused-payment-ID scenario now uses fresh terms, so a slow run can't push it past its deadline. 61/61 on testnet.
Record every settlement before submitting it, so concurrent /settle calls don't pay for a doomed duplicate. In self-managed mode /verify reads the client's nonce entry, since recording-mode simulation can't see it. A facilitator that doesn't relay self-managed payments refuses them at /verify. Name extra.receiverAuthorizer in /supported, keep retired delegated keys, note that the settlement record is per facilitator, key rotation, and the 61/61 run.
The review fixes change what the seller side must do in upto. No seller SDK package exists yet, so the note records where that code lands, what it needs at the 402 and at /settle, and what to check first.
A recorded upto settlement now reopens while the client's entry is unexpired: when nothing was sent (as exact does since 0073), and, optionally, after a FAILED transaction, which rolls back the host's nonce. Unlike exact, the seller has already served and the client has no reason to sign again. A reopen repeats the enforcing simulation. Also fix the spec's fee figures to the cited 61/61 run and point the issue draft and deploy README at the current proxy (Stan's review).
UptoTerms.facilitator becomes receiverAuthorizer, matching the contract, the event and the spec. settleAndCheck takes the authorizer, submitter, payer and channels, so the self-managed relay scenario no longer repeats it. The signer-pool smoke script signs an address-credential receiver entry, which the pool accepts. The e2e suite passed 61/61 again on 2026-11-13 (results kept in a separate file).
ed11c0b to
688df03
Compare
Summary
Task 0034 (merge
uptofor Stellar upstream): the maintainer review of our upstream spec PR asked for two changes. This PR makes both in UptoProxy, redeploys it on testnet, runs the e2e suite again and updates the docs and the spec draft. It is for our team's review first: nothing has been posted upstream, and nothing will be until we agree on it here.contracts/upto-proxy/src/lib.rs): the bound key is nowreceiver_authorizer, either the facilitator's key (facilitator-delegated mode) or the resource server's own key (self-managed mode), as in the SVMuptospec. Itsrequire_auth()already coveredactual_amount, so only the name changes, in the argument and theupto_settledevent.is_nonce_usedand error 7 (NonceUsed) are gone, andnoncestays as a signed payment ID. A second entry signed with the same ID is a second payment, and it settles.CDPHI4JYNSWNAQFXUAZAWTBUBYLZZRWFU6F6EJIHLSTAL6MJWXOVEA2X(WASMa3d7bd0a…f666), now indeploy/testnet.env.example.CAL7SBTO…V2VCis retired.contracts/upto-proxy/e2e): the signer pool refuses source-account auth entries, which had kept the suite from settling until 0015. The receiver authorizer now signs its own address-credential entry. A new scenario covers self-managed mode: the seller's account signs the receiver authorization and another facilitator relays it.contracts/upto-proxy/README.md, ADR 0010 with D6 amended and D12 added,docs/threat-model.md,docs/upto-proxy-testnet-report.md): updated for both changes.docs/scf/milestone 1 files keep the old address on purpose, since they record what milestone 1 delivered.lore/1-tasks/active/0034_FEATURE_upstream-upto-stellar/drafts/scheme_upto_stellar.md): both modes,extra.receiverAuthorizer,payload.receiverAuthorization, single use through the auth nonce, and rewritten verification, settlement, error and security sections.Please confirm
/verifycan't run the enforcing simulation (payments): the server signs only at/settle./verifysimulates in recording mode and checks the client's signature and auth nonce itself. A smart-wallet client whose__check_authfails is therefore caught only at settlement, which the spec now lists under the server's credit risk.G...andC...accounts. AC...authorizer is verified only by the enforcing simulation under the fee ceiling, as a smart-wallet client is. Our seller SDK will sign with aG...key first.(from, auth nonce), not the payment ID, before every settlement. Zero settlements need it because they never reach the chain. Non-zero ones need it so that concurrent/settlecalls on one payload don't pay for a duplicate that would fail. The record is per facilitator, so in self-managed mode a server could still charge a zero-settled entry through another facilitator, within its ceiling. In self-managed mode a zero/settlestill needspayload.receiverAuthorization.allowance_expiration_ledger, because the facilitator pays the rent on its nonce./verifywithinvalid_upto_stellar_payload_receiver_authorization, so the server learns before it serves. In self-managed mode/verifyreads the client's nonce entry withgetLedgerEntries, because a recording-mode simulation can't see it.Testing
pnpm contracts:test→ 40 passed, 0 failed;pnpm contracts:test:wasm→ both WASM tests pass (onlysettle_uptoexported, 3,727 bytes).pnpm contracts:e2eon testnet, 2026-11-12 20:58 UTC → 61/61, 29 transactions, new proxyCDPHI4JY…EA2X.Error(Auth, ExistingValue).cfed0770…7ad9, UPTOE2Eca1ec753…7ce8, TEST5ed0e7af…975f).pnpm format:check(tracked files),pnpm lint,pnpm typecheck,pnpm build,pnpm contracts:lint→ pass.pnpm testwas not run locally, because no package code changed; CI runs it.