Skip to content

feat(lore-0034): add the receiver authorizer and drop UptoProxy nonce storage - #181

Merged
karczuRF merged 9 commits into
developfrom
lore-0034-review-fixes
Nov 13, 2026
Merged

karczuRF merged 9 commits into
developfrom
lore-0034-review-fixes

Conversation

@karczuRF

Copy link
Copy Markdown
Collaborator

Summary

Task 0034 (merge upto for 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.

  • Receiver authorizer (contracts/upto-proxy/src/lib.rs): the bound key is now receiver_authorizer, either the facilitator's key (facilitator-delegated mode) or the resource server's own key (self-managed mode), as in the SVM upto spec. Its require_auth() already covered actual_amount, so only the name changes, in the argument and the upto_settled event.
  • No contract nonce storage (same file): Soroban's auth nonce is the single-use check. is_nonce_used and error 7 (NonceUsed) are gone, and nonce stays as a signed payment ID. A second entry signed with the same ID is a second payment, and it settles.
  • New testnet deployment: CDPHI4JYNSWNAQFXUAZAWTBUBYLZZRWFU6F6EJIHLSTAL6MJWXOVEA2X (WASM a3d7bd0a…f666), now in deploy/testnet.env.example. CAL7SBTO…V2VC is retired.
  • e2e suite settles again (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.
  • Docs (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.
  • Spec draft (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

  1. Self-managed /verify can't run the enforcing simulation (payments): the server signs only at /settle. /verify simulates in recording mode and checks the client's signature and auth nonce itself. A smart-wallet client whose __check_auth fails is therefore caught only at settlement, which the spec now lists under the server's credit risk.
  2. Receiver authorizer keys (payments): the spec allows G... and C... accounts. A C... authorizer is verified only by the enforcing simulation under the fee ceiling, as a smart-wallet client is. Our seller SDK will sign with a G... key first.
  3. The settlement record (payments): the facilitator records (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 /settle calls 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 /settle still needs payload.receiverAuthorization.
  4. The receiver authorization's expiry (payments): at most the client's allowance_expiration_ledger, because the facilitator pays the rent on its nonce.
  5. Facilitators that don't relay self-managed payments (payments): they refuse them at /verify with invalid_upto_stellar_payload_receiver_authorization, so the server learns before it serves. In self-managed mode /verify reads the client's nonce entry with getLedgerEntries, because a recording-mode simulation can't see it.
  6. Operation source (payments, platform): the spec no longer says the facilitator SHOULD be the operation source. In facilitator-delegated mode it MAY be, but source-account credentials MUST NOT be used when the operation has no source of its own, since they would authorize as the channel account.

Testing

  • pnpm contracts:test → 40 passed, 0 failed; pnpm contracts:test:wasm → both WASM tests pass (only settle_upto exported, 3,727 bytes).
  • pnpm contracts:e2e on testnet, 2026-11-12 20:58 UTC → 61/61, 29 transactions, new proxy CDPHI4JY…EA2X.
    • A replayed entry fails with Error(Auth, ExistingValue).
    • A reused payment ID under a new signature settles on all three tokens.
    • A self-managed payment, with the seller's key as receiver authorizer and relayed by the other facilitator, settles on all three tokens (USDC cfed0770…7ad9, UPTOE2E ca1ec753…7ce8, TEST 5ed0e7af…975f).
    • A settlement at the ceiling cost 21,399–23,061 stroops.
    • Details are in the testnet report. An earlier run that day, before the self-managed scenario was added, passed 58/58.
  • Code review before the PR (4 reviewers): 3 decisions and 15 fixes applied, in the last two commits.
  • pnpm format:check (tracked files), pnpm lint, pnpm typecheck, pnpm build, pnpm contracts:lint → pass.
  • pnpm test was not run locally, because no package code changed; CI runs it.

@stkrolikiewicz stkrolikiewicz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things outside the diff that this PR made stale:

  • lore/1-tasks/active/0034_FEATURE_upstream-upto-stellar/drafts/issue-proposal.md:36 still cites the retired CAL7SBTO…V2VC and the 58-scenario run. If it goes upstream with the spec, maintainers see two canonical testnet addresses.
  • deploy/README.md:19 still says UPTO_PROXY_CONTRACT_ID in testnet.env.example is the task 0035 deployment.
  • packages/signer-pool/test/live/testnet-smoke.ts:123 still builds the receiver entry with sorobanCredentialsSourceAccount, the shape this PR removes from the e2e settleCall because the pool refuses it. Against CDPHI4JY…EA2X the 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Describe that reopen here (nothing sent → the record can be reopened while the entry is valid), instead of a flat refusal.
  2. Sent and then failed: exact answers "sign a new payment", which doesn't work for upto, because the client has no reason to re-sign after being served. Should a sent-and-failed upto record reopen while the client's entry is unexpired? Worth deciding before 0015.

Same rule in contracts/upto-proxy/README.md:254.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 exact does 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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", () =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@karczuRF

Copy link
Copy Markdown
Collaborator Author

Thanks, all three fixed in e871ad2 and ed11c0b:

  • drafts/issue-proposal.md now names CDPHI4JY…EA2X and the 61-scenario run (27 settlements).
  • deploy/README.md says the address is the deployment from task 0034 (CDPHI4JY…EA2X, the 61/61 run of 2026-11-12).
  • testnet-smoke.ts: the facilitator now signs its own address-credential receiver entry, expiring at the client allowance ledger, as the e2e settleCall does. It had been broken since d85b041, not by this PR. It type-checks, but I have not run it.

@stkrolikiewicz stkrolikiewicz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).
@karczuRF
karczuRF force-pushed the lore-0034-review-fixes branch from ed11c0b to 688df03 Compare November 13, 2026 14:02
@karczuRF
karczuRF merged commit dfde337 into develop Nov 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants