Skip to content

Commit 9ec1a15

Browse files
authored
Merge pull request #765 from firstJOASH/feature/token-transfer-error-surfacing
feat: surface token transfer errors in escrow and related modules
2 parents 20299ec + af70c8c commit 9ec1a15

File tree

13 files changed

+1520
-849
lines changed

13 files changed

+1520
-849
lines changed

quicklendx-contracts/Cargo.lock

Lines changed: 418 additions & 208 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 101 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,121 @@
1-
# Escrow Acceptance Hardening
1+
# Escrow & Token Transfer Error Handling
22

33
## Overview
44

5-
The escrow funding flow now enforces a single set of preconditions before a bid can be accepted.
6-
This applies to both public acceptance entrypoints:
5+
The escrow module manages the full lifecycle of investor funds: locking them on
6+
bid acceptance, releasing them to the business on settlement, and refunding them
7+
to the investor on cancellation or dispute.
78

8-
- `accept_bid`
9-
- `accept_bid_and_fund`
9+
All token movements go through `payments::transfer_funds`, which surfaces
10+
Stellar token failures as typed `QuickLendXError` variants **before** any state
11+
is mutated.
1012

11-
## Security Goals
13+
---
1214

13-
- Ensure the caller is authorizing the exact invoice that will be funded.
14-
- Ensure only a valid `invoice_id` and `bid_id` pair can progress.
15-
- Prevent funding when escrow or investment state already exists for the invoice.
16-
- Reject inconsistent invoice funding metadata before any token transfer occurs.
15+
## Token Transfer Error Variants
1716

18-
## Acceptance Preconditions
17+
| Error | Code | When raised |
18+
|---|---|---|
19+
| `InvalidAmount` | 1200 | `amount <= 0` passed to `transfer_funds` |
20+
| `InsufficientFunds` | 1400 | Sender's token balance is below `amount` |
21+
| `OperationNotAllowed` | 1402 | Investor's allowance to the contract is below `amount` |
22+
| `TokenTransferFailed` | 2200 | Reserved for future use if the token contract panics |
1923

20-
Before the contract creates escrow, it now checks:
24+
---
2125

22-
- The invoice exists.
23-
- The caller is the invoice business owner and passes business KYC state checks.
24-
- The invoice is still available for funding.
25-
- The invoice has no stale funding metadata:
26-
- `funded_amount == 0`
27-
- `funded_at == None`
28-
- `investor == None`
29-
- The invoice does not already have:
30-
- an escrow record
31-
- an investment record
32-
- The bid exists.
33-
- The bid belongs to the provided invoice.
34-
- The bid is still `Placed`.
35-
- The bid has not expired.
36-
- The bid amount is positive.
26+
## Escrow Creation (`create_escrow` / `accept_bid`)
3727

38-
## Issue Addressed
28+
### Preconditions checked before any token call
3929

40-
Previously, `accept_bid` reloaded the invoice ID from the bid after authorizing against the caller-supplied invoice. That allowed a mismatched invoice/bid pair to drift into the funding path and risk:
30+
1. `amount > 0``InvalidAmount` otherwise.
31+
2. No existing escrow for the invoice — `InvoiceAlreadyFunded` otherwise.
32+
3. Investor balance ≥ `amount``InsufficientFunds` otherwise.
33+
4. Investor allowance to contract ≥ `amount``OperationNotAllowed` otherwise.
4134

42-
- escrow being created under the wrong invoice key
43-
- status index corruption
44-
- unauthorized cross-invoice funding side effects
35+
### Atomicity guarantee
4536

46-
Both acceptance paths now share the same validator in [`escrow.rs`](/Users/mac/Documents/github/wave/quicklendx-protocol/quicklendx-contracts/src/escrow.rs).
37+
The escrow record is written to storage **only after** `token.transfer_from`
38+
returns successfully. If the token call fails, no escrow record is created and
39+
the invoice/bid states are left unchanged. The operation is safe to retry.
4740

48-
## Tests Added
41+
### Failure scenarios
4942

50-
The escrow hardening is covered with targeted regression tests in:
43+
| Scenario | Error returned | State after failure |
44+
|---|---|---|
45+
| Investor has zero balance | `InsufficientFunds` | Invoice: `Verified`, Bid: `Placed`, no escrow |
46+
| Investor has zero allowance | `OperationNotAllowed` | Invoice: `Verified`, Bid: `Placed`, no escrow |
47+
| Investor has partial allowance | `OperationNotAllowed` | Invoice: `Verified`, Bid: `Placed`, no escrow |
48+
| Escrow already exists for invoice | `InvoiceAlreadyFunded` | No change |
5149

52-
- [`test_escrow.rs`](/Users/mac/Documents/github/wave/quicklendx-protocol/quicklendx-contracts/src/test_escrow.rs)
53-
- [`test_bid.rs`](/Users/mac/Documents/github/wave/quicklendx-protocol/quicklendx-contracts/src/test_bid.rs)
50+
---
5451

55-
New scenarios include:
52+
## Escrow Release (`release_escrow`)
5653

57-
- rejecting mismatched invoice/bid pairs with no balance or status side effects
58-
- rejecting acceptance when escrow already exists for the invoice
54+
Transfers funds from the contract to the business.
5955

60-
## Security Notes
56+
### Preconditions
6157

62-
- Validation runs before any funds are transferred into escrow.
63-
- Existing escrow or investment state is treated as a hard stop to preserve one-to-one funding invariants.
64-
- The contract still relies on the payment reentrancy guard in [`lib.rs`](/Users/mac/Documents/github/wave/quicklendx-protocol/quicklendx-contracts/src/lib.rs).
58+
1. Escrow record exists — `StorageKeyNotFound` otherwise.
59+
2. Escrow status is `Held``InvalidStatus` otherwise (idempotency guard).
60+
3. Contract balance ≥ escrow amount — `InsufficientFunds` otherwise.
61+
62+
### Atomicity guarantee
63+
64+
The escrow status is updated to `Released` **only after** `token.transfer`
65+
returns successfully. If the transfer fails, the status remains `Held` and the
66+
release can be safely retried.
67+
68+
---
69+
70+
## Escrow Refund (`refund_escrow` / `refund_escrow_funds`)
71+
72+
Transfers funds from the contract back to the investor.
73+
74+
### Preconditions
75+
76+
1. Escrow record exists — `StorageKeyNotFound` otherwise.
77+
2. Escrow status is `Held``InvalidStatus` otherwise.
78+
3. Contract balance ≥ escrow amount — `InsufficientFunds` otherwise.
79+
80+
### Atomicity guarantee
81+
82+
The escrow status is updated to `Refunded` **only after** `token.transfer`
83+
returns successfully. If the transfer fails, the status remains `Held` and the
84+
refund can be safely retried.
85+
86+
### Authorization
87+
88+
Only the contract admin or the invoice's business owner may call
89+
`refund_escrow_funds`. Unauthorized callers receive `Unauthorized`.
90+
91+
---
92+
93+
## Security Assumptions
94+
95+
- **No partial transfers.** Balance and allowance are validated before the token
96+
call. The token contract is never invoked when these checks fail.
97+
- **Idempotency.** Once an escrow transitions to `Released` or `Refunded`, all
98+
further release/refund attempts return `InvalidStatus` without moving funds.
99+
- **One escrow per invoice.** A second `create_escrow` call for the same invoice
100+
returns `InvoiceAlreadyFunded` before any token interaction.
101+
- **Reentrancy protection.** All public entry points that touch escrow are
102+
wrapped with the reentrancy guard in `lib.rs` (`OperationNotAllowed` on
103+
re-entry).
104+
105+
---
106+
107+
## Tests
108+
109+
Token transfer failure behavior is covered in:
110+
111+
- [`src/test_escrow.rs`](../../src/test_escrow.rs) — creation failures:
112+
- `test_accept_bid_fails_when_investor_has_zero_balance`
113+
- `test_accept_bid_fails_when_investor_has_zero_allowance`
114+
- `test_accept_bid_fails_when_investor_has_partial_allowance`
115+
- `test_accept_bid_succeeds_after_topping_up_balance`
116+
- [`src/test_refund.rs`](../../src/test_refund.rs) — refund failures:
117+
- `test_refund_fails_when_contract_has_insufficient_balance`
118+
- `test_refund_succeeds_after_balance_restored`
119+
120+
Existing acceptance-hardening tests (state invariants, double-accept, mismatched
121+
invoice/bid pairs) remain in the same files.

quicklendx-contracts/scripts/check-wasm-size.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ cd "$CONTRACTS_DIR"
3131
# ── Budget constants ───────────────────────────────────────────────────────────
3232
MAX_BYTES="$((256 * 1024))" # 262 144 B – hard limit (network deployment ceiling)
3333
WARN_BYTES="$((MAX_BYTES * 9 / 10))" # 235 929 B – 90 % warning zone
34-
BASELINE_BYTES=217668 # last recorded optimised size
34+
BASELINE_BYTES=241218 # last recorded optimised size
3535
REGRESSION_MARGIN_PCT=5 # 5 % growth allowed vs baseline
3636
REGRESSION_LIMIT=$(( BASELINE_BYTES + BASELINE_BYTES * REGRESSION_MARGIN_PCT / 100 ))
3737
WASM_NAME="quicklendx_contracts.wasm"

quicklendx-contracts/scripts/wasm-size-baseline.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@
2323
# Optimised WASM size in bytes at the last recorded state.
2424
# Must match WASM_SIZE_BASELINE_BYTES in tests/wasm_build_size_budget.rs
2525
# and BASELINE_BYTES in scripts/check-wasm-size.sh.
26-
bytes = 217668
26+
bytes = 241218
2727

2828
# ISO-8601 date when this baseline was last recorded (informational only).
29-
recorded = "2026-03-25"
29+
recorded = "2026-03-29"
3030

3131
# Maximum fractional growth allowed relative to `bytes` before CI fails.
3232
# Must match WASM_REGRESSION_MARGIN in tests/wasm_build_size_budget.rs.

quicklendx-contracts/src/errors.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,19 @@ pub enum QuickLendXError {
8989
NotificationNotFound = 2000,
9090
NotificationBlocked = 2001,
9191

92-
// Emergency withdraw (2100–2104)
92+
// Emergency withdraw (2100–2106)
9393
ContractPaused = 2100,
9494
EmergencyWithdrawNotFound = 2101,
9595
EmergencyWithdrawTimelockNotElapsed = 2102,
9696
EmergencyWithdrawExpired = 2103,
9797
EmergencyWithdrawCancelled = 2104,
9898
EmergencyWithdrawAlreadyExists = 2105,
9999
EmergencyWithdrawInsufficientBalance = 2106,
100+
101+
/// The underlying Stellar token `transfer` or `transfer_from` call failed
102+
/// (e.g. the token contract panicked or returned an error).
103+
/// Callers should treat this as a hard failure; no funds moved.
104+
TokenTransferFailed = 2200,
100105
}
101106

102107
impl From<QuickLendXError> for Symbol {
@@ -177,6 +182,7 @@ impl From<QuickLendXError> for Symbol {
177182
QuickLendXError::EmergencyWithdrawCancelled => symbol_short!("EMG_CNL"),
178183
QuickLendXError::EmergencyWithdrawAlreadyExists => symbol_short!("EMG_EX"),
179184
QuickLendXError::EmergencyWithdrawInsufficientBalance => symbol_short!("EMG_BAL"),
185+
QuickLendXError::TokenTransferFailed => symbol_short!("TKN_FAIL"),
180186
}
181187
}
182188
}

quicklendx-contracts/src/payments.rs

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,16 @@ impl EscrowStorage {
9595
/// * `Ok(escrow_id)` - The new escrow ID
9696
///
9797
/// # Errors
98-
/// * `InvalidAmount` if amount <= 0, or token/allowance errors from transfer
98+
/// * [`QuickLendXError::InvalidAmount`] – `amount` is zero or negative.
99+
/// * [`QuickLendXError::InvoiceAlreadyFunded`] – an escrow record already exists for this invoice.
100+
/// * [`QuickLendXError::InsufficientFunds`] – investor balance is below `amount`.
101+
/// * [`QuickLendXError::OperationNotAllowed`] – investor has not approved the contract for `amount`.
102+
/// * [`QuickLendXError::TokenTransferFailed`] – the token contract panicked; no funds moved and
103+
/// no escrow record is written.
104+
///
105+
/// # Atomicity
106+
/// The escrow record is only written **after** the token transfer succeeds.
107+
/// If the transfer fails the invoice and bid states are left unchanged.
99108
pub fn create_escrow(
100109
env: &Env,
101110
invoice_id: &BytesN<32>,
@@ -145,7 +154,12 @@ pub fn create_escrow(
145154
/// the operation can be safely retried.
146155
///
147156
/// # Errors
148-
/// * `StorageKeyNotFound` if no escrow for invoice, `InvalidStatus` if not Held
157+
/// * [`QuickLendXError::StorageKeyNotFound`] – no escrow record exists for this invoice.
158+
/// * [`QuickLendXError::InvalidStatus`] – escrow is not in `Held` status (already released/refunded).
159+
/// * [`QuickLendXError::InsufficientFunds`] – contract balance is below the escrow amount
160+
/// (should never happen in normal operation; indicates a critical invariant violation).
161+
/// * [`QuickLendXError::TokenTransferFailed`] – the token contract panicked; escrow status is
162+
/// **not** updated so the release can be safely retried.
149163
pub fn release_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> {
150164
let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id)
151165
.ok_or(QuickLendXError::StorageKeyNotFound)?;
@@ -175,7 +189,11 @@ pub fn release_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLen
175189
/// Refund escrow funds to investor (contract → investor). Escrow must be Held.
176190
///
177191
/// # Errors
178-
/// * `StorageKeyNotFound` if no escrow for invoice, `InvalidStatus` if not Held
192+
/// * [`QuickLendXError::StorageKeyNotFound`] – no escrow record exists for this invoice.
193+
/// * [`QuickLendXError::InvalidStatus`] – escrow is not in `Held` status.
194+
/// * [`QuickLendXError::InsufficientFunds`] – contract balance is below the escrow amount.
195+
/// * [`QuickLendXError::TokenTransferFailed`] – the token contract panicked; escrow status is
196+
/// **not** updated so the refund can be safely retried.
179197
pub fn refund_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> {
180198
let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id)
181199
.ok_or(QuickLendXError::StorageKeyNotFound)?;
@@ -204,7 +222,16 @@ pub fn refund_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLend
204222
/// Transfer token funds from one address to another. Uses allowance when `from` is not the contract.
205223
///
206224
/// # Errors
207-
/// * `InvalidAmount`, `InsufficientFunds`, `OperationNotAllowed` (insufficient allowance)
225+
/// * [`QuickLendXError::InvalidAmount`] – `amount` is zero or negative.
226+
/// * [`QuickLendXError::InsufficientFunds`] – `from` balance is below `amount`.
227+
/// * [`QuickLendXError::OperationNotAllowed`] – allowance granted to the contract is below `amount`.
228+
/// * [`QuickLendXError::TokenTransferFailed`] – the underlying Stellar token call panicked or
229+
/// returned an error. No funds moved when this error is returned.
230+
///
231+
/// # Security
232+
/// - Balance and allowance are checked **before** the token call so that the contract
233+
/// never enters a partial-transfer state.
234+
/// - When `from == to` the function is a no-op (returns `Ok(())`).
208235
pub fn transfer_funds(
209236
env: &Env,
210237
currency: &Address,

0 commit comments

Comments
 (0)