Skip to content

Commit 50b9736

Browse files
authored
feat: Add forensic audit balance verification for Horizon integrity proof (#194)
1 parent ef0cd0b commit 50b9736

File tree

2 files changed

+959
-0
lines changed

2 files changed

+959
-0
lines changed

cmd/horizon-demo/audit.go

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
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, &currentaccountv1.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

Comments
 (0)