Skip to content

Commit a563052

Browse files
soumen0818claude
andcommitted
feat(backend): the settlement boundary — Phase 3
The explicit join between privacy and value. Flagged as the highest-risk phase; Option C is what made it tractable, because custody never moves and the boundary therefore carries evidence rather than authority. 3.1 — SettlementIntent wraps the existing SettlementRequest instead of duplicating its fields: those are already bound inside the Soroban proof, and copying them would create a second version of the truth that can drift from the first. ComplianceStatus is deliberately not a boolean. "No proof was offered" and "a proof was offered and failed" are opposite facts — an old client versus a bug or an attack — and collapsing them hides the difference exactly when it matters. Validate() refuses contradictions rather than repairing them: verified with no commitment is unauditable and therefore indistinguishable from unverified; failed with no reason is vague at the least useful moment. 3.2 — AuthorisedBy() makes the trust model legible at the point of use. Under Option C it always names the Soroban proof; a verified Midnight proof is recorded as advisory. That is the honest description, and the phase brief demanded it: do not describe this as trustless unless a proof, not an operator, enforces it. A test asserts the string always leads with the Soroban proof, so the claim cannot quietly drift. 3.3 — The idempotency key is the NULLIFIER, not a generated id. The contract refuses a nullifier it has seen, so a duplicate cannot move value twice, enforced on-chain across every replica rather than in one process's memory. A generated key would be weaker while looking stronger. Exit test met: TestDuplicateIntentCannotPayTwice settles once, resubmits the identical intent, and asserts ErrAlreadySettled with value having moved exactly once. TestDistinctIntentsBothSettle checks the key is not so broad that it swallows unrelated payments. 9 tests, 13 backend packages passing, vet clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4527b88 commit a563052

3 files changed

Lines changed: 465 additions & 16 deletions

File tree

Docs/progress.md

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -438,28 +438,65 @@ identity and credential details never disclosed?_
438438

439439
---
440440

441-
## Phase 3 — The settlement boundary
441+
## Phase 3 — The settlement boundary
442442

443-
**Goal:** the explicit, auditable join between privacy and value. This is the highest-risk phase.
443+
**Goal:** the explicit, auditable join between privacy and value. Flagged as the highest-risk phase,
444+
and Option C is what made it tractable — custody never moves, so the boundary carries evidence rather
445+
than authority.
444446

445-
### 3.1 Settlement intent ☐
447+
`backend/internal/provider/intent.go`, 9 tests.
446448

447-
Define and version the structure (V2 doc §15). It must carry enough for settlement and nothing
448-
more — that is the selective-disclosure principle applied at the most tempting place to violate it.
449+
### 3.1 Settlement intent ☑
449450

450-
### 3.2 Authorisation ☐ ⚠
451+
`SettlementIntent` wraps the existing `SettlementRequest` rather than duplicating its fields: those
452+
are already bound inside the Soroban proof, and copying them would create a second version of the
453+
truth that can drift from the first.
451454

452-
How does the settlement service know an intent is genuine and unspent? Under Phase 1.3's chosen
453-
custody model, write down what is cryptographically enforced and what is trusted. **Do not describe
454-
this as trustless unless a proof, not an operator, enforces it.**
455+
What it adds is what Midnight concluded — and `ComplianceStatus` is deliberately **not** a boolean:
455456

456-
### 3.3 Exactly-once settlement ☐
457+
| Status | Meaning |
458+
| --------------- | --------------------------------------------------------------------------------- |
459+
| `not_attempted` | No Midnight proof accompanied this transfer — the expected state during migration |
460+
| `verified` | A proof was supplied and checked out |
461+
| `failed` | A proof was supplied and **did not** verify |
457462

458-
One verified private transfer must produce exactly one settlement — under retries, timeouts,
459-
crashes and duplicate intents. Test the failure modes deliberately, not just the happy path.
463+
"No proof was offered" and "a proof was offered and failed" are opposite facts. A boolean collapses
464+
them, and the difference matters most when something is wrong: the first is an old client, the second
465+
is a bug or an attack.
460466

