Skip to content

feat: on-chain pending holds + unified spend() settlement#120

Open
seongyun-ko wants to merge 1 commit intomasterfrom
feat/on-chain-pending-holds
Open

feat: on-chain pending holds + unified spend() settlement#120
seongyun-ko wants to merge 1 commit intomasterfrom
feat/on-chain-pending-holds

Conversation

@seongyun-ko
Copy link
Copy Markdown
Contributor

@seongyun-ko seongyun-ko commented Apr 20, 2026

Summary

Introduces PendingHoldsModule (PHM) — a provider-namespaced on-chain registry of card transaction holds — and unifies spend() + forceSpend() into a single settlement path where Settlement is KING.

  • Auth-ack consumes the user's spending limit immediately via addHold. The limit shrinks the moment the merchant authorizes a swipe, so subsequent spends can't double-draw against already-authorized balance.
  • Settlement reconciles the hold with the actual merchant settlement amount. If no hold exists (late settlement, backend gap), Settlement is KING — we still settle by creating a forced hold and bypassing the limit.
  • Partial settlement is first-class when PHM is wired: if the safe has insufficient token balance at settlement, we transfer what's available, emit Spend with the actually spent amount, and leave a forced residual hold tracking the remaining debt.
  • Reversals release the hold and credit the limit back. No token movement.

Moved from sc-cash-internal#20 (the op-migration temporary repo). Codex P1 bot finding addressed: no-PHM mode now reverts with InsufficientBalance on partial settlement instead of silently dropping the remainder.

What shipped

New contracts

  • src/modules/cash/PendingHoldsModule.sol — UUPS module, ERC-7201 namespaced storage, provider-namespaced hold keys (keccak256(safe, providerCode, txId)).
  • src/interfaces/IPendingHoldsModule.solHoldRecord, ReleaseReason, events, errors.

CashModuleCore refactor

  • Removed forceSpend(). Unified into spend() with three internal phases: _phmSettleHold → transfer → _phmFinalize.
  • _spendDebitPartial() — caps each token at available balance, returns actual USD transferred, emits Spend with actual amounts.
  • _phmSettleHold performs all limit accounting in Core (no PHM→Core callback), avoiding reentrancy into the nonReentrant context.
  • No-PHM guard: partial settlement reverts with InsufficientBalance when pendingHoldsModule == address(0) — there is no remainder tracking in legacy/pre-PHM mode, so strict debit semantics are preserved.

Supporting changes

  • SpendingLimitLib.release() — credits back amount to daily/monthly counters, with time-based resets applied first.
  • CashLens — adds spendable(), canSpend(), holdsSummary() views that subtract totalPendingHolds from rawSpendable.
  • CashModuleSettersconsumeSpendingLimit / releaseSpendingLimit PHM→Core callbacks for addHold / releaseHold / updateHold.

E2E call flow — Rain card transaction

Below is the full lifecycle. Same mechanics apply to Reap / PIX with their own providerCode.

1. Cardholder swipes — auth-ack

Rain  →  EtherFi wallet  →  PHM.addHold(safe, bytes4("RAIN"), txId, amountUsd)
  • PHM calls CashModuleCore.consumeSpendingLimit(safe, amountUsd)
    • Reverts with ExceededDailySpendingLimit / ExceededMonthlySpendingLimit if the hold would breach the limit
  • Stores HoldRecord { amountUsd, createdAt, providerCode=RAIN, forced=false }
  • totalHolds[safe] += amountUsd
  • Emits HoldAdded(safe, RAIN, txId, amountUsd, createdAt, forced=false)

Effect: spendable(safe) = rawSpendable(safe) − totalPendingHolds(safe) shrinks immediately. User cannot double-spend against the authorized amount.

2. Merchant adjusts the auth (tip, fuel top-up, incremental auth)

Rain  →  EtherFi wallet  →  PHM.updateHold(safe, RAIN, txId, newAmountUsd)
  • newAmountUsd > oldAmountUsd: calls consumeSpendingLimit(delta) → charges delta to limit
  • newAmountUsd < oldAmountUsd: calls releaseSpendingLimit(delta) → credits delta back
  • Forced holds skip the callback (they never consumed limit at creation)
  • Updates totalHolds[safe] and the record
  • Emits HoldUpdated(safe, RAIN, txId, oldAmountUsd, newAmountUsd, updatedAt)

