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
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ warnings = "deny"
[workspace.lints.clippy]
all = "warn"


[profile.dev]
overflow-checks = true

[profile.release]
opt-level = "z"
Expand Down
33 changes: 19 additions & 14 deletions DEPLOYMENTS.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Deployments

Every Accensa contract deployment is recorded here with its contract ID and the
transaction that created it, so anyone can verify the deployment independently
without trusting this repository.
Every Accensa contract deployment is recorded here with its contract ID and
provenance, so anyone can verify the deployment independently without trusting
this repository.

Machine-readable values live in [`deployments/testnet.env`](deployments/testnet.env)
and are produced by [`deploy.sh`](deploy.sh).
Machine-readable values live in [`deployments/<network>.env`](deployments/) and
are produced by [`deploy.sh`](deploy.sh).

---

## Testnet

Expand Down Expand Up @@ -52,12 +54,12 @@ Deployed 2026-07-22 with `soroban-sdk` 27.0.0, built for `wasm32v1-none`.
| Initialize `RefundVault` | [`5c77fc34…`](https://stellar.expert/explorer/testnet/tx/5c77fc346943f56e10fc3666f4640211d721c1754886f107aac9fa696897662e) |
| Anchor batch #1 | [`99d0481b…`](https://stellar.expert/explorer/testnet/tx/99d0481bf2b4a00b51f1ca7c3e633d8675dc84ede8eefc6804a00686ff7b8c9a) |

## Verifying the live deployment yourself
### Verifying the live testnet deployment yourself

Batch #1 is anchored on-chain over four demo receipts. Its Merkle root was computed
off-chain by the TypeScript SDK (`packages/sdk` in
[`accensa-app`](https://github.com/accensa/accensa-app)) and verified on-chain by
`ReceiptAnchor.verify_receipt` — the two implementations agree on the same
Batch #1 is anchored on-chain over four demo receipts. Its Merkle root was
computed off-chain by the TypeScript SDK (`packages/sdk` in
[`accensa-app`](https://github.com/accensa/accensa-app)) and verified on-chain
by `ReceiptAnchor.verify_receipt` — the two implementations agree on the same
sorted-pair SHA-256 convention.

Read the anchored batch:
Expand Down Expand Up @@ -154,10 +156,13 @@ exactly the same: `ReceiptAnchor.initialize` with the same account):
## Redeploying

```bash
./deploy.sh # testnet, identity "deployer"
NETWORK=futurenet ./deploy.sh # another network
TOKEN=<usdc-sac-id> ./deploy.sh # settle refunds in USDC instead of XLM
./deploy.sh # testnet (default), identity "deployer"
NETWORK=futurenet ./deploy.sh # another network
TOKEN=<usdc-sac-id> ./deploy.sh # settle refunds in USDC instead of XLM

# Pubnet — requires clean working tree, main branch, and explicit confirmation:
NETWORK=pubnet TOKEN=<mainnet-usdc-sac-id> ./deploy.sh
```

The script writes `deployments/<network>.env`. Commit that file so the record
The script writes `deployments/<network>.env`. Commit that file so the record
stays reproducible.
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,52 @@ If a `BatchRecord` or `RefundRecord` is archived, it must be restored by submitt

For a complete breakdown of what is stored, why it is persistent, and the rent cost implications, read the [Storage Audit](docs/storage-audit.md).

## Amount Semantics

All `RefundVault` amounts are **integer token base units** (`i128`).
No floating-point arithmetic is used anywhere in the contract.

### 7-decimal Stellar assets

Stellar assets such as USDC and native XLM use **7 decimal places**:

| Unit | Base units |
|---|---|
| 1 stroop (smallest) | `1` |
| 1 token | `10_000_000` |
| 5 USDC | `50_000_000` |

Worked example — refunding 5 USDC:

```
5 USDC × 10_000_000 = 50_000_000 base units
```

The contract stores and transfers exactly `50_000_000` as an `i128`.

### RefundMax

`RefundMax` is a **reserved storage key** (`DataKey::RefundMax` in `lib.rs`)
that is not currently set, read, or enforced by any contract function.
The `AmountExceedsMax` error (code 11) is defined but unreachable from the
`refund` path today.

When implemented, `RefundMax` would be an `i128` value in the same integer
base units as all other amounts — e.g., `10_000_000` for a 1-token limit
on a 7-decimal asset.

### refund_window_ledgers

`refund_window_ledgers` is denominated in **Stellar ledgers**, not seconds.
The testnet deployment uses `17_280`:

```
17_280 ledgers × ~5 seconds/ledger ≈ 86_400 seconds ≈ 24 hours
```

This is an **approximate** wall-clock duration because ledger close times
vary. Setting `0` disables the window entirely (no expiry).

## Live on Testnet

| Contract | ID |
Expand Down
181 changes: 181 additions & 0 deletions contracts/refund-vault/src/fuzz_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,187 @@ proptest! {
failures.join("\n")
);
}

#[test]
fn test_fuzz_refund_i128_boundaries(
amount in prop_oneof![
Just(0i128),
Just(1i128),
Just(-1i128),
Just(i128::MIN),
Just(i128::MIN + 1),
Just(i128::MAX),
Just(i128::MAX - 1),
proptest::num::i128::ANY,
]
) {
let (env, client, merchant, _token) =
setup(100);
client.deposit(&merchant, &100);

let payment_ref =
BytesN::from_array(&env, &[0u8; 32]);
let buyer = Address::generate(&env);
let res = client.try_refund(
&payment_ref, &buyer, &amount, &0,
);

if amount <= 0 {
assert_eq!(
res, Err(Ok(Error::InvalidAmount))
);
} else if amount > 100 {
assert_eq!(
res,
Err(Ok(Error::InsufficientFloat))
);
} else {
assert!(res.is_ok());
}
}

#[test]
fn test_fuzz_deposit_i128_boundaries(
amount in prop_oneof![
Just(0i128),
Just(1i128),
Just(-1i128),
Just(i128::MIN),
Just(i128::MIN + 1),
Just(i128::MAX),
Just(i128::MAX - 1),
proptest::num::i128::ANY,
]
) {
let (_, client, merchant, _) = setup(100);
let res = client.try_deposit(
&merchant, &amount,
);

if amount <= 0 {
assert_eq!(
res, Err(Ok(Error::InvalidAmount))
);
} else if amount > FLOAT {
assert!(res.is_err());
} else {
assert!(res.is_ok());
}
}
}

// ── Accounting invariant fuzz test ─────────────────────────────────────────

#[derive(Debug, Clone)]
enum VaultOp {
Deposit(i128),
Refund(i128),
Withdraw(i128),
}

proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]

#[test]
fn test_fuzz_vault_accounting_invariant(
ops in prop::collection::vec(
prop_oneof![
(1i128..100_000).prop_map(VaultOp::Deposit),
(1i128..1_000).prop_map(VaultOp::Refund),
(1i128..1_000).prop_map(VaultOp::Withdraw),
],
0..30,
)
) {
let (env, client, merchant, token) =
setup(100_000_000);
let token_client = TokenClient::new(
&env, &token,
);

let mut total_deposits: i128 = 0;
let mut total_refunds: i128 = 0;
let mut total_withdrawals: i128 = 0;
let mut refund_counter: u32 = 0;

for op in ops {
match op {
VaultOp::Deposit(amount) => {
if token_client.balance(&merchant)
>= amount
{
if client
.try_deposit(
&merchant, &amount,
)
.is_ok()
{
total_deposits += amount;
}
}
}
VaultOp::Refund(amount) => {
let mut pr = [0u8; 32];
pr[..4].copy_from_slice(
&refund_counter.to_le_bytes(),
);
refund_counter = refund_counter
.wrapping_add(1);
let payment_ref =
BytesN::from_array(&env, &pr);
let buyer =
Address::generate(&env);
if client
.try_refund(
&payment_ref,
&buyer,
&amount,
&0,
)
.is_ok()
{
total_refunds += amount;
}
}
VaultOp::Withdraw(amount) => {
if client
.try_withdraw(
&amount, &merchant,
)
.is_ok()
{
total_withdrawals += amount;
}
}
}
}

let vault_balance = token_client
.balance(&client.address);

// Invariant 1: vault float is non-negative.
prop_assert!(
vault_balance >= 0,
"vault balance must be >= 0, got {}",
vault_balance,
);

// Invariant 2: without yield, vault balance
// equals net flow through the contract.
prop_assert_eq!(
vault_balance,
total_deposits
- total_refunds
- total_withdrawals,
"vault balance ({}) must equal \
deposits ({}) - refunds ({}) \
- withdrawals ({})",
vault_balance,
total_deposits,
total_refunds,
total_withdrawals,
);
}
}