461-
**Exit test:** _Does one verified transfer produce exactly one Stellar settlement, and can a
462-
deliberately duplicated or replayed intent never produce a second payment?_
467+
`Validate()` refuses contradictions rather than repairing them — verified with no commitment (which
468+
would be unauditable, so indistinguishable from unverified), failed with no reason, not-attempted
469+
carrying evidence. A boundary type that silently fixes its own inconsistencies is one nobody can
470+
reason about.
471+
472+
Versioned from the start, because this joins two systems that will not always be deployed together.
473+
474+
### 3.2 Authorisation ☑ — and the honest answer is "not Midnight"
475+
476+
`AuthorisedBy()` exists so the trust model is legible at the point of use rather than in a document
477+
somebody has to remember. Under Option C it always returns the Soroban proof; a verified Midnight
478+
proof is recorded as **advisory**.
479+
480+
That is the honest description and the phase brief demanded it: _"do not describe this as trustless
481+
unless a proof, not an operator, enforces it."_ Today Soroban enforces — note ownership, conservation,
482+
double-spend and KYC in-circuit. Midnight observes.
483+
484+
A test asserts the string always leads with the Soroban proof, so the claim cannot quietly drift. The
485+
day the pool contract verifies a Midnight proof reference directly, that function changes and every
486+
caller reading it gets the new answer at once.
487+
488+
### 3.3 Exactly-once settlement ☑
489+
490+
**The idempotency key is the nullifier, not a generated id.** That is the whole trick: the contract
491+
refuses a nullifier it has already seen, so a duplicate cannot move value twice — enforced on-chain
492+
across every replica, rather than by bookkeeping in one process's memory. A generated key would be
493+
strictly weaker while looking stronger, which is the worst combination.
494+
495+
**Exit test — _does one verified transfer produce exactly one settlement, and can a duplicate or
496+
replay never produce a second payment?_****yes.** `TestDuplicateIntentCannotPayTwice` settles once,
497+
submits the identical intent again, and asserts `ErrAlreadySettled` with the value having moved
498+
exactly once. `TestDistinctIntentsBothSettle` checks the key is not so broad that it swallows
499+
unrelated payments.
463500

464501
---
465502

