Skip to content

Commit 4d8958b

Browse files
authored
feat: Add PaymentOrder uniqueness verification for Horizon integrity proof (#195)
* feat: Add PaymentOrder uniqueness verification for Horizon integrity proof Implement forensic audit to verify exactly one PaymentOrder exists for the demo's IdempotencyKey, detecting idempotency breaches where duplicate orders were created. Key changes: - Add RunOrderAudit() to query and filter PaymentOrders by idempotency key - Implement three assertion branches: unique found (PASS), duplicates found (FAIL - idempotency breach), none found (FAIL - saga never persisted) - Add PaymentOrderSummary type for audit reporting - Comprehensive test coverage for all scenarios * feat: Add optional lien verification for Horizon integrity proof Add non-blocking lien state verification as a stretch goal (Task 8). After payment completion, verifies the associated lien is in EXECUTED state rather than still ACTIVE (orphaned). - audit_lien.go: LienAuditConfig, LienAuditResult, RunLienAudit - Uses RetrieveLien RPC (no ListLiens API available) - Non-blocking: orphaned liens logged as warnings, don't fail demo - Integrates with report via SetLienVerification - Full test coverage in audit_lien_test.go * fix: Define local constant for unknown order status string --------- Co-authored-by: Ben Coombs <[email protected]>
1 parent 50b9736 commit 4d8958b

File tree

5 files changed

+1468
-0
lines changed

5 files changed

+1468
-0
lines changed

cmd/horizon-demo/audit_lien.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Package main provides lien verification 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+
// LienAuditConfig holds configuration for the lien verification audit.
14+
type LienAuditConfig struct {
15+
// LienID is the lien to verify (obtained from PaymentOrder)
16+
LienID string
17+
// AccountID is the expected account for the lien
18+
AccountID string
19+
// Logger for structured logging
20+
Logger *slog.Logger
21+
}
22+
23+
// LienAuditResult captures the outcome of the lien verification.
24+
type LienAuditResult struct {
25+
// LienID is the audited lien
26+
LienID string
27+
// AccountID is the lien's account
28+
AccountID string
29+
// LienStatus is the current status of the lien
30+
LienStatus string
31+
// ExpectedStatus is what the status should be (EXECUTED for completed payments)
32+
ExpectedStatus string
33+
// IsOrphaned indicates if the lien is still ACTIVE (orphaned reservation)
34+
IsOrphaned bool
35+
// Verdict is the audit determination for this check
36+
Verdict AuditVerdict
37+
// Error captures any error during audit (nil on success)
38+
Error error
39+
}
40+
41+
// Lien audit errors.
42+
var (
43+
ErrLienAuditConfigInvalid = errors.New("invalid lien audit configuration")
44+
ErrLienAuditRetrieveFailed = errors.New("failed to retrieve lien")
45+
ErrLienAuditOrphaned = errors.New("orphaned lien detected: lien still ACTIVE after payment completion")
46+
ErrLienAuditUnexpectedStatus = errors.New("lien in unexpected status")
47+
)
48+
49+
// RunLienAudit executes the lien verification.
50+
// This retrieves the lien by ID and verifies it is in EXECUTED status (not ACTIVE).
51+
//
52+
// Assertion branches:
53+
// 1. Lien status == EXECUTED: PASS - lien was properly converted to debit
54+
// 2. Lien status == ACTIVE: WARN - orphaned lien (reservation not released)
55+
// 3. Lien status == TERMINATED: INFO - lien was released without execution (saga compensation)
56+
// 4. Lien not found or error: ERROR - cannot verify
57+
//
58+
// This is a non-blocking check - orphaned liens are logged as warnings but don't fail the demo.
59+
func RunLienAudit(ctx context.Context, clients *Clients, cfg *LienAuditConfig) (*LienAuditResult, error) {
60+
if err := validateLienAuditConfig(cfg); err != nil {
61+
return nil, err
62+
}
63+
64+
logger := cfg.Logger
65+
if logger == nil {
66+
logger = slog.Default()
67+
}
68+
69+
result := &LienAuditResult{
70+
LienID: cfg.LienID,
71+
AccountID: cfg.AccountID,
72+
ExpectedStatus: currentaccountv1.LienStatus_LIEN_STATUS_EXECUTED.String(),
73+
}
74+
75+
logger.Info("lien audit: starting verification",
76+
"lien_id", cfg.LienID,
77+
"account_id", cfg.AccountID,
78+
)
79+
80+
// Retrieve lien details
81+
retrieveResp, err := clients.CurrentAccount.RetrieveLien(ctx, &currentaccountv1.RetrieveLienRequest{
82+
LienId: cfg.LienID,
83+
})
84+
if err != nil {
85+
result.Error = fmt.Errorf("%w: %w", ErrLienAuditRetrieveFailed, err)
86+
result.Verdict = AuditVerdictError
87+
logger.Error("lien audit: failed to retrieve lien",
88+
"lien_id", cfg.LienID,
89+
"error", err,
90+
)
91+
return result, result.Error
92+
}
93+
94+
lien := retrieveResp.GetLien()
95+
if lien == nil {
96+
result.Error = fmt.Errorf("%w: response has nil lien", ErrLienAuditRetrieveFailed)
97+
result.Verdict = AuditVerdictError
98+
logger.Error("lien audit: nil lien in response", "lien_id", cfg.LienID)
99+
return result, result.Error
100+
}
101+
102+
result.LienStatus = lien.GetStatus().String()
103+
104+
// Verify lien belongs to expected account
105+
if lien.GetAccountId() != cfg.AccountID {
106+
logger.Warn("lien audit: lien account mismatch",
107+
"lien_id", cfg.LienID,
108+
"expected_account", cfg.AccountID,
109+
"actual_account", lien.GetAccountId(),
110+
)
111+
}
112+
113+
logger.Info("lien audit: retrieved lien",
114+
"lien_id", cfg.LienID,
115+
"status", result.LienStatus,
116+
"account_id", lien.GetAccountId(),
117+
)
118+
119+
// Perform assertion branches
120+
switch lien.GetStatus() {
121+
case currentaccountv1.LienStatus_LIEN_STATUS_EXECUTED:
122+
// PASS: Lien was properly converted to debit
123+
result.IsOrphaned = false
124+
result.Verdict = AuditVerdictPass
125+
126+
logger.Info("lien audit: PASS - lien executed correctly",
127+
"lien_id", cfg.LienID,
128+
"status", result.LienStatus,
129+
)
130+
131+
case currentaccountv1.LienStatus_LIEN_STATUS_ACTIVE:
132+
// WARN: Orphaned lien - reservation not released
133+
result.IsOrphaned = true
134+
result.Verdict = AuditVerdictPass // Non-blocking, just informational
135+
result.Error = ErrLienAuditOrphaned
136+
137+
logger.Warn("lien audit: WARN - orphaned lien detected",
138+
"lien_id", cfg.LienID,
139+
"status", result.LienStatus,
140+
"message", "lien still ACTIVE after payment, may indicate saga compensation bug",
141+
)
142+
143+
case currentaccountv1.LienStatus_LIEN_STATUS_TERMINATED:
144+
// INFO: Lien was released without execution (saga compensation path)
145+
result.IsOrphaned = false
146+
result.Verdict = AuditVerdictPass // Valid state for compensated transactions
147+
148+
logger.Info("lien audit: INFO - lien terminated (saga compensation)",
149+
"lien_id", cfg.LienID,
150+
"status", result.LienStatus,
151+
)
152+
153+
case currentaccountv1.LienStatus_LIEN_STATUS_UNSPECIFIED:
154+
// WARN: Unspecified status (should not occur in practice)
155+
result.IsOrphaned = false
156+
result.Verdict = AuditVerdictPass // Non-blocking
157+
result.Error = fmt.Errorf("%w: got %s", ErrLienAuditUnexpectedStatus, result.LienStatus)
158+
159+
logger.Warn("lien audit: WARN - unexpected lien status",
160+
"lien_id", cfg.LienID,
161+
"status", result.LienStatus,
162+
)
163+
}
164+
165+
return result, nil // Return nil error for non-blocking checks
166+
}
167+
168+
// validateLienAuditConfig validates the lien audit configuration.
169+
func validateLienAuditConfig(cfg *LienAuditConfig) error {
170+
if cfg == nil {
171+
return fmt.Errorf("%w: config is nil", ErrLienAuditConfigInvalid)
172+
}
173+
174+
if cfg.LienID == "" {
175+
return fmt.Errorf("%w: LienID is required", ErrLienAuditConfigInvalid)
176+
}
177+
178+
if cfg.AccountID == "" {
179+
return fmt.Errorf("%w: AccountID is required", ErrLienAuditConfigInvalid)
180+
}
181+
182+
return nil
183+
}
184+
185+
// NewLienAuditConfig creates a LienAuditConfig with the given parameters.
186+
func NewLienAuditConfig(lienID, accountID string, logger *slog.Logger) *LienAuditConfig {
187+
if logger == nil {
188+
logger = slog.Default()
189+
}
190+
return &LienAuditConfig{
191+
LienID: lienID,
192+
AccountID: accountID,
193+
Logger: logger,
194+
}
195+
}
196+
197+
// NewLienAuditConfigFromOrderAudit creates a LienAuditConfig from an OrderAuditResult.
198+
// Returns nil if no matching orders were found or if the order has no lien ID.
199+
func NewLienAuditConfigFromOrderAudit(orderResult *OrderAuditResult, logger *slog.Logger) *LienAuditConfig {
200+
if orderResult == nil || len(orderResult.MatchingOrders) == 0 {
201+
return nil
202+
}
203+
204+
// Use the first matching order's lien ID
205+
lienID := orderResult.MatchingOrders[0].LienID
206+
if lienID == "" {
207+
return nil
208+
}
209+
210+
if logger == nil {
211+
logger = slog.Default()
212+
}
213+
214+
return &LienAuditConfig{
215+
LienID: lienID,
216+
AccountID: orderResult.AccountID,
217+
Logger: logger,
218+
}
219+
}

0 commit comments

Comments
 (0)