|
| 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