Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ jobs:
target
key: ${{ runner.os }}-cargo-wasm-${{ hashFiles('**/Cargo.lock') }}
- name: Wasm build
run: cargo build --target wasm32v1-none --release
# `testutils` is a host-only test-support crate (it enables
# soroban-sdk's `testutils` feature, which the SDK rejects on wasm), so
# it is excluded from the artifact build.
run: cargo build --workspace --target wasm32v1-none --release --exclude testutils

- name: Report WASM sizes & enforce size budget
run: |
Expand Down
4 changes: 2 additions & 2 deletions .wasm-budget.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"receipt_anchor": 24576,
"refund_vault": 37376
"receipt_anchor": 33792,
"refund_vault": 56320
}
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ breaking changes bump the **minor** version, and they are called out as such.
`.git/HEAD`, the resolved branch ref, the index and `src/` so a cached build
cannot report a stale hash. A `test_commit_meta_is_well_formed` test in both
crates pins the embedded commit to 40 hex characters.
- **Oracle aggregator for dynamic refund policies** (`RefundVault`): a
standard `Oracle` interface (`get_price` + `get_last_update_ledger`) that
any price/data feed contract can implement, merchant-whitelisted via
`add_oracle`/`remove_oracle`/`get_oracles`; a median aggregator
(`get_median_price`) that queries every whitelisted oracle for a feed and
returns the median of the fresh (non-stale) values, so no single provider
is trusted; and an `OraclePolicy` (feed, threshold, staleness bound,
`refund_when_below`) installed via `set_oracle_policy`/`clear_oracle_policy`
that gates `refund` and `process_batch` — a refund is only paid out while
the aggregated feed satisfies the condition, failing closed on a missing
whitelist or all-stale data. New events `oracle_policy_set_event` /
`oracle_policy_cleared_event` and error codes 302–307
(`NoOraclesConfigured`, `OracleAlreadyAdded`, `OracleNotFound`,
`StaleOracleData`, `NoOraclePolicy`, `OraclePolicyDenied`).

### Changed

Expand All @@ -49,6 +63,20 @@ breaking changes bump the **minor** version, and they are called out as such.
`test_deposit_from_non_merchant_fails` pins the behaviour and is annotated as
deliberate.

### Fixed