// ── Regression corpus ──────────────────────────────────────────────────────
Expand Down
31 changes: 31 additions & 0 deletions contracts/refund-vault/src/yield_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -854,3 +854,34 @@ fn test_existing_deposit_refund_withdraw_still_works() {
vault_client.withdraw(&200_000, &merchant);
assert_eq!(tc.balance(&vault_client.address), 4_680_000);
}

// ── i128 overflow boundary tests (issue #57) ───────────────────────────────

#[test]
#[should_panic]
fn test_deploy_to_yield_i128_multiplication_overflow() {
// The deploy_to_yield path computes:
// total_value = token_balance + deployed - harvested
// reserve_required = total_value * reserve_ratio / 10_000
//
// If total_value is large enough and reserve_ratio > 0, the
// multiplication overflows i128. overflow-checks = true catches
// this as a panic, preventing silent wrapping.
let (env, vault_client, merchant, _token, _strategy, _tc) = setup_with_strategy(10_000, 10_000);

// Mint a huge amount and deposit it so total_value is enormous.
let huge: i128 = i128::MAX / 10;
let sac = StellarAssetClient::new(
&env,
&env.storage()
.instance()
.get::<_, Address>(&crate::DataKey::Token)
.unwrap(),
);
sac.mint(&merchant, &huge);
vault_client.deposit(&merchant, &huge);

// total_value = huge, reserve_ratio = 10_000 (100 %).
// huge * 10_000 overflows i128.
vault_client.deploy_to_yield(&1);
}
Loading
Loading