3. Settlement arrives (happy path)

Rain  →  EtherFi wallet  →  CashModuleCore.spend(safe, txId, BinSponsor.Rain, tokens, amountsInUsd, cashbacks)

Inside spend() (whenNotPaused, nonReentrant, onlyEtherFiWallet):

3a. _validateSpend

  • Array length + duplicate-token checks
  • transactionCleared[txId] = true (idempotency — replays revert with TransactionAlreadyCleared)
  • Returns totalSpendingInUsd

3b. _phmSettleHold(safe, Rain, txId, totalSpendingInUsd)

  • Calls PHM.settlementSyncHold(...) which returns (existed, wasForced, oldAmount) with no callback

  • Updates hold record to totalSpendingInUsd, adjusts totalHolds[safe]

  • Core then applies three-way limit logic:

    Scenario Limit action
    !existed (no prior hold) Bypass — Settlement is KING
    existed && !wasForced Charge/release delta only (settlement − oldAmount)
    existed && wasForced (forceAddHold path) Charge full settlement

    For the normal Rain flow: settlement == oldAmount → delta = 0 → limit untouched (it was already consumed at auth-ack).

3c. Token transfer

  • Credit mode: _spendCredit — borrows full settlement from DebtManager, all-or-nothing
  • Debit mode: _spendDebitPartial — for each token, transfers min(required, available), scales USD proportionally, returns actualSpendInUsd
  • No-PHM guard: if pendingHoldsModule == address(0) and actualSpendInUsd < totalSpendingInUsd, revert InsufficientBalance. The remainder has nowhere to go; legacy/pre-PHM deployments keep strict debit semantics.

3d. _phmFinalize(safe, Rain, txId, total, actual)

  • remaining = total − actual
  • remaining == 0: PHM.removeHold(safe, Rain, txId) → deletes record, decrements totalHolds, emits HoldRemoved
  • remaining > 0: PHM.settlementSetRemainingHold(safe, Rain, txId, remaining) → updates hold to debt amount, marks forced=true, no limit adjustment (limit was charged for the full settlement at 3b). A separate special-function clearance path handles the residual.

3e. _cashback — accrues cashback against actualSpendInUsd (partial-aware)

Emits: Spend(safe, txId, Rain, tokens, actualAmounts, actualUsd, actualSpendInUsd, Mode) where actualSpendInUsd ≤ totalSpendingInUsd.

4. Reversal — auth never settled

Rain  →  EtherFi wallet  →  PHM.releaseHold(safe, RAIN, txId, ReleaseReason.REVERSAL)
  • For non-forced holds: calls releaseSpendingLimit(safe, holdAmount) → credits limit back
  • Forced holds skip the callback (they never consumed limit)
  • Deletes the record, decrements totalHolds[safe]
  • Emits HoldReleased(safe, RAIN, txId, holdAmount, REVERSAL, releasedAt)

Effect: user's limit is fully restored; no token movement.

5. Operator recovery — settlement without auth-ack (backend gap)

If a settlement arrives for a tx that never went through auth-ack (e.g., EtherFi wallet outage):

Option A — force-capture before settlement:

EtherFi wallet  →  PHM.forceAddHold(safe, RAIN, txId, amountUsd)
[later]         →  CashModuleCore.spend(...)
  • forceAddHold creates HoldRecord { forced=true }, bypasses the balance check
  • No consumeSpendingLimit callback at creation
  • At settlement, _phmSettleHold sees wasForced=true → charges full settlement to the limit

Option B — spend with no prior hold (late settlement):

EtherFi wallet  →  CashModuleCore.spend(...)
  • _phmSettleHold calls settlementSyncHold; no record exists → PHM creates a forced hold with settlementAmount
  • Returns (existed=false, ...) → Core bypasses the limit entirely ("Settlement is KING")
  • Token transfer proceeds normally; _phmFinalize removes or updates the hold per partial/full result

6. Admin release (non-reversal ops action)

EtherFi wallet  →  PHM.releaseHold(safe, RAIN, txId, ReleaseReason.ADMIN)

Same mechanics as reversal, but reason=ADMIN in the event for downstream ops indexing.


Invariants