- **`main` was failing CI** (left red by the advanced-wasm-memory merge):
restored the truncated `assert_eq!` in
`test_process_batch_exceeds_max_size_fails` (the file would not parse),
fixed the clippy 1.98 `needless_borrow` / `unnecessary_cast` violations,
excluded the host-only `testutils` crate from the wasm artifact build (it
enables soroban-sdk's `testutils` feature, which the SDK rejects on wasm),
and re-baselined the cost-regression constants and wasm size budgets to the
freshly measured values (`verify_receipt` CPU 569,906 → 780,985 after the
pure-Wasm sha2 rewrite; `refund` CPU 397,721 → 477,714; `refund_vault.wasm`
37,376 → 56,320 bytes; `receipt_anchor.wasm` 24,576 → 33,792 bytes on the
current toolchain).

## [0.3.0] — 2026-08-26

### ⚠️ Breaking
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ Holds merchant float and executes refunds bounded by an on-chain policy.
| `get_pending_policy()` | Returns the current pending policy proposal, if any. |
| `get_policy_timelock()` | Returns the policy timelock delay in ledgers (read-only). |
| `get_refund(payment_ref) -> Option<RefundRecord>` | Looks up a refund. |
| `add_oracle(oracle)` | Whitelists an oracle contract implementing the standard `Oracle` interface (`get_price` + `get_last_update_ledger`); merchant auth required. |
| `remove_oracle(oracle)` | Removes an oracle from the whitelist; merchant auth required. |
| `get_oracles() -> Vec<Address>` | Returns the oracle whitelist, in insertion order (read-only). |
| `get_median_price(feed_id, max_staleness_ledgers) -> Result<i128, Error>` | Queries every whitelisted oracle for the feed and returns the **median** of the fresh (non-stale) values. |
| `set_oracle_policy(policy)` | Installs the dynamic oracle policy that gates refunds; merchant auth required. |
| `clear_oracle_policy()` | Removes the dynamic oracle policy, restoring time-window-only refunds; merchant auth required. |
| `get_oracle_policy() -> Option<OraclePolicy>` | Returns the current oracle policy, if any (read-only). |
| `pause()` | Pauses operations for emergency stops. Merchant auth required. |
| `unpause()` | Resumes paused operations. Merchant auth required. |
| `extend_refund_ttl(payment_ref)` | Extends the TTL of a refund record to prevent archival. Publicly callable. |
Expand All @@ -134,6 +141,8 @@ Emits:
| `PauseEvent` | `("pause_event", ledger)` | — |
| `UnpauseEvent` | `("unpause_event", ledger)` | — |
| `RefundWindowUpdatedEvent` | `("refund_window_updated_event", previous_window, new_window)` | — |
| `OraclePolicySetEvent` | `("oracle_policy_set_event", feed_id)` | `threshold`, `refund_when_below`, `max_staleness_ledgers` |
| `OraclePolicyClearedEvent` | `("oracle_policy_cleared_event", feed_id)` | — |

Each partial refund emits its own `RefundEvent` carrying **both** the amount for
that call (`amount`) and the running total (`cumulative_refunded`), so an indexer
Expand All @@ -160,6 +169,21 @@ Enforced invariants, each covered by a test:
[`docs/SECURITY_MODEL.md`](docs/SECURITY_MODEL.md#1-the-admin-merchant)).
- **Pausable** — operations are halted if the vault is paused (`Paused`).

**Dynamic (oracle-gated) policies** — beyond the static refund window, the
merchant can install an `OraclePolicy` so refunds are only paid out while an
externally-sourced value satisfies a condition (e.g. *"refund while the asset
price is below the SLA floor"*). The vault never trusts a single feed:
whitelisted oracles implement the standard `Oracle` interface
(`get_price` / `get_last_update_ledger`), the aggregator queries all of them
and takes the **median** of the fresh values, and a value older than the
policy's `max_staleness_ledgers` is excluded. If no oracle is whitelisted, or
every whitelisted oracle is stale, the vault **fails closed**
(`NoOraclesConfigured` / `StaleOracleData`) rather than guessing; a refund
rejected by the condition returns `OraclePolicyDenied`. The gate applies to
both `refund` and every item of `process_batch`. See
[`docs/SECURITY_MODEL.md`](docs/SECURITY_MODEL.md#6-the-oracle-aggregator-optional)
for the trust model.

## Error Codes

Both contracts return errors from a **single, shared enum** in
Expand Down Expand Up @@ -188,6 +212,12 @@ contracts instead of per-contract tables.
| 17 | `NothingToHarvest` | Nothing to harvest from the yield strategy. |
| 18 | `InvalidRatio` | A configured ratio was out of range. |
| 19 | `ExceedsPayment` | Cumulative refunds would exceed the payment ceiling. |
| 302 | `NoOraclesConfigured` | No oracle contracts are whitelisted on the vault. |
| 303 | `OracleAlreadyAdded` | An oracle contract is already on the whitelist. |
| 304 | `OracleNotFound` | The oracle contract is not on the whitelist. |
| 305 | `StaleOracleData` | Every whitelisted oracle returned stale data for the requested feed. |
| 306 | `NoOraclePolicy` | No dynamic oracle policy is configured. |
| 307 | `OraclePolicyDenied` | A refund was rejected because the oracle policy condition was not met. |
| 100 | `BatchNotFound` | The requested batch does not exist (or was pruned). |
| 101 | `BatchTooLarge` | A batch larger than `MAX_BATCH_SIZE` was submitted. |

Expand Down
12 changes: 12 additions & 0 deletions contracts/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,16 @@ pub enum Error {
NoPendingPolicy = 300,
/// The timelock period has not yet elapsed.
TimelockNotExpired = 301,
/// No oracle contracts are whitelisted on the vault.
NoOraclesConfigured = 302,
/// The oracle contract is already on the whitelist.
OracleAlreadyAdded = 303,
/// The oracle contract is not on the whitelist.
OracleNotFound = 304,
/// Every whitelisted oracle returned stale data for the requested feed.
StaleOracleData = 305,
/// No dynamic oracle policy is configured.
NoOraclePolicy = 306,
/// A refund was rejected because the oracle policy condition was not met.
OraclePolicyDenied = 307,
}
5 changes: 4 additions & 1 deletion contracts/receipt-anchor/src/fuzz_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,10 @@ 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;
/// CPU re-baselined on 2026-08-28 after the pure-Wasm sha2 verification
/// rewrite (#250) moved hashing into the guest, raising the host-measured
/// instruction count (see the re-baselining procedure in CONTRIBUTING.md).
const VERIFY_RECEIPT_BASELINE_CPU: u64 = 780_985;
const VERIFY_RECEIPT_BASELINE_MEM: u64 = 1_500_000;

#[test]
Expand Down
2 changes: 1 addition & 1 deletion contracts/receipt-anchor/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ fn test_root_buffer_evicts_oldest_when_full() {
// First entry is now [1u8; 32] (the second root we anchored).
assert_eq!(buffer.get(0).unwrap(), BytesN::from_array(&env, &[1u8; 32]));
// Last entry is the new root.
assert_eq!(buffer.get((ROOT_BUFFER_SIZE - 1) as u32).unwrap(), new_root);
assert_eq!(buffer.get(ROOT_BUFFER_SIZE - 1).unwrap(), new_root);
}

#[test]
Expand Down
8 changes: 6 additions & 2 deletions contracts/refund-vault/src/fuzz_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,12 @@ impl Model {
const HEADROOM_PERCENT: u64 = 15;

/// Cost baselines for `RefundVault::refund`
/// Measured via `env.cost_estimate().budget().cpu_instruction_cost()` and `env.cost_estimate().budget().memory_bytes_cost()` on 2026-08-26.
const REFUND_BASELINE_CPU: u64 = 397_721;
/// Measured via `env.cost_estimate().budget().cpu_instruction_cost()` and `env.cost_estimate().budget().memory_bytes_cost()` on 2026-08-28.
/// Re-baselined after the partial-refund, TTL-guard, reentrancy-guard and
/// oracle-policy additions grew the `refund` path (see `docs/RELEASING.md`
/// re-baselining procedure; measured with the oracle policy *unset* so the
/// value reflects the common path).
const REFUND_BASELINE_CPU: u64 = 477_714;
const REFUND_BASELINE_MEM: u64 = 131_994;

#[test]
Expand Down
Loading
Loading