This document details the storage architecture, data classifications, and rent cost implications for the Accensa contracts (ReceiptAnchor and RefundVault).
Soroban provides three storage classes:
- Instance: Bound to the contract instance, loads automatically, and archived as a unit.
- Persistent: Key-value entries that survive independently, requires rent, and can be restored if archived.
- Temporary: Automatically deleted after TTL expiration, cannot be restored.
| DataKey | Class | Contents | Size | Justification |
|---|---|---|---|---|
Admin |
Instance | Address (Merchant) |
Small | Required for authentication of merchant operations (anchor_batch, prune_batches). Essential state that must always be available. |
BatchCount |
Instance | u64 (Sequence) |
Small | Tracks the latest batch ID to ensure monotonic assignment. Cannot be reconstructed on-chain efficiently without full event replay. |
PrunedUpTo |
Instance | u64 (Cursor) |
Small | Maintains the lower-bound of active batches. Essential for efficient pruning iterations. |
Batch(u64) |
Persistent | BatchRecord |
~100 bytes | Holds the Merkle root, count, period, and ledger. Required for on-chain verify_receipt execution. While AnchorEvent emits this data, on-chain functions cannot read events. Must be persistent to prevent arbitrary deletion; if archived, it can be restored to prove old receipts. |
| DataKey | Class | Contents | Size | Justification |
|---|---|---|---|---|
Admin |
Instance | Address (Merchant) |
Small | Required for authentication of merchant operations (deposit, refund, withdraw, pause). |
Token |
Instance | Address (SEP-41 token contract; the USDC SAC by default) |
Small | The underlying asset contract address. The vault is token-agnostic — any SEP-41 token is accepted — but each vault instance is bound to exactly one token. Crucial for token transfers. |
RefundWindow |
Instance | u32 (Ledgers) |
Small | Global policy parameter determining refund eligibility. |
RefundDeadline |
Instance | u64 (Unix seconds) |
Small | Wall-clock deadline after which refund claims are rejected; 0 = no deadline. Set together with the window by propose_policy/execute_policy and consulted at claim time in refund. |
FeeBps |
Instance | u32 |
Small | Refund fee rate in basis points (0 = no fee, max 10_000); consulted at claim time by refund and updated by set_fee_bps. |
FeeRecipient |
Instance | Address (Optional) |
Small | Explicit fee collector; when unset, refund pays the fee to the merchant (admin). Set by set_fee_recipient. |
IsPaused |
Instance | bool |
Small | Emergency halt flag. Must be immediately available at all times. |
Metadata |
Instance | Reserved | Variable | Reserved for future contract configuration or metadata. |
RefundMax |
Instance | i128 |
Small | Reserved configuration for maximum allowed refund limits. |
Admins |
Instance | Reserved | Variable | Reserved for potential multi-admin expansion. |
Threshold |
Instance | Reserved | Small | Reserved for potential multi-sig or quorum thresholds. |
Refund(BytesN<32>) |
Persistent | RefundRecord |
~100 bytes | Legacy (0.1.0) single-refund record, retained read-only for migration detection. |
RefundV2(BytesN<32>) |
Persistent | RefundRecord |
~100 bytes | Tracks cumulative refunds per payment (amount, recipient, ledger). Critical to prevent replay attacks (double-refunding the same payment). If this were Temporary, it could expire and allow a second refund. If archived, it remains a tombstone that prevents re-creation until restored — see "TTL Strategy" below for why its TTL extension is sized to the configured refund window rather than a flat interval, and why the threshold passed to extend_ttl matters as much as the extension amount. |
YieldStrategy |
Persistent | Address |
Small | Address of the external yield strategy contract. Only loaded by yield-related calls (deploy_to_yield, withdraw_from_yield, harvest_yield, get_yield_info). Kept in Persistent (not Instance) storage so non-yield calls (deposit, refund, withdraw, pause) never pay the read/write byte cost of loading it (issue #131). Extended with TTL_EXTEND on every write. |
DeployedPrincipal |
Persistent | i128 |
Small | Cumulative principal deployed to the yield strategy. Only loaded by yield calls. Kept in Persistent storage to avoid loading cost on non-yield calls (issue #131). Extended with TTL_EXTEND on every write. |
HarvestedYield |
Persistent | i128 |
Small | Cumulative yield harvested from the strategy, tracked for operator withdrawal. Only loaded by yield calls. Kept in Persistent storage to avoid loading cost on non-yield calls (issue #131). Extended with TTL_EXTEND on every write. |
ReserveRatio |
Persistent | u32 (basis points) |
Small | Minimum reserve ratio in basis points (e.g. 2000 = 20%). Determines how much liquid balance must remain after yield deployment. Only loaded by yield calls. Kept in Persistent storage to avoid loading cost on non-yield calls (issue #131). Extended with TTL_EXTEND on every write. |
MaxDeployRatio |
Persistent | u32 (basis points) |
Small | Maximum deployment ratio in basis points (e.g. 8000 = 80%). Caps the total deployed principal relative to total vault value. Only loaded by yield calls. Kept in Persistent storage to avoid loading cost on non-yield calls (issue #131). Extended with TTL_EXTEND on every write. |
PendingPolicy |
Instance | PolicyProposal |
Small | A pending refund-window policy change waiting for its timelock to expire. |
ReentrancyLock |
Instance | bool |
Small | Transient guard flag set during external calls (token transfers, strategy invocations) to reject reentrant calls. |
Note: The Metadata, RefundMax, Admins, and Threshold keys are defined in the DataKey enum for future compatibility and expansion, though some may currently be inactive in the logic.
RefundVault is deliberately token-agnostic. initialize binds one instance to one token contract, and the vault never assumes anything about that token beyond SEP-41. In particular it does not assume seven decimals: all amounts (deposit, refund, withdraw) are raw integer units in the token's smallest unit, and the float-bound check compares those units directly against the vault's token balance. A 0- or 2-decimal SEP-41 token therefore behaves identically to a 7-decimal Stellar Asset Contract — the vault performs no decimal arithmetic of its own. Converting human-readable amounts into the token's smallest unit is the responsibility of the merchant and the facilitator, not the contract.
This matches the conclusion in accensa-app (the facilitator): one vault is bound to one token, so a merchant settling in multiple assets deploys one vault per asset. The full lifecycle (deposit → refund → withdraw) and the float-bound check are exercised against a non-7-decimal token in token_agnostic_tests.rs, along with the smallest unit, i128 extremes, and a refund exactly equal to the float.
Stellar uses a Time-To-Live (TTL) mechanism to manage state bloat.
TTL_EXTEND:518,400ledgers (approximately 30 days, assuming ~5 seconds per ledger).TTL_THRESHOLD:100ledgers.
Rationale:
A 30-day TTL_EXTEND ensures that actively used batches and recent refund records remain in the live state without requiring manual restoration by downstream clients. The TTL_THRESHOLD of 100 ledgers acts as a buffer to prevent rent-bumping transactions from spamming the network on every single contract call—only extending the TTL if it drops below this threshold.
Both Instance storage (which covers Admin, IsPaused, etc.) and the actively modified Persistent entries (RefundRecord, BatchRecord, yield-related keys) receive TTL extensions during mutations to keep the active working set alive.
RefundVault's RefundV2 guard is a deliberate exception to the flat TTL_THRESHOLD/TTL_EXTEND pattern above, for two reasons discovered while verifying the double-refund guard against real archival behaviour:
- A flat 30-day extension doesn't track the configured refund window.
refundonly re-checkshas()/get()on this key when it is called; nothing re-extends its TTL between calls. A merchant withrefund_window_ledgerslonger than 518,400 (or0, meaning "no time bound" —set_refund_window/initializedeliberately allow this), who issues one partial refund near the start of that window and nothing else, would have had a guard entry whose TTL could lapse well before the window itself closes — even though furtherrefundcalls against thatpayment_refare still policy-valid.refund_record_ttl_extend_toincontracts/refund-vault/src/lib.rsnow sizes the extension topaid_at_ledger + window(or the network'smax_ttl()whenwindow == 0), so the guard cannot outlive its own policy window and cannot age out while it does. TTL_THRESHOLD(100 ledgers, ~8 minutes) is below any realisticmin_persistent_entry_ttlfloor, including the SDK's own4096-ledger test default. Sinceextend_ttl(threshold, extend_to)only bumps the TTL when the entry's current remaining TTL is belowthreshold, and a freshly-written persistent entry already carries the network's floor TTL (which exceeds 100 on any real network), theextend_ttl(TTL_THRESHOLD, TTL_EXTEND)call used elsewhere in this contract is a no-op immediately afterset— the entry is left at the network floor, not atTTL_EXTEND. For theRefundV2key (and its manual top-up,extend_refund_ttl), the threshold passed is the computedextend_tovalue itself (extend_ttl(extend_to, extend_to)), so the extension actually fires whenever the entry's TTL is below what the policy requires. This is proven incontracts/refund-vault/src/test.rs(test_long_window_extends_guard_past_flat_ttl,test_zero_window_extends_guard_to_max_ttl), which fail against the old flat-threshold code and pass against the fix.
Yield-related persistent keys (YieldStrategy, DeployedPrincipal, HarvestedYield, ReserveRatio, MaxDeployRatio) follow the standard flat TTL_EXTEND/TTL_THRESHOLD pattern via the persist_yield_ttl helper. These keys are only written by yield-related entry points (set_yield_strategy, set_reserve_ratio, set_max_deploy_ratio, deploy_to_yield, withdraw_from_yield, harvest_yield), and non-yield calls (deposit, refund, withdraw, pause, admin transfer) never touch them — which is the entire point of moving them out of Instance storage (issue #131). The flat extension is sufficient here because the yield configuration is purely admin-set and does not need to track a time-bound policy window the way RefundV2 does.
This does not fully resolve the open question of whether an archived (not just aged) persistent entry fails safe (host traps on access) or fails open on the live network — docs/SECURITY_MODEL.md still flags that as verified only against this SDK's test host, which auto-heals expired entries rather than modeling a hard archival trap. What this fix removes is the case where the guard's TTL falls short of the policy window on its own, regardless of how archival itself behaves.
Persistent storage incurs rent to stay active on the Stellar network, priced at roughly 0.5 XLM per KB per year.
- BatchRecord: A single record is roughly 100 bytes (root: 32b, count: 4b, periods: 16b, overhead: ~50b).
- RefundRecord: A single record is roughly 100 bytes (address: 32b, amount: 16b, ledger: 4b, overhead: ~50b).
- Yield config keys (
YieldStrategy,DeployedPrincipal,HarvestedYield,ReserveRatio,MaxDeployRatio): Fixed set of 5 entries, ~50 bytes total. Only written by yield operations; no per-payment scaling.
Projection: If a merchant processes 10,000 payments daily, batched into chunks of 500:
- 20
BatchRecords per day = 7,300 batches per year. - Storage footprint: 7,300 * 100 bytes ≈ 730 KB.
- Rent cost for Batches: ~365 XLM per year.
If 1% of those 10,000 daily payments require refunds:
- 100
RefundRecords per day = 36,500 refunds per year. - Storage footprint: 36,500 * 100 bytes ≈ 3.65 MB.
- Rent cost for Refunds: ~1,825 XLM per year.
For millions of payments, the total archival rent costs only fractions of a cent per transaction.
When the TTL of a Persistent entry or the Instance storage expires, it falls into an Archived state.
- Archival: The data is removed from the active ledger, halting contract operations that rely on it. For
RefundVault, an archivedRefundRecordprevents verifying double-spends natively, which is why the Soroban environment fails the transaction rather than returning "not found". - Restoration: An archived entry can be restored by submitting a
RestoreFootprintoperation. Any user or agent can pay the rent to restore an archivedBatchRecordto executeverify_receiptor aRefundRecordto interact with the vault again.
The initial one-line conclusion holds true, but is now substantiated: No state can be moved to Temporary.
- Instance state is globally required for the contracts to function.
- Persistent state (
BatchRecordandRefundRecord) serve as critical audit trails and double-spend preventions that must survive indefinitely, whether active or archived.