|
1 | | -# Escrow Acceptance Hardening |
| 1 | +# Escrow & Token Transfer Error Handling |
2 | 2 |
|
3 | 3 | ## Overview |
4 | 4 |
|
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. |
7 | 8 |
|
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. |
10 | 12 |
|
11 | | -## Security Goals |
| 13 | +--- |
12 | 14 |
|
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 |
17 | 16 |
|
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 | |
19 | 23 |
|
20 | | -Before the contract creates escrow, it now checks: |
| 24 | +--- |
21 | 25 |
|
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`) |
37 | 27 |
|
38 | | -## Issue Addressed |
| 28 | +### Preconditions checked before any token call |
39 | 29 |
|
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. |
41 | 34 |
|
42 | | -- escrow being created under the wrong invoice key |
43 | | -- status index corruption |
44 | | -- unauthorized cross-invoice funding side effects |
| 35 | +### Atomicity guarantee |
45 | 36 |
|
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. |
47 | 40 |
|
48 | | -## Tests Added |
| 41 | +### Failure scenarios |
49 | 42 |
|
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 | |
51 | 49 |
|
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 | +--- |
54 | 51 |
|
55 | | -New scenarios include: |
| 52 | +## Escrow Release (`release_escrow`) |
56 | 53 |
|
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. |
59 | 55 |
|
60 | | -## Security Notes |
| 56 | +### Preconditions |
61 | 57 |
|
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. |
0 commit comments