Throughout every transition:

spendable(safe) = rawSpendable(safe) − totalPendingHolds(safe)
  • rawSpendable = raw daily/monthly limit headroom from SpendingLimitLib
  • totalPendingHolds = sum of all active hold amounts (forced + non-forced)
  • transactionCleared[txId] is monotonic — once true, never flips back; replays hard-revert

Two-removal-path invariant

  • removeHold() — post-spend only, callable only by CashModuleCore
  • releaseHold() — non-spend path (reversals / admin), callable only by EtherFi wallet

No hold can be removed without one of these two explicit calls.

Provider namespacing

Keys are keccak256(abi.encode(safe, providerCode, txId)). Rain / Reap / PIX / CardOrder each have their own bytes4 namespace, so a RAIN txId cannot collide with a REAP hold. IPendingHoldsModule.providerCodeFromBinSponsor(BinSponsor) is the canonical converter used at settlement time. Backend is trusted (onlyEtherFiWallet) to send canonical codes at hold creation; no on-chain allowlist so new providers can ship without a contract upgrade.


Test plan

  • Unit: addHold / forceAddHold / updateHold / releaseHold / removeHold — auth, amount validation, duplicate guards, totalHolds accounting
  • Unit: settlementSyncHold exists + non-existent paths, forced vs non-forced, decrease/increase/noop
  • Unit: settlementSetRemainingHold marks forced, adjusts totalHolds, emits HoldUpdated
  • Integration: full auth → settlement flow (hold removed, limit delta correct)
  • Integration: incremental auth (updateHold up + down, limit deltas)
  • Integration: reversal flow (releaseHold → limit credited, then spend with no hold succeeds via Settlement is KING)
  • Integration: forceAddHold → spend charges full limit at settlement
  • Integration: spend with no prior hold (Settlement is KING) bypasses limit, doesn't affect unrelated holds
  • Integration: withdrawal guard — holds block requestWithdrawal; release unblocks it
  • Lens: spendable, canSpend, holdsSummary reflect hold consumption
  • No-PHM guard: partial debit settlement reverts InsufficientBalance when PHM is unset
  • Byte code size check: CashModuleCore under 24KB
  • Full test suite: 49/49 PHM tests passing, 414/415 cash module tests passing (1 unrelated pre-existing failure in SettlementDispatcher)

Deployment notes

  • New DeployPendingHoldsModule.s.sol script
  • UpgradeCashModuleWithPendingHolds.s.sol — wires PHM into existing Core deployments
  • UpgradeCashLensWithPendingHolds.s.sol — upgrades Lens to holds-aware implementation
  • CashLens constructor now takes (cashModule, dataProvider, pendingHoldsModule) — all existing deploy/upgrade scripts updated to pass address(0) placeholder; real address wired in PHM deployment flow

Post-Deploy Monitoring & Validation

  • Watch HoldAdded / HoldRemoved / HoldReleased event volumes match Rain backend's auth → settlement volumes
  • Alert on any HoldAdded { forced: true } from forceAddHold — should be operator-invoked only, rare
  • Alert on Spend events where amountInUsd < totalSpendingInUsd (partial settlement — track remaining holds)
  • Monitor totalPendingHolds drift vs. sum of open holds in backend DB
  • Verify spendable == rawSpendable − totalPendingHolds on a sample of safes via CashLens.holdsSummary

🤖 Generated with Claude Code


Note

High Risk
High risk because it changes core CashModule settlement/spending-limit accounting and introduces a new on-chain holds registry that gates withdrawals and enables partial debit settlements when configured.

Overview
Introduces an on-chain PendingHoldsModule to track provider-namespaced pending card holds, including hold lifecycle APIs/events (add/update/release at auth time; sync/remove/update at settlement) and controller-managed cashModuleCore wiring.

Refactors CashModuleCore.spend() into a unified settlement path that syncs/creates holds, adjusts spending limits via deltas, supports partial debit settlement (emitting Spend for the actual transferred amount) and records any remainder as a forced hold; legacy deployments without PHM now revert on partial debit settlement to avoid silent debt.

Adds spending-limit “credit back” support via SpendingLimitLib.release(), blocks withdrawals when holds exist, and updates CashLens to take a PHM address (immutable) and expose hold-aware views (spendable, holdsSummary). Deployment/upgrade scripts and tests are updated to pass the new CashLens constructor arg and to add PHM deploy + Gnosis upgrade bundles.

