Skip to content

Commit c848a07

Browse files
authored
fix(exchange): enforce daily-cap integrity on manual path, actual-paid ledger, and headroom (adversarial-review follow-ups) (#1457)
* fix(exchange): enforce daily-cap integrity on manual path, actual-paid ledger, and headroom (adversarial-review follow-ups) H1: checkDailyCap fails closed on unparseable paymentDue (was treating as $0). H2: executeApprovedExchange and processAutoExchange bound Execute MaxPaymentDueUSD by min(perExchangeCap, dailyCap-dailySpent) so re-quotes cannot breach headroom. H3: Add CompleteRIExchangeWithPayment to persist the accepted (not pre-execution) amount; both auto and manual paths use the fresh quote from Execute. H4: Retry ledger write 3x after money moves; halt auto run / return 500 on persistent failure to prevent cap bypass via missing ledger rows. M5: GetRIExchangeDailySpend includes processing rows (COALESCE on completed_at/ updated_at) to close concurrent-approval TOCTOU window. Each defect has a regression test that fails pre-fix and passes post-fix. * fix(exchange): handle ParseDecimalRat error, extract checkCapsAndComputeHeadroom Fixes golangci-lint errcheck failure on handler_ri_exchange.go:1351 where `exchange.ParseDecimalRat` error was discarded with `_`. The fix extracts a `checkCapsAndComputeHeadroom` helper that bundles the three cap-validation checks (MaxDailyUSD==0, checkDailyCap, MaxPerExchangeUSD==0) and the headroom calculation, properly returning a reason string on any parse failure (fail closed). This also brings `executeApprovedExchange` back under gocyclo -over 10 (complexity 8) after the helper reduces it from the 12 that errcheck enforcement would have caused. Rebased onto main (after PR #1456 merged; no conflicts in store_postgres.go).
1 parent 5c267c5 commit c848a07

13 files changed

Lines changed: 795 additions & 57 deletions

‎internal/analytics/collector_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ func (m *mockConfigStore) TransitionRIExchangeStatus(ctx context.Context, id str
313313
func (m *mockConfigStore) CompleteRIExchange(ctx context.Context, id string, exchangeID string) error {
314314
return nil
315315
}
316+
func (m *mockConfigStore) CompleteRIExchangeWithPayment(_ context.Context, _, _, _ string) error {
317+
return nil
318+
}
316319
func (m *mockConfigStore) FailRIExchange(ctx context.Context, id string, errorMsg string) error {
317320
return nil
318321
}

‎internal/api/exchange_helpers_test.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ func TestCheckDailyCap_InvalidDailySpend(t *testing.T) {
3636
}
3737

3838
func TestCheckDailyCap_InvalidPaymentDue(t *testing.T) {
39-
// Unparseable payment due → treated as $0, within cap
39+
// H1 fix: an unparseable payment-due string must fail closed (return a
40+
// blocking reason) instead of being treated as $0. Proceeding as $0 would
41+
// allow an exchange of unknown cost through the daily cap check.
4042
reason := checkDailyCap("100.00", "not-a-number", 500.0)
41-
// $100 + $0 = $100 < $500 → allowed
42-
assert.Equal(t, "", reason)
43+
assert.NotEmpty(t, reason, "unparseable payment due must block the exchange (fail closed)")
44+
assert.Contains(t, reason, "could not parse payment due")
4345
}
4446

4547
func TestCheckDailyCap_ExactlyAtCap(t *testing.T) {

‎internal/api/handler.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/LeanerCloud/CUDly/internal/email"
1717
"github.com/LeanerCloud/CUDly/internal/oidc"
1818
"github.com/LeanerCloud/CUDly/internal/runtime"
19+
"github.com/LeanerCloud/CUDly/pkg/exchange"
1920
"github.com/LeanerCloud/CUDly/pkg/logging"
2021
"github.com/aws/aws-lambda-go/events"
2122
"github.com/aws/aws-sdk-go-v2/aws"
@@ -120,6 +121,12 @@ type Handler struct {
120121
// validation no-ops, deferring to the frontend's hardcoded rules.
121122
commitmentOpts CommitmentOptsInterface
122123

124+
// executeExchangeFn is the RI exchange execution function injected by tests.
125+
// When nil (the production default), executeApprovedExchange calls
126+
// exchange.ExecuteExchange directly. Tests inject a stub so the handler
127+
// can be exercised end-to-end without live AWS credentials or a real RI.
128+
executeExchangeFn func(ctx context.Context, req exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error)
129+
123130
// encryptionKeySource is the env var name that resolved the credential
124131
// encryption key. Empty when no credStore is configured. Used by the
125132
// /health endpoint only — never logged outside that one place.

‎internal/api/handler_ri_exchange.go‎

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,78 @@ func (h *Handler) failExchange(ctx context.Context, id, reason string) (any, err
12681268
return map[string]any{"status": "failed", "reason": reason}, nil
12691269
}
12701270

1271+
// retryCompleteWithPayment calls CompleteRIExchangeWithPayment up to
1272+
// maxLedgerAttempts times, logging retries. Returns a non-nil error if all
1273+
// attempts fail. Extracted from executeApprovedExchange to reduce its
1274+
// cyclomatic complexity (H4 fix).
1275+
func (h *Handler) retryCompleteWithPayment(ctx context.Context, id, exchangeID, acceptedPaymentDue string) error {
1276+
const maxAttempts = 3
1277+
var err error
1278+
for attempt := 1; attempt <= maxAttempts; attempt++ {
1279+
err = h.config.CompleteRIExchangeWithPayment(ctx, id, exchangeID, acceptedPaymentDue)
1280+
if err == nil {
1281+
return nil
1282+
}
1283+
if attempt < maxAttempts {
1284+
logging.Warnf("ledger write retry %d/%d for exchange %s after money moved: %v",
1285+
attempt, maxAttempts, id, err)
1286+
}
1287+
}
1288+
return err
1289+
}
1290+
1291+
// handlerChooseEffectiveCap returns the smaller of perExchangeCap and daily
1292+
// headroom (dailyCap - dailySpent), bounding Execute's MaxPaymentDueUSD so a
1293+
// fresh re-quote cannot exceed the remaining daily budget (H2 fix).
1294+
func handlerChooseEffectiveCap(dailyCap, dailySpent, perExchangeCap *big.Rat) *big.Rat {
1295+
remaining := new(big.Rat).Sub(dailyCap, dailySpent)
1296+
if remaining.Cmp(perExchangeCap) < 0 {
1297+
return remaining
1298+
}
1299+
return perExchangeCap
1300+
}
1301+
1302+
// handlerAcceptedAmount extracts the confirmed payment amount from a fresh
1303+
// Execute quote, falling back to fallback when freshQ is nil or empty (H3 fix).
1304+
func handlerAcceptedAmount(freshQ *exchange.ExchangeQuoteSummary, fallback string) string {
1305+
if freshQ == nil {
1306+
return fallback
1307+
}
1308+
if freshQ.PaymentDueUSDStr != "" {
1309+
return freshQ.PaymentDueUSDStr
1310+
}
1311+
// Zero-cost exchange: PaymentDueRaw was empty (AWS returned nil) so
1312+
// PaymentDueUSDStr is also empty. Use "0" to avoid a NULL payment_due in
1313+
// the DB that would silently distort GetRIExchangeDailySpend's SUM.
1314+
return "0"
1315+
}
1316+
1317+
// checkCapsAndComputeHeadroom validates the spending-cap configuration, runs the
1318+
// daily-cap check, and computes the effective MaxPaymentDueUSD that Execute must
1319+
// not exceed (H2: remaining daily headroom vs per-exchange cap, whichever is
1320+
// smaller). Returns a non-empty reason string on any failure so the caller can
1321+
// forward it to failExchange.
1322+
func checkCapsAndComputeHeadroom(dailySpendStr, paymentDue string, cfg *config.GlobalConfig) (effectiveCap *big.Rat, reason string) {
1323+
if cfg.RIExchangeMaxDailyUSD == 0 {
1324+
return nil, "daily spending cap is not configured (RIExchangeMaxDailyUSD is 0)"
1325+
}
1326+
if reason := checkDailyCap(dailySpendStr, paymentDue, cfg.RIExchangeMaxDailyUSD); reason != "" {
1327+
return nil, reason
1328+
}
1329+
if cfg.RIExchangeMaxPerExchangeUSD == 0 {
1330+
return nil, "per-exchange spending cap is not configured (RIExchangeMaxPerExchangeUSD is 0)"
1331+
}
1332+
// checkDailyCap already verified dailySpendStr is parseable; a second failure
1333+
// is an internal error - fail closed to avoid executing with wrong headroom.
1334+
dailySpent, err := exchange.ParseDecimalRat(dailySpendStr)
1335+
if err != nil || dailySpent == nil {
1336+
return nil, fmt.Sprintf("daily spend re-parse failed (internal error): %v", err)
1337+
}
1338+
dailyCap := new(big.Rat).SetFloat64(cfg.RIExchangeMaxDailyUSD)
1339+
perExchangeCap := new(big.Rat).SetFloat64(cfg.RIExchangeMaxPerExchangeUSD)
1340+
return handlerChooseEffectiveCap(dailyCap, dailySpent, perExchangeCap), ""
1341+
}
1342+
12711343
// executeApprovedExchange checks caps and executes the exchange after approval.
12721344
func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record *config.RIExchangeRecord) (any, error) {
12731345
dailySpendStr, err := h.config.GetRIExchangeDailySpend(ctx, time.Now())
@@ -1280,13 +1352,6 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record
12801352
return h.failExchange(ctx, id, "config load failed")
12811353
}
12821354

1283-
if globalCfg.RIExchangeMaxDailyUSD == 0 {
1284-
return h.failExchange(ctx, id, "daily spending cap is not configured (RIExchangeMaxDailyUSD is 0)")
1285-
}
1286-
if reason := checkDailyCap(dailySpendStr, record.PaymentDue, globalCfg.RIExchangeMaxDailyUSD); reason != "" {
1287-
return h.failExchange(ctx, id, reason)
1288-
}
1289-
12901355
region := record.Region
12911356
if region == "" {
12921357
// Region is a required field captured at record-creation time.
@@ -1295,31 +1360,46 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record
12951360
return h.failExchange(ctx, id, "exchange record has no region; cannot execute safely")
12961361
}
12971362

1298-
if globalCfg.RIExchangeMaxPerExchangeUSD == 0 {
1299-
return h.failExchange(ctx, id, "per-exchange spending cap is not configured (RIExchangeMaxPerExchangeUSD is 0)")
1363+
effectiveCap, reason := checkCapsAndComputeHeadroom(dailySpendStr, record.PaymentDue, globalCfg)
1364+
if reason != "" {
1365+
return h.failExchange(ctx, id, reason)
13001366
}
13011367

1302-
perExchangeCap := new(big.Rat).SetFloat64(globalCfg.RIExchangeMaxPerExchangeUSD)
1303-
exchangeID, _, execErr := exchange.ExecuteExchange(ctx, exchange.ExchangeExecuteRequest{
1368+
execFn := exchange.ExecuteExchange
1369+
if h.executeExchangeFn != nil {
1370+
execFn = h.executeExchangeFn
1371+
}
1372+
exchangeID, freshQ, execErr := execFn(ctx, exchange.ExchangeExecuteRequest{
13041373
Region: region,
13051374
ReservedIDs: record.SourceRIIDs,
13061375
TargetOfferingID: record.TargetOfferingID,
13071376
TargetCount: int32(record.TargetCount), // #nosec G115 -- RI quantity stored from validated API request; AWS limits RI counts well below math.MaxInt32
1308-
MaxPaymentDueUSD: perExchangeCap,
1377+
MaxPaymentDueUSD: effectiveCap,
13091378
})
13101379
if execErr != nil {
13111380
return h.failExchange(ctx, id, execErr.Error())
13121381
}
13131382

1314-
if err := h.config.CompleteRIExchange(ctx, id, exchangeID); err != nil {
1315-
logging.Errorf("failed to mark exchange %s as completed: %v", id, err)
1383+
// H3: persist the amount AWS actually accepted, not the stale pre-execution
1384+
// quote stored in record.PaymentDue.
1385+
acceptedPaymentDue := handlerAcceptedAmount(freshQ, record.PaymentDue)
1386+
1387+
// H4: retry the ledger write via retryCompleteWithPayment; persistent
1388+
// failure is returned as an error (HTTP 500) so the caller knows money
1389+
// moved but the record was not updated.
1390+
if completeErr := h.retryCompleteWithPayment(ctx, id, exchangeID, acceptedPaymentDue); completeErr != nil {
1391+
logging.Errorf("all ledger write attempts failed for exchange %s after money moved: %v",
1392+
id, completeErr)
1393+
return nil, fmt.Errorf("exchange executed (id=%s) but ledger update failed: %w",
1394+
exchangeID, completeErr)
13161395
}
13171396

13181397
return map[string]any{"status": "completed", "exchange_id": exchangeID}, nil
13191398
}
13201399

13211400
// checkDailyCap verifies the exchange payment won't exceed the daily spending cap.
1322-
// Returns an empty string if within cap, or a reason string if exceeded.
1401+
// Returns an empty string if within cap, or a reason string if exceeded or if
1402+
// either input cannot be parsed (fail closed on parse errors).
13231403
func checkDailyCap(dailySpendStr, paymentDueStr string, maxDailyUSD float64) string {
13241404
dailyCap := new(big.Rat).SetFloat64(maxDailyUSD)
13251405
dailySpent, err := exchange.ParseDecimalRat(dailySpendStr)
@@ -1331,8 +1411,11 @@ func checkDailyCap(dailySpendStr, paymentDueStr string, maxDailyUSD float64) str
13311411
}
13321412
paymentDue, err := exchange.ParseDecimalRat(paymentDueStr)
13331413
if err != nil || paymentDue == nil {
1334-
logging.Warnf("checkDailyCap: failed to parse payment due string %q: %v; treating as $0", paymentDueStr, err)
1335-
paymentDue = new(big.Rat)
1414+
// H1 fix: fail closed on an unparseable payment-due string instead of
1415+
// treating it as $0. An unparseable value means we cannot determine the
1416+
// true cost of this exchange, so proceeding risks exceeding the cap.
1417+
logging.Warnf("checkDailyCap: failed to parse payment due string %q: %v; blocking exchange to avoid cap bypass", paymentDueStr, err)
1418+
return fmt.Sprintf("daily spend check failed: could not parse payment due value %q", paymentDueStr)
13361419
}
13371420

13381421
newTotal := new(big.Rat).Add(dailySpent, paymentDue)

0 commit comments

Comments
 (0)