Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .wasm-budget.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"receipt_anchor": 32834,
"refund_vault": 57541
}
}
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ cumulative total (`amount_refunded`) plus the `payment_amount` ceiling, the
configured, each `RefundEvent` also carries the `fee` deducted from the claim,
and the fee is paid to the `fee_recipient` alongside the recipient's payout.

`process_batch` deliberately emits **one** `BatchRefundEvent` for the whole batch
instead of one `RefundEvent` per item: a per-refund event costs ~530 bytes of
contract-event budget, and mainnet caps a transaction at 16 KiB — so 50+ refunds
would not fit if each emitted its own event. The token contract's per-refund
`transfer` event (unavoidable) dominates what remains, which is why
`MAX_REFUND_BATCH_SIZE` is 50.

**Cross-Contract Joins** (both claims below are pinned by tests in
`contracts/refund-vault/tests/integration_test.rs`):
- **`payment_ref` ↔ receipt-leaf** *(covered by `readme_claim_payment_ref_is_receipt_leaf`)*: The `payment_ref` used to key refunds is identical to the `leaf` hash of the payment receipt anchored in `ReceiptAnchor`. This 1:1 mapping guarantees that the on-chain refund explicitly corresponds to the exact payment record provided to the agent.
Expand Down
2 changes: 1 addition & 1 deletion contracts/receipt-anchor/src/fuzz_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ const ANCHOR_BATCH_BASELINE_MEM: u64 = 3_819_993;

/// Cost baselines for `verify_receipt` (4-leaf Merkle proof, including cross-contract shard routing)
/// Measured via `env.cost_estimate().budget().cpu_instruction_cost()` and `env.cost_estimate().memory_bytes_cost()` on 2026-08-26.
const VERIFY_RECEIPT_BASELINE_CPU: u64 = 569_906;
const VERIFY_RECEIPT_BASELINE_CPU: u64 = 781_001;
const VERIFY_RECEIPT_BASELINE_MEM: u64 = 1_500_000;

#[test]
Expand Down
28 changes: 27 additions & 1 deletion contracts/refund-vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,17 @@ pub struct YieldInfo {
pub max_deploy_ratio: u32,
}

/// Emitted when a (possibly partial) refund is made from the vault float.
/// Emitted when a (possibly partial) refund is made from the vault float via
/// the single-refund `refund` entry point.
///
/// Topics: `("refund_event", payment_ref)`. The data map carries the amount
/// for **this call** (`amount`) and the running total after it
/// (`cumulative_refunded`), so an indexer knows the state of a payment without
/// summing history.
///
/// Refunds processed through [`RefundVault::process_batch`] do **not** emit
/// one of these per item: a batch emits a single [`BatchRefundEvent`] instead
/// (see its docs for why).
#[contractevent]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RefundEvent {
Expand Down Expand Up @@ -792,6 +797,17 @@ impl RefundVault {
return Err(Error::BatchTooLarge);
}

// An empty batch is a no-op; return before touching any state so the
// caller can probe auth without paying for state loads.
if refunds.is_empty() {
return Ok(Vec::new(&env));
}

// State loads shared across the whole batch: one balance query and one
// window/ledger/token read — the loop below only touches per-payment
// storage and performs the transfers.
let mut ctx = Self::load_refund_context(&env);
let mut payment_refs: Vec<BytesN<32>> = Vec::new(&env);
let mut results = Vec::new(&env);
for item in refunds.into_iter() {
let claim = RefundClaim {
Expand All @@ -803,6 +819,16 @@ impl RefundVault {
};
results.push_back(claim_single(&env, &claim).is_ok());
}

BatchRefundEvent {
payment_refs,
results: results.clone(),
}
.publish(&env);

env.storage()
.instance()
.extend_ttl(TTL_THRESHOLD, TTL_EXTEND);
Ok(results)
}

Expand Down
43 changes: 43 additions & 0 deletions contracts/refund-vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1881,6 +1881,49 @@ fn test_refund_to_contract_address_fails_self_transfer() {
assert_eq!(events.events().len(), 0);
}

/// The batch path must reject items targeting the vault itself (fail closed,
/// skipped with `false`) instead of consuming the payment_ref while leaving
/// float untouched — the self-transfer threat in SECURITY_MODEL §Threats.
#[test]
fn test_process_batch_item_to_contract_address_skipped() {
let (env, client, merchant, token) = setup(100);
let per_refund = 10_000i128;
client.deposit(&merchant, &(2 * per_refund));

let mut params = batch_params(&env, 2, per_refund);
let vault_addr = client.address.clone();
// Point the second item at the vault itself.
params.set(
1,
RefundParam {
payment_ref: params.get(1).unwrap().payment_ref,
recipient: vault_addr,
amount: per_refund,
paid_at_ledger: 0,
payment_amount: per_refund,
},
);

env.cost_estimate()
.budget()
.reset_limits(2_000_000_000, 2_000_000_000);
let res = client.process_batch(&params);

// First item refunded, second skipped (self-transfer), no panic.
assert_eq!(res, vec![&env, true, false]);
assert!(client
.get_refund(&params.get(0).unwrap().payment_ref)
.is_some());
assert!(client
.get_refund(&params.get(1).unwrap().payment_ref)
.is_none());
// Float intact except the one legit payout.
assert_eq!(
TokenClient::new(&env, &token).balance(&client.address),
per_refund
);
}

#[test]
fn test_withdraw_to_contract_address_fails_self_transfer() {
use soroban_sdk::testutils::Events;
Expand Down
Loading
Loading