Reviewed by Cursor Bugbot for commit aeea9d7. Bugbot is set up for automated code reviews on this repo. Configure here.

Introduces PendingHoldsModule (PHM) as a provider-namespaced on-chain hold
registry for Rain / Reap / PIX card transactions, and unifies spend() +
forceSpend() into a single settlement path where "Settlement is KING".

- addHold/updateHold/releaseHold — EtherFi wallet lifecycle at auth-ack /
  incremental auth / network reversal; spending limit charged at auth time
- forceAddHold — operator recovery path, bypasses balance check (forced=true)
- settlementSyncHold / settlementSetRemainingHold / removeHold —
  CashModuleCore-only callbacks at settlement; no reentrancy back into Core
- Unified spend(): hold sync (create-if-missing) → partial-aware token
  transfer → finalize (remove on full, update-remaining on partial).
  Spend event always reflects actual spent amount, not requested settlement.
- Three-way limit accounting: no-hold bypass, non-forced delta, forced full
- No-PHM guard: partial settlement reverts with InsufficientBalance when
  pendingHoldsModule is unset (no remainder tracking → preserve strict
  debit semantics for legacy / pre-PHM deployments).
- CashLens exposes holds-aware spendable() and canSpend()
- SpendingLimitLib.release() added to credit back limit on downward deltas

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

Certora Run Started (Certora verification)

  • Group ID: 666a65bb-470b-49c0-b622-da0f4d6121a8
Config Status Link Log File
CashModuleCore.conf --rule "p03" Failed (1) - certora/confs/CashModuleCore.conf-7b4bf49915e0.log
EtherFiSafe.conf Failed (1) - certora/confs/EtherFiSafe.conf-0ec309c0e339.log

Certora Run Summary

  • Started 0 jobs
  • 2 jobs failed

Download Logs

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit aeea9d7. Configure here.

currentLimit(limit);
limit.spentToday = amount <= limit.spentToday ? limit.spentToday - amount : 0;
limit.spentThisMonth = amount <= limit.spentThisMonth ? limit.spentThisMonth - amount : 0;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-day hold release silently inflates daily spending capacity

Medium Severity

SpendingLimitLib.release() floors spentToday at zero after calling currentLimit(), which resets the daily counter on day boundaries. When a hold created on Day 1 is released on Day 2 (after spentToday resets to 0), the release over-credits the daily counter, erasing legitimate Day 2 spending. For example: Day 1 addHold(100) sets spentToday=100; Day 2 spend(80) sets spentToday=80; Day 2 releaseHold(100) floors spentToday to 0 instead of keeping it at 80 — the user can then spend the full daily limit again. The monthly counter is unaffected when stays within the same month.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aeea9d7. Configure here.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aeea9d70db

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 408 to +409
$$.transactionCleared[txId] = true;
$$.spendingLimit.spend(totalSpendingInUsd);
// NOTE: spendingLimit.spend() is NOT called here. Callers are responsible:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Namespace cleared-transaction key by provider

The new hold registry is keyed by (safe, providerCode, txId) (PendingHoldsModule._holdKey), so different providers can legitimately share the same txId, but spend() still marks replay state in a single transactionCleared[txId] slot. Once one provider settles, a second provider settlement with the same txId will revert with TransactionAlreadyCleared, leaving its hold unreconciled (and potentially blocking withdrawals until manual release). Replay tracking should include provider namespace to match the new hold-key model.

Useful? React with 👍 / 👎.

Comment on lines +141 to +145
external
whenNotPaused
nonReentrant
onlyEtherFiWallet
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate safe addresses before recording holds

addHold accepts any address and proceeds without verifying that safe is an EtherFi Safe. That allows backend typos/non-safe addresses to consume spending-limit state and create holds that cannot be settled through spend() (which is onlyEtherFiSafe), forcing manual cleanup and risking stale state on incorrect addresses. Adding safe validation at hold entry points (or in the CashModule callback) would prevent this operationally expensive failure mode.

Useful? React with 👍 / 👎.

@seongyun-ko seongyun-ko requested a review from shivam-ef April 28, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant