|
| 1 | +// Package main provides forensic audit functionality for the Horizon Integrity Proof demo. |
| 2 | +package main |
| 3 | + |
| 4 | +import ( |
| 5 | + "context" |
| 6 | + "errors" |
| 7 | + "fmt" |
| 8 | + "log/slog" |
| 9 | + |
| 10 | + currentaccountv1 "github.com/meridianhub/meridian/api/proto/meridian/current_account/v1" |
| 11 | +) |
| 12 | + |
| 13 | +// AuditConfig holds configuration for the forensic audit phase. |
| 14 | +type AuditConfig struct { |
| 15 | + // AccountID is the test account to audit |
| 16 | + AccountID string |
| 17 | + // InitialBalancePence is the account balance before any payments |
| 18 | + InitialBalancePence int64 |
| 19 | + // PaymentAmountPence is the expected payment amount |
| 20 | + PaymentAmountPence int64 |
| 21 | + // Logger for structured logging |
| 22 | + Logger *slog.Logger |
| 23 | +} |
| 24 | + |
| 25 | +// AuditResult captures the outcome of the forensic audit. |
| 26 | +type AuditResult struct { |
| 27 | + // AccountID is the audited account |
| 28 | + AccountID string |
| 29 | + // InitialBalancePence is the starting balance |
| 30 | + InitialBalancePence int64 |
| 31 | + // FinalBalancePence is the actual final balance |
| 32 | + FinalBalancePence int64 |
| 33 | + // ExpectedBalancePence is what the balance should be (initial - payment) |
| 34 | + ExpectedBalancePence int64 |
| 35 | + // BalanceCorrect indicates if final balance matches expected |
| 36 | + BalanceCorrect bool |
| 37 | + // BalanceStatus describes the balance verification outcome |
| 38 | + BalanceStatus BalanceStatus |
| 39 | + // TransactionsRecorded is the number of payment orders found (for future use) |
| 40 | + TransactionsRecorded int |
| 41 | + // NoDoubleSpend indicates exactly one transaction was recorded |
| 42 | + NoDoubleSpend bool |
| 43 | + // Verdict is the final audit determination |
| 44 | + Verdict AuditVerdict |
| 45 | + // Error captures any error during audit (nil on success) |
| 46 | + Error error |
| 47 | +} |
| 48 | + |
| 49 | +// BalanceStatus represents the outcome of balance verification. |
| 50 | +type BalanceStatus int |
| 51 | + |
| 52 | +const ( |
| 53 | + // BalanceStatusCorrect indicates balance is exactly as expected (single payment). |
| 54 | + BalanceStatusCorrect BalanceStatus = iota |
| 55 | + // BalanceStatusDoubleSpend indicates balance shows two payments were deducted. |
| 56 | + BalanceStatusDoubleSpend |
| 57 | + // BalanceStatusNoPayment indicates no payment was executed. |
| 58 | + BalanceStatusNoPayment |
| 59 | + // BalanceStatusUnexpected indicates balance doesn't match any expected pattern. |
| 60 | + BalanceStatusUnexpected |
| 61 | +) |
| 62 | + |
| 63 | +// String constants for audit status values. |
| 64 | +const ( |
| 65 | + balanceStatusCorrectStr = "CORRECT" |
| 66 | + balanceStatusDoubleSpendStr = "DOUBLE_SPEND" |
| 67 | + balanceStatusNoPaymentStr = "NO_PAYMENT" |
| 68 | + balanceStatusUnexpectedStr = "UNEXPECTED" |
| 69 | + statusUnknownStr = "UNKNOWN" |
| 70 | +) |
| 71 | + |
| 72 | +func (s BalanceStatus) String() string { |
| 73 | + switch s { |
| 74 | + case BalanceStatusCorrect: |
| 75 | + return balanceStatusCorrectStr |
| 76 | + case BalanceStatusDoubleSpend: |
| 77 | + return balanceStatusDoubleSpendStr |
| 78 | + case BalanceStatusNoPayment: |
| 79 | + return balanceStatusNoPaymentStr |
| 80 | + case BalanceStatusUnexpected: |
| 81 | + return balanceStatusUnexpectedStr |
| 82 | + default: |
| 83 | + return statusUnknownStr |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +// AuditVerdict represents the final audit determination. |
| 88 | +type AuditVerdict int |
| 89 | + |
| 90 | +const ( |
| 91 | + // AuditVerdictPass indicates the system behaved correctly. |
| 92 | + AuditVerdictPass AuditVerdict = iota |
| 93 | + // AuditVerdictFail indicates an integrity issue was detected. |
| 94 | + AuditVerdictFail |
| 95 | + // AuditVerdictError indicates the audit could not complete. |
| 96 | + AuditVerdictError |
| 97 | +) |
| 98 | + |
| 99 | +// Audit verdict string constants. |
| 100 | +const ( |
| 101 | + auditVerdictPassStr = "PASS" |
| 102 | + auditVerdictFailStr = "FAIL" |
| 103 | + auditVerdictErrorStr = "ERROR" |
| 104 | +) |
| 105 | + |
| 106 | +func (v AuditVerdict) String() string { |
| 107 | + switch v { |
| 108 | + case AuditVerdictPass: |
| 109 | + return auditVerdictPassStr |
| 110 | + case AuditVerdictFail: |
| 111 | + return auditVerdictFailStr |
| 112 | + case AuditVerdictError: |
| 113 | + return auditVerdictErrorStr |
| 114 | + default: |
| 115 | + return statusUnknownStr |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +// Audit errors. |
| 120 | +var ( |
| 121 | + ErrAuditConfigInvalid = errors.New("invalid audit configuration") |
| 122 | + ErrAuditBalanceRetrieval = errors.New("failed to retrieve account balance") |
| 123 | + ErrAuditDoubleSpend = errors.New("double-spend detected: payment executed twice") |
| 124 | + ErrAuditNoPayment = errors.New("no payment executed: saga failed") |
| 125 | + ErrAuditUnexpectedState = errors.New("unexpected account state") |
| 126 | +) |
| 127 | + |
| 128 | +// RunAudit executes the forensic audit phase of the demo. |
| 129 | +// This verifies that exactly one payment was executed (no double-spend). |
| 130 | +// |
| 131 | +// Assertion branches: |
| 132 | +// 1. balance == (initial - payment): PASS - correct single deduction |
| 133 | +// 2. balance == (initial - 2*payment): FAIL - double-spend detected |
| 134 | +// 3. balance == initial: FAIL - no payment executed |
| 135 | +// 4. any other balance: FAIL - unexpected state |
| 136 | +func RunAudit(ctx context.Context, clients *Clients, cfg *AuditConfig) (*AuditResult, error) { |
| 137 | + if err := validateAuditConfig(cfg); err != nil { |
| 138 | + return nil, err |
| 139 | + } |
| 140 | + |
| 141 | + logger := cfg.Logger |
| 142 | + if logger == nil { |
| 143 | + logger = slog.Default() |
| 144 | + } |
| 145 | + |
| 146 | + result := &AuditResult{ |
| 147 | + AccountID: cfg.AccountID, |
| 148 | + InitialBalancePence: cfg.InitialBalancePence, |
| 149 | + ExpectedBalancePence: cfg.InitialBalancePence - cfg.PaymentAmountPence, |
| 150 | + } |
| 151 | + |
| 152 | + logger.Info("audit: starting balance verification", |
| 153 | + "account_id", cfg.AccountID, |
| 154 | + "initial_balance_pence", cfg.InitialBalancePence, |
| 155 | + "payment_amount_pence", cfg.PaymentAmountPence, |
| 156 | + "expected_balance_pence", result.ExpectedBalancePence, |
| 157 | + ) |
| 158 | + |
| 159 | + // Retrieve current account balance |
| 160 | + retrieveResp, err := clients.CurrentAccount.RetrieveCurrentAccount(ctx, ¤taccountv1.RetrieveCurrentAccountRequest{ |
| 161 | + AccountId: cfg.AccountID, |
| 162 | + }) |
| 163 | + if err != nil { |
| 164 | + result.Error = fmt.Errorf("%w: %w", ErrAuditBalanceRetrieval, err) |
| 165 | + result.Verdict = AuditVerdictError |
| 166 | + logger.Error("audit: failed to retrieve account balance", |
| 167 | + "account_id", cfg.AccountID, |
| 168 | + "error", err, |
| 169 | + ) |
| 170 | + return result, result.Error |
| 171 | + } |
| 172 | + |
| 173 | + // Extract balance using moneyToPence from preflight.go |
| 174 | + // Defensive nil checks for chained gRPC getters |
| 175 | + facility := retrieveResp.GetFacility() |
| 176 | + if facility == nil { |
| 177 | + result.Error = fmt.Errorf("%w: response has nil facility", ErrAuditBalanceRetrieval) |
| 178 | + result.Verdict = AuditVerdictError |
| 179 | + logger.Error("audit: nil facility in response", "account_id", cfg.AccountID) |
| 180 | + return result, result.Error |
| 181 | + } |
| 182 | + accountBalance := facility.GetCurrentBalance() |
| 183 | + if accountBalance == nil { |
| 184 | + result.Error = fmt.Errorf("%w: facility has nil current_balance", ErrAuditBalanceRetrieval) |
| 185 | + result.Verdict = AuditVerdictError |
| 186 | + logger.Error("audit: nil current_balance in facility", "account_id", cfg.AccountID) |
| 187 | + return result, result.Error |
| 188 | + } |
| 189 | + result.FinalBalancePence = moneyToPence(accountBalance.GetCurrentBalance().GetAmount()) |
| 190 | + |
| 191 | + logger.Info("audit: retrieved final balance", |
| 192 | + "account_id", cfg.AccountID, |
| 193 | + "final_balance_pence", result.FinalBalancePence, |
| 194 | + "final_balance_gbp", fmt.Sprintf("%.2f", float64(result.FinalBalancePence)/100), |
| 195 | + ) |
| 196 | + |
| 197 | + // Calculate expected values for assertion branches |
| 198 | + expectedSinglePayment := cfg.InitialBalancePence - cfg.PaymentAmountPence |
| 199 | + expectedDoublePayment := cfg.InitialBalancePence - (2 * cfg.PaymentAmountPence) |
| 200 | + expectedNoPayment := cfg.InitialBalancePence |
| 201 | + |
| 202 | + // Perform assertion branches |
| 203 | + switch result.FinalBalancePence { |
| 204 | + case expectedSinglePayment: |
| 205 | + // PASS: Correct single deduction |
| 206 | + result.BalanceCorrect = true |
| 207 | + result.BalanceStatus = BalanceStatusCorrect |
| 208 | + result.TransactionsRecorded = 1 |
| 209 | + result.NoDoubleSpend = true |
| 210 | + result.Verdict = AuditVerdictPass |
| 211 | + |
| 212 | + logger.Info("audit: PASS - correct single deduction", |
| 213 | + "account_id", cfg.AccountID, |
| 214 | + "final_balance_pence", result.FinalBalancePence, |
| 215 | + "expected_balance_pence", expectedSinglePayment, |
| 216 | + ) |
| 217 | + |
| 218 | + case expectedDoublePayment: |
| 219 | + // FAIL: Double-spend detected |
| 220 | + result.BalanceCorrect = false |
| 221 | + result.BalanceStatus = BalanceStatusDoubleSpend |
| 222 | + result.TransactionsRecorded = 2 |
| 223 | + result.NoDoubleSpend = false |
| 224 | + result.Verdict = AuditVerdictFail |
| 225 | + result.Error = ErrAuditDoubleSpend |
| 226 | + |
| 227 | + logger.Error("audit: FAIL - double-spend detected", |
| 228 | + "account_id", cfg.AccountID, |
| 229 | + "final_balance_pence", result.FinalBalancePence, |
| 230 | + "expected_balance_pence", expectedSinglePayment, |
| 231 | + "double_spend_balance", expectedDoublePayment, |
| 232 | + ) |
| 233 | + |
| 234 | + case expectedNoPayment: |
| 235 | + // FAIL: No payment executed |
| 236 | + result.BalanceCorrect = false |
| 237 | + result.BalanceStatus = BalanceStatusNoPayment |
| 238 | + result.TransactionsRecorded = 0 |
| 239 | + result.NoDoubleSpend = true // Technically true, but saga failed |
| 240 | + result.Verdict = AuditVerdictFail |
| 241 | + result.Error = ErrAuditNoPayment |
| 242 | + |
| 243 | + logger.Error("audit: FAIL - no payment executed (saga failed)", |
| 244 | + "account_id", cfg.AccountID, |
| 245 | + "final_balance_pence", result.FinalBalancePence, |
| 246 | + "initial_balance_pence", cfg.InitialBalancePence, |
| 247 | + ) |
| 248 | + |
| 249 | + default: |
| 250 | + // FAIL: Unexpected state |
| 251 | + result.BalanceCorrect = false |
| 252 | + result.BalanceStatus = BalanceStatusUnexpected |
| 253 | + result.TransactionsRecorded = -1 // Unknown |
| 254 | + result.NoDoubleSpend = false |
| 255 | + result.Verdict = AuditVerdictFail |
| 256 | + result.Error = fmt.Errorf("%w: balance %d pence does not match any expected value", |
| 257 | + ErrAuditUnexpectedState, result.FinalBalancePence) |
| 258 | + |
| 259 | + logger.Error("audit: FAIL - unexpected account state", |
| 260 | + "account_id", cfg.AccountID, |
| 261 | + "final_balance_pence", result.FinalBalancePence, |
| 262 | + "expected_single_payment", expectedSinglePayment, |
| 263 | + "expected_double_payment", expectedDoublePayment, |
| 264 | + "expected_no_payment", expectedNoPayment, |
| 265 | + ) |
| 266 | + } |
| 267 | + |
| 268 | + return result, result.Error |
| 269 | +} |
| 270 | + |
| 271 | +// validateAuditConfig validates the audit configuration. |
| 272 | +func validateAuditConfig(cfg *AuditConfig) error { |
| 273 | + if cfg == nil { |
| 274 | + return fmt.Errorf("%w: config is nil", ErrAuditConfigInvalid) |
| 275 | + } |
| 276 | + |
| 277 | + if cfg.AccountID == "" { |
| 278 | + return fmt.Errorf("%w: AccountID is required", ErrAuditConfigInvalid) |
| 279 | + } |
| 280 | + |
| 281 | + if cfg.InitialBalancePence <= 0 { |
| 282 | + return fmt.Errorf("%w: InitialBalancePence must be positive", ErrAuditConfigInvalid) |
| 283 | + } |
| 284 | + |
| 285 | + if cfg.PaymentAmountPence <= 0 { |
| 286 | + return fmt.Errorf("%w: PaymentAmountPence must be positive", ErrAuditConfigInvalid) |
| 287 | + } |
| 288 | + |
| 289 | + if cfg.PaymentAmountPence > cfg.InitialBalancePence { |
| 290 | + return fmt.Errorf("%w: PaymentAmountPence (%d) cannot exceed InitialBalancePence (%d)", |
| 291 | + ErrAuditConfigInvalid, cfg.PaymentAmountPence, cfg.InitialBalancePence) |
| 292 | + } |
| 293 | + |
| 294 | + return nil |
| 295 | +} |
| 296 | + |
| 297 | +// DefaultAuditConfig returns an AuditConfig with default values. |
| 298 | +// Caller must set AccountID. |
| 299 | +func DefaultAuditConfig() *AuditConfig { |
| 300 | + return &AuditConfig{ |
| 301 | + InitialBalancePence: 100000, // GBP 1,000.00 |
| 302 | + PaymentAmountPence: 10000, // GBP 100.00 |
| 303 | + Logger: slog.Default(), |
| 304 | + } |
| 305 | +} |
| 306 | + |
| 307 | +// NewAuditConfigFromPreFlight creates an AuditConfig from PreFlightResult. |
| 308 | +// This ensures the audit uses the actual values from the pre-flight phase. |
| 309 | +// Returns nil if preflight is nil. |
| 310 | +func NewAuditConfigFromPreFlight(preflight *PreFlightResult, paymentAmountPence int64, logger *slog.Logger) *AuditConfig { |
| 311 | + if preflight == nil { |
| 312 | + return nil |
| 313 | + } |
| 314 | + if logger == nil { |
| 315 | + logger = slog.Default() |
| 316 | + } |
| 317 | + return &AuditConfig{ |
| 318 | + AccountID: preflight.AccountID, |
| 319 | + InitialBalancePence: preflight.InitialBalancePence, |
| 320 | + PaymentAmountPence: paymentAmountPence, |
| 321 | + Logger: logger, |
| 322 | + } |
| 323 | +} |
| 324 | + |
| 325 | +// ToVerificationReport converts an AuditResult to a VerificationReport for the JSON report. |
| 326 | +func (r *AuditResult) ToVerificationReport(requestsSent int) VerificationReport { |
| 327 | + return VerificationReport{ |
| 328 | + RequestsSent: requestsSent, |
| 329 | + TransactionsRecorded: r.TransactionsRecorded, |
| 330 | + BalanceCorrect: r.BalanceCorrect, |
| 331 | + NoDoubleSpend: r.NoDoubleSpend, |
| 332 | + } |
| 333 | +} |
0 commit comments