@@ -596,8 +633,8 @@ Recorded because each cost real time and each has a cheap rule that prevents it.
596633
| ----------------------- | ------ | ------------------------------------------------------- |
597634
| 0 — Foundations || 0.1–0.4 ☑ · 0.5 needs a device and a browser |
598635
| 1 — Midnight decision || 1.1 ☑ · 1.3 ☑ Option C · 1.2 measurement comes from 2.1 |
599-
| 2 — Privacy core | | Unblocked — start with 2.1 |
600-
| 3 — Settlement boundary | | What enforces intent authenticity? |
636+
| 2 — Privacy core | | 2.1 ☑ 2.1b ☑ 2.2 ☑ 2.3 ☑ |
637+
| 3 — Settlement boundary | | Authorised by Soroban; Midnight advisory |
601638
| 4 — Stellar adapter || Licensed payout partner (commercial) |
602639
| 5 — App integration || On-device proving must survive |
603640
| 6 — Hardening |||
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package provider
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
)
8+
9+
/*
10+
* The settlement boundary (Docs/progress.md §3).
11+
*
12+
* # What a settlement intent is
13+
*
14+
* The one explicit join between the privacy layer and the value layer. Midnight proves a transfer is
15+
* *allowed*; Stellar moves the value. Something has to carry the first fact to the second, and this
16+
* is that something — named, versioned and carrying as little as possible.
17+
*
18+
* # Why it is not a second proof system
19+
*
20+
* Under Option C (Docs/v2-phase1-midnight-evaluation.md §1.3) the Soroban pool remains the custodian
21+
* and keeps enforcing everything it enforces today: note ownership, conservation, double-spend, and
22+
* KYC in-circuit. The Midnight proof runs *alongside* that, and this type is how the two are
23+
* compared.
24+
*
25+
* That makes the honest description of a compliance proof today: **advisory**. It is recorded,
26+
* compared and audited, and the value layer does not consult it. Saying otherwise would claim a
27+
* guarantee that does not exist — see ComplianceStatus for how that is kept visible rather than
28+
* assumed.
29+
*
30+
* # What it must not carry
31+
*
32+
* No amount, no recipient, no credential contents. The eligibility commitment is a hash, and the
33+
* settlement request's own fields are all bound inside the Soroban proof. A boundary type is the
34+
* most tempting place to smuggle "just one more field" across, which is exactly why this one is
35+
* small and its contents are justified individually.
36+
*/
37+
38+
// IntentVersion is the wire format of a settlement intent.
39+
//
40+
// Versioned from the start because this crosses a boundary between two systems that will not always
41+
// be deployed together. An unversioned structure forces a guess about what an old peer meant, and
42+
// the guess is wrong exactly when it matters.
43+
const IntentVersion = 1
44+
45+
// ComplianceStatus records what the privacy layer actually said about a transfer.
46+
//
47+
// Deliberately more than a boolean. "No proof was offered" and "a proof was offered and did not
48+
// verify" are opposite facts — the first is a transfer from an older client, the second is either a
49+
// bug or an attack — and collapsing them into `false` would hide the difference at the moment it is
50+
// most worth seeing.
51+
type ComplianceStatus string
52+
53+
const (
54+
// ComplianceNotAttempted means no Midnight proof accompanied this transfer.
55+
//
56+
// The expected state during the migration: clients that predate Midnight support produce
57+
// perfectly valid transfers, and the Soroban circuit is still enforcing KYC for all of them.
58+
ComplianceNotAttempted ComplianceStatus = "not_attempted"
59+
60+
// ComplianceVerified means a Midnight proof was supplied and checked out.
61+
ComplianceVerified ComplianceStatus = "verified"
62+
63+
// ComplianceFailed means a proof was supplied and did NOT verify.
64+
//
65+
// Never ignorable. Under Option C the settlement still proceeds — Soroban is the authority and
66+
// has its own KYC constraints — but a failure here means the two layers disagree about the same
67+
// transfer, and that is a fact somebody needs to look at rather than a number on a dashboard.
68+
ComplianceFailed ComplianceStatus = "failed"
69+
)
70+
71+
// SettlementIntent is a verified statement that a transfer may proceed, plus what the privacy layer
72+
// concluded about it.
73+
//
74+
// Carries the settlement request rather than duplicating its fields: the request's contents are
75+
// bound inside the Soroban proof, and copying them here would create a second version of the truth
76+
// that could drift from the first.
77+
type SettlementIntent struct {
78+
// Version is IntentVersion at the time of creation.
79+
Version int
80+
81+
// Settlement is what the value layer will execute. Every field is proof-bound.
82+
Settlement SettlementRequest
83+
84+
// Compliance is what Midnight concluded. See ComplianceStatus.
85+
Compliance ComplianceStatus
86+
87+
// EligibilityCommitment is the hash returned by the Midnight `proveEligibility` circuit.
88+
//
89+
// A handle, not a disclosure: it binds this settlement to one specific eligibility proof so the
90+
// two can be matched in an audit, and it reveals nothing further — recovering the credential
91+
// behind it would mean inverting a hash.
92+
//
93+
// Empty when Compliance is ComplianceNotAttempted.
94+
EligibilityCommitment string
95+
96+
// ComplianceError is why verification failed, for an operator reading the record.
97+
//
98+
// Only set when Compliance is ComplianceFailed, and never returned to a user: a compliance
99+
// failure is not something a sender can act on, and the text is diagnostic rather than
100+
// explanatory.
101+
ComplianceError string
102+
}
103+
104+
// Typed failures at the boundary.
105+
var (
106+
// ErrIntentVersion means the intent came from an incompatible peer.
107+
ErrIntentVersion = errors.New("unsupported settlement intent version")
108+
109+
// ErrIntentIncomplete means a required field is missing — a construction bug, not a user error.
110+
ErrIntentIncomplete = errors.New("settlement intent is incomplete")
111+
112+
// ErrComplianceMismatch means the status and the evidence contradict each other: a verified
113+
// intent with no commitment, or a failure with no reason. Refused rather than repaired, because
114+
// a boundary type that silently fixes its own contradictions is one nobody can reason about.
115+
ErrComplianceMismatch = errors.New("compliance status does not match the evidence")
116+
)
117+
118+
// NewIntent builds an intent for a transfer that carried no Midnight proof.
119+
//
120+
// The common path during migration, and deliberately the easiest one to construct correctly: a
121+
// caller that knows nothing about compliance still produces a valid, honestly-labelled intent.
122+
func NewIntent(req SettlementRequest) SettlementIntent {
123+
return SettlementIntent{
124+
Version: IntentVersion,
125+
Settlement: req,
126+
Compliance: ComplianceNotAttempted,
127+
}
128+
}
129+
130+
// WithCompliance records what the privacy layer concluded.
131+
//
132+
// Returns a copy rather than mutating: an intent that changes after it has been recorded is one
133+
// where the audit trail and the decision can disagree.
134+
func (i SettlementIntent) WithCompliance(status ComplianceStatus, commitment, reason string) SettlementIntent {
135+
i.Compliance = status
136+
i.EligibilityCommitment = commitment
137+
i.ComplianceError = reason
138+
return i
139+
}
140+
141+
// Validate checks the intent is internally consistent before it is acted on.
142+
//
143+
// Structural only. It does not verify the Midnight proof — that happens where the proof system
144+
// lives, and a boundary type that pretended to verify would be claiming an authority it does not
145+
// have.
146+
func (i SettlementIntent) Validate() error {
147+
if i.Version != IntentVersion {
148+
return fmt.Errorf("%w: got %d, want %d", ErrIntentVersion, i.Version, IntentVersion)
149+
}
150+
if strings.TrimSpace(i.Settlement.Nullifier) == "" {
151+
return fmt.Errorf("%w: no nullifier", ErrIntentIncomplete)
152+
}
153+
if strings.TrimSpace(i.Settlement.ProofHex) == "" {
154+
return fmt.Errorf("%w: no settlement proof", ErrIntentIncomplete)
155+
}
156+
157+
switch i.Compliance {
158+
case ComplianceNotAttempted:
159+
// Nothing was claimed, so nothing may be attached. Evidence alongside "not attempted" means
160+
// a caller lost track of what it actually did.
161+
if i.EligibilityCommitment != "" || i.ComplianceError != "" {
162+
return fmt.Errorf("%w: not_attempted carries evidence", ErrComplianceMismatch)
163+
}
164+
case ComplianceVerified:
165+
// A verified claim with no commitment cannot be audited, which makes it indistinguishable
166+
// from an unverified one.
167+
if strings.TrimSpace(i.EligibilityCommitment) == "" {
168+
return fmt.Errorf("%w: verified without a commitment", ErrComplianceMismatch)
169+
}
170+
if i.ComplianceError != "" {
171+
return fmt.Errorf("%w: verified but carries an error", ErrComplianceMismatch)
172+
}
173+
case ComplianceFailed:
174+
// A failure with no reason tells an operator only that something is wrong, which is the
175+
// least useful moment to be vague.
176+
if strings.TrimSpace(i.ComplianceError) == "" {
177+
return fmt.Errorf("%w: failed without a reason", ErrComplianceMismatch)
178+
}
179+
default:
180+
return fmt.Errorf("%w: unknown status %q", ErrComplianceMismatch, i.Compliance)
181+
}
182+
return nil
183+
}
184+
185+
// IdempotencyKey is what makes settlement exactly-once.
186+
//
187+
// The nullifier, not a generated id. That is the whole trick: the contract refuses a nullifier it
188+
// has already seen, so a duplicate submission cannot move value twice — enforced on-chain, across
189+
// every replica, rather than by bookkeeping in one process's memory.
190+
//
191+
// A generated key would be strictly weaker and would look stronger, which is the worst combination.
192+
func (i SettlementIntent) IdempotencyKey() string {
193+
return i.Settlement.Nullifier
194+
}
195+
196+
// AuthorisedBy reports what actually permits this settlement to proceed.
197+
//
198+
// Exists to make the trust model legible at the point of use rather than in a document somebody has
199+
// to remember. Under Option C the answer is always the Soroban proof: the Midnight proof is
200+
// evidence, not authority.
201+
//
202+
// The day that changes — when the pool contract verifies a Midnight proof reference directly — this
203+
// function changes with it, and every caller reading it gets the new answer at once.
204+
func (i SettlementIntent) AuthorisedBy() string {
205+
if i.Compliance == ComplianceVerified {
206+
return "soroban proof (midnight compliance proof verified, advisory)"
207+
}
208+
return "soroban proof"
209+
}

0 commit comments

Comments
 (0)