Skip to content
Merged
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
36 changes: 36 additions & 0 deletions Stellar-contracts-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,42 @@ remain available. Only the authorized pauser can change the pause state; for a
volume-limit halt, governance must use the auditable override flow described
below before activity can resume.

### Storage TTL / rent model

`wpi-token` deliberately splits storage by lifecycle:

| Data | Storage class | TTL strategy |
|---|---|---|
| `Balance(owner)`, `Allowance(owner, spender)`, processed deposit IDs, processed redemption IDs | Persistent | Each write extends that entry's TTL independently |
| `Admin`, `ProposedAdmin`, `Minter`, `Pauser`, `VolumeLimitAdmin`, `Paused`, `CircuitBreaker`, `TotalSupply`, volume-window bookkeeping | Instance | The contract refreshes instance TTL on writes; operators must still bump it during long idle periods |

This matters because Soroban `instance()` storage has a single shared TTL for
the whole contract instance. If it is allowed to age out, every value stored in
that namespace becomes unavailable together. User-owned balances and allowances
therefore live in `persistent()` storage instead, so one stale account can no
longer drag every holder over the same expiry cliff.
Comment on lines +102 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document individual persistent-entry expiry and recovery.

Persistent storage avoids the shared instance cliff, but balances and allowances still expire after their own TTL if they are not written. State the renewal period, that reads do not renew it, and the required archival/restore and rent-funding procedure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Stellar-contracts-v1/README.md` around lines 102 - 106, Update the README
section explaining persistent() storage to document that each balance and
allowance entry has its own expiry, specify the renewal period and that reads do
not renew TTL, and describe the required archival/restore and rent-funding
procedure for expired entries.


For quiet periods, run an automated keeper or cron job that calls the admin-only
`bump_instance_ttl` entrypoint before the shared instance TTL gets close to
expiry. Example:

```bash
stellar contract invoke \
--id "$WPI_CONTRACT_ID" \
--source "$ADMIN_IDENTITY" \
--network testnet \
-- \
bump_instance_ttl
```

Recommended operations policy:

- Trigger `bump_instance_ttl` on a schedule comfortably inside the rent window
even if no bridge traffic is flowing.
- Monitor the contract instance TTL off-chain and alert well before expiry.
- Keep rent funding and the admin signer used for this keeper path under the
same operational controls as upgrade governance.

### Configure the wPi bridge volume circuit breaker

`wpi-token` fails closed: mint and burn calls return
Expand Down
2 changes: 1 addition & 1 deletion Stellar-contracts-v1/target/.rustc_info.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"rustc_fingerprint":6167044578903928928,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.88.0 (6b00bc388 2025-06-23)\nbinary: rustc\ncommit-hash: 6b00bc3880198600130e1cf62b8f8a93494488cc\ncommit-date: 2025-06-23\nhost: x86_64-pc-windows-msvc\nrelease: 1.88.0\nLLVM version: 20.1.5\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\pc\\.rustup\\toolchains\\1.88.0-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"6027984484328994041":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\nlib___.a\n___.dll\nC:\\Users\\pc\\.rustup\\toolchains\\1.88.0-x86_64-pc-windows-msvc\noff\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}}
{"rustc_fingerprint":9382291209947408725,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\Godsm\\.rustup\\toolchains\\1.88.0-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.88.0 (6b00bc388 2025-06-23)\nbinary: rustc\ncommit-hash: 6b00bc3880198600130e1cf62b8f8a93494488cc\ncommit-date: 2025-06-23\nhost: x86_64-pc-windows-msvc\nrelease: 1.88.0\nLLVM version: 20.1.5\n","stderr":""}},"successes":{}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Remove generated compiler metadata from version control.

This target/.rustc_info.json file embeds the local Windows path C:\Users\Godsm\... and toolchain environment, making the repository environment-specific while exposing developer-identifying data. Remove it from the PR and ensure target/ is ignored.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Stellar-contracts-v1/target/.rustc_info.json` at line 1, Remove the generated
target/.rustc_info.json artifact from version control and update the repository
ignore configuration to exclude the entire target/ directory, preventing future
Rust compiler metadata and local environment paths from being committed.

108 changes: 89 additions & 19 deletions Stellar-contracts-v1/wpi-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@

use soroban_sdk::{
contract, contracterror, contractevent, contractimpl, contracttype, symbol_short, Address,
BytesN, Env, Symbol,
BytesN, Env, IntoVal, Symbol, Val,
};

const NAME: &str = "Wrapped Pi";
const SYMBOL: &str = "wPI";
const LEDGERS_PER_DAY: u32 = 17_280;
const PERSISTENT_ENTRY_TTL_THRESHOLD: u32 = 30 * LEDGERS_PER_DAY;
const PERSISTENT_ENTRY_TTL_EXTEND_TO: u32 = 180 * LEDGERS_PER_DAY;
const INSTANCE_TTL_THRESHOLD: u32 = 7 * LEDGERS_PER_DAY;
const INSTANCE_TTL_EXTEND_TO: u32 = 30 * LEDGERS_PER_DAY;
/// Pi Network is an SCP fork of Stellar and exposes the same Horizon REST
/// API. Native Pi amounts use 7 decimal places (1 Pi = 10_000_000 stroops),
/// identical to Stellar's native asset convention.
Expand Down Expand Up @@ -148,14 +153,31 @@ fn is_redemption_processed(env: &Env, redemption_id: &BytesN<32>) -> bool {
}

fn mark_redemption_processed(env: &Env, redemption_id: &BytesN<32>) {
env.storage()
.persistent()
.set(&DataKey::ProcessedRedemption(redemption_id.clone()), &true);
let key = DataKey::ProcessedRedemption(redemption_id.clone());
env.storage().persistent().set(&key, &true);
bump_persistent_ttl(env, &key);
Comment on lines +156 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Do not let replay-protection markers expire.

A processed ID is written once and then ages out after 180 days. Once absent, an old deposit can be minted again and an old redemption can be replayed. Use replay state that remains enforceable for the bridge’s full replay horizon, such as an ordered source-chain checkpoint/nonce design; periodic per-ID renewal is not sufficient at scale.

  • Stellar-contracts-v1/wpi-token/src/lib.rs#L156-L158: store redemption replay state with durable replay semantics.
  • Stellar-contracts-v1/wpi-token/src/lib.rs#L390-L392: store deposit replay state with the same durable replay semantics.
📍 Affects 1 file
  • Stellar-contracts-v1/wpi-token/src/lib.rs#L156-L158 (this comment)
  • Stellar-contracts-v1/wpi-token/src/lib.rs#L390-L392
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Stellar-contracts-v1/wpi-token/src/lib.rs` around lines 156 - 158, Replace
the expiring per-ID replay markers in the redemption path around
DataKey::ProcessedRedemption and the deposit path at
Stellar-contracts-v1/wpi-token/src/lib.rs:390-392 with durable replay
protection, such as an ordered source-chain checkpoint/nonce scheme that remains
enforceable for the full replay horizon. Update both sites consistently; do not
rely on periodic per-ID TTL renewal or expiring persistent storage.

}

#[contract]
pub struct WpiToken;

fn bump_instance_storage_ttl(env: &Env) {
env.storage()
.instance()
.extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO);
}

fn bump_persistent_ttl<K>(env: &Env, key: &K)
where
K: IntoVal<Env, Val>,
{
env.storage().persistent().extend_ttl(
key,
PERSISTENT_ENTRY_TTL_THRESHOLD,
PERSISTENT_ENTRY_TTL_EXTEND_TO,
);
}

fn read_admin(env: &Env) -> Address {
env.storage()
.instance()
Expand Down Expand Up @@ -196,6 +218,7 @@ fn write_volume_limit_admin(env: &Env, admin: &Address) {
env.storage()
.instance()
.set(&DataKey::VolumeLimitAdmin, admin);
bump_instance_storage_ttl(env);
}

fn read_minter(env: &Env) -> Address {
Expand All @@ -218,6 +241,7 @@ fn require_minter(env: &Env) -> Address {

fn write_minter(env: &Env, minter: &Address) {
env.storage().instance().set(&DataKey::Minter, minter);
bump_instance_storage_ttl(env);
}

fn read_pauser(env: &Env) -> Address {
Expand All @@ -240,10 +264,12 @@ fn require_pauser(env: &Env) -> Address {

fn write_pauser(env: &Env, pauser: &Address) {
env.storage().instance().set(&DataKey::Pauser, pauser);
bump_instance_storage_ttl(env);
}

fn write_admin(env: &Env, admin: &Address) {
env.storage().instance().set(&DataKey::Admin, admin);
bump_instance_storage_ttl(env);
}

fn read_proposed_admin(env: &Env) -> Option<Address> {
Expand All @@ -256,10 +282,12 @@ fn write_proposed_admin(env: &Env, proposed: &Address) {
env.storage()
.instance()
.set(&DataKey::ProposedAdmin, proposed);
bump_instance_storage_ttl(env);
}

fn remove_proposed_admin(env: &Env) {
env.storage().instance().remove(&DataKey::ProposedAdmin);
bump_instance_storage_ttl(env);
}

fn is_paused(env: &Env) -> bool {
Expand All @@ -271,6 +299,7 @@ fn is_paused(env: &Env) -> bool {

fn set_paused(env: &Env, paused: bool) {
env.storage().instance().set(&DataKey::Paused, &paused);
bump_instance_storage_ttl(env);
}

fn is_circuit_breaker_active(env: &Env) -> bool {
Expand All @@ -284,26 +313,35 @@ fn set_circuit_breaker(env: &Env, active: bool) {
env.storage()
.instance()
.set(&DataKey::CircuitBreaker, &active);
bump_instance_storage_ttl(env);
}

fn read_balance(env: &Env, address: &Address) -> i128 {
let key = DataKey::Balance(address.clone());
env.storage()
.instance()
.get::<DataKey, i128>(&DataKey::Balance(address.clone()))
.persistent()
.get::<DataKey, i128>(&key)
.or_else(|| env.storage().instance().get::<DataKey, i128>(&key))
.unwrap_or(0)
}

fn write_balance(env: &Env, address: &Address, amount: i128) {
let key = DataKey::Balance(address.clone());
env.storage().persistent().set(&key, &amount);
bump_persistent_ttl(env, &key);
env.storage().instance().remove(&key);
}

fn read_allowance_data(env: &Env, owner: &Address, spender: &Address) -> Option<AllowanceData> {
let key = DataKey::Allowance(owner.clone(), spender.clone());
env.storage()
.instance()
.set(&DataKey::Balance(address.clone()), &amount);
.persistent()
.get::<DataKey, AllowanceData>(&key)
.or_else(|| env.storage().instance().get::<DataKey, AllowanceData>(&key))
Comment on lines 319 to +340

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Migrate existing account state before instance expiry.

The fallback only preserves legacy balances/allowances until the shared instance namespace expires; inactive pre-upgrade accounts are never copied to persistent storage. Provide a one-time migration/snapshot-claim path before rollout rather than relying on each holder to perform a future write.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Stellar-contracts-v1/wpi-token/src/lib.rs` around lines 319 - 340, Update the
balance and allowance migration flow around read_balance, write_balance, and
read_allowance_data to provide a one-time snapshot-claim path that copies legacy
instance entries into persistent storage before instance expiry. Ensure inactive
pre-upgrade accounts can claim their existing state without requiring a future
balance or allowance write, and make the migration idempotent while preserving
current persistent-state behavior.

}

fn read_allowance(env: &Env, owner: &Address, spender: &Address) -> i128 {
let allowance = env
.storage()
.instance()
.get::<DataKey, AllowanceData>(&DataKey::Allowance(owner.clone(), spender.clone()));
let allowance = read_allowance_data(env, owner, spender);
match allowance {
Some(data) if data.expiration_ledger >= env.ledger().sequence() => data.amount,
_ => 0,
Expand All @@ -317,13 +355,16 @@ fn write_allowance(
amount: i128,
expiration_ledger: u32,
) {
env.storage().instance().set(
&DataKey::Allowance(owner.clone(), spender.clone()),
let key = DataKey::Allowance(owner.clone(), spender.clone());
env.storage().persistent().set(
&key,
&AllowanceData {
amount,
expiration_ledger,
},
);
bump_persistent_ttl(env, &key);
env.storage().instance().remove(&key);
}

fn read_total_supply(env: &Env) -> i128 {
Expand All @@ -335,6 +376,7 @@ fn read_total_supply(env: &Env) -> i128 {

fn write_total_supply(env: &Env, amount: i128) {
env.storage().instance().set(&DataKey::TotalSupply, &amount);
bump_instance_storage_ttl(env);
}

fn is_deposit_processed(env: &Env, deposit_id: &BytesN<32>) -> bool {
Expand All @@ -345,9 +387,9 @@ fn is_deposit_processed(env: &Env, deposit_id: &BytesN<32>) -> bool {
}

fn mark_deposit_processed(env: &Env, deposit_id: &BytesN<32>) {
env.storage()
.persistent()
.set(&DataKey::ProcessedDeposit(deposit_id.clone()), &true);
let key = DataKey::ProcessedDeposit(deposit_id.clone());
env.storage().persistent().set(&key, &true);
bump_persistent_ttl(env, &key);
}

fn next_redemption_nonce(env: &Env) -> Result<u64, Error> {
Expand All @@ -360,6 +402,7 @@ fn next_redemption_nonce(env: &Env) -> Result<u64, Error> {
env.storage()
.instance()
.set(&DataKey::RedemptionNonce, &next);
bump_instance_storage_ttl(env);
Ok(next)
}

Expand All @@ -384,6 +427,7 @@ fn advance_volume_generation(env: &Env) -> Result<u32, Error> {
env.storage()
.instance()
.set(&DataKey::VolumeGeneration, &next);
bump_instance_storage_ttl(env);
Ok(next)
}

Expand Down Expand Up @@ -418,6 +462,7 @@ fn write_volume_bucket(env: &Env, bucket: &VolumeBucket) {
env.storage()
.instance()
.set(&DataKey::VolumeBucket(slot), bucket);
bump_instance_storage_ttl(env);
}

fn read_volume_window(env: &Env, config: &VolumeLimitConfig) -> Result<VolumeWindow, Error> {
Expand Down Expand Up @@ -611,6 +656,7 @@ impl WpiToken {
env.storage()
.instance()
.set(&DataKey::VolumeLimitConfig, &config);
bump_instance_storage_ttl(&env);
VolumeLimitsConfigured {
mint_limit,
burn_limit,
Expand Down Expand Up @@ -687,8 +733,16 @@ impl WpiToken {
transfer_internal(&env, &from, &to, amount)?;
let expiration_ledger = env
.storage()
.instance()
.persistent()
.get::<DataKey, AllowanceData>(&DataKey::Allowance(from.clone(), spender.clone()))
.or_else(|| {
env.storage()
.instance()
.get::<DataKey, AllowanceData>(&DataKey::Allowance(
from.clone(),
spender.clone(),
))
})
.map(|data| data.expiration_ledger)
.unwrap_or(0);
write_allowance(&env, &from, &spender, allowance - amount, expiration_ledger);
Expand Down Expand Up @@ -723,8 +777,16 @@ impl WpiToken {
}
let expiration_ledger = env
.storage()
.instance()
.persistent()
.get::<DataKey, AllowanceData>(&DataKey::Allowance(from.clone(), spender.clone()))
.or_else(|| {
env.storage()
.instance()
.get::<DataKey, AllowanceData>(&DataKey::Allowance(
from.clone(),
spender.clone(),
))
})
.map(|data| data.expiration_ledger)
.unwrap_or(0);
write_allowance(&env, &from, &spender, allowance - amount, expiration_ledger);
Expand Down Expand Up @@ -865,6 +927,14 @@ impl WpiToken {
Ok(())
}

/// Admin-only keeper hook for periods where the contract is idle and
/// instance storage would not otherwise be refreshed by normal writes.
pub fn bump_instance_ttl(env: Env) -> Result<(), Error> {
require_admin(&env);
bump_instance_storage_ttl(&env);
Ok(())
}

pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error> {
require_admin(&env);
env.deployer().update_current_contract_wasm(new_wasm_hash);
Expand Down
Loading
Loading