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
17 changes: 17 additions & 0 deletions contracts/chainmove-pool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ This contract is prototype/testnet work only. It is not audited and must not be
- Read pool state.
- Read an investor position.

## Idempotency Keys

Funding, repayment, and refund calls take an external `reference` string used
to make retries idempotent. The storage key for that receipt is derived from
a domain/version tag, the operation kind, the pool ID, and the participant
address, then hashed to a fixed-size digest (`DataKey::ScopedReference`).
This means the same external reference can be reused safely across unrelated
pools, operations, or actors, and no one can preempt another pool/actor by
guessing or reusing its reference. A duplicate call within the same scope
(same kind, pool, actor, and reference) with matching parameters still
returns the original result; matching reference with different parameters is
rejected as `DuplicateReference`.

Receipts written before this change live under the older, unscoped
`DataKey::Reference` key. Those are still honored for exact-scope replays,
but that key is never written to going forward.

## Local Commands

From the repository root:
Expand Down
83 changes: 70 additions & 13 deletions contracts/chainmove-pool/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#![no_std]

use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Symbol,
contract, contracterror, contractimpl, contracttype, token, xdr::ToXdr, Address, BytesN, Env,
String, Symbol,
};

#[contract]
Expand Down Expand Up @@ -99,14 +100,23 @@ struct RefundBasis {
enum DataKey {
Pool(u64),
InvestorPosition(u64, Address),
// Legacy global reference key: scoped only by the raw external reference.
// Retained solely so receipts written before the scoped key existed can
// still be recognized as idempotent replays. Never written to anymore.
Reference(String),
// Reference key scoped by domain/version, operation kind, pool, and actor,
// then hashed to a fixed-size digest so an unrelated pool/operation/actor
// can never collide with (or be blocked by) another scope's reference,
// and storage cost stays bounded regardless of external reference length.
ScopedReference(BytesN<32>),
RefundBasis(u64, Address),
LegacyPool(u64), // Legacy key format for migration testing
}

const DAY_IN_LEDGERS: u32 = 17280;
const RENT_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS;
const RENT_EXTEND_TO: u32 = 30 * DAY_IN_LEDGERS;
const REFERENCE_KEY_DOMAIN: u32 = 1;

#[contractimpl]
impl ChainMovePoolContract {
Expand Down Expand Up @@ -608,6 +618,39 @@ impl ChainMovePoolContract {
}
}

fn scoped_reference_key(
env: &Env,
kind: &OperationKind,
pool_id: u64,
participant: &Address,
reference: &String,
) -> DataKey {
let scope = (
REFERENCE_KEY_DOMAIN,
kind.clone(),
pool_id,
participant.clone(),
reference.clone(),
);
let digest = env.crypto().sha256(&scope.to_xdr(env));
DataKey::ScopedReference(digest.to_bytes())
}

fn load_investor_position(
env: &Env,
pool_id: u64,
participant: Address,
) -> Result<InvestorPosition, ContractError> {
let pos_key = DataKey::InvestorPosition(pool_id, participant);
let position = env
.storage()
.persistent()
.get(&pos_key)
.ok_or(ContractError::InvestorPositionNotFound)?;
env.storage().persistent().extend_ttl(&pos_key, RENT_THRESHOLD, RENT_EXTEND_TO);
Ok(position)
}

fn read_idempotent_position(
env: &Env,
reference: &String,
Expand All @@ -616,13 +659,13 @@ fn read_idempotent_position(
participant: Address,
amount: i128,
) -> Result<Option<InvestorPosition>, ContractError> {
let key = DataKey::Reference(reference.clone());
let scoped_key = scoped_reference_key(env, &kind, pool_id, &participant, reference);
if let Some(receipt) = env
.storage()
.persistent()
.get::<DataKey, OperationReceipt>(&key)
.get::<DataKey, OperationReceipt>(&scoped_key)
{
env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO);
env.storage().persistent().extend_ttl(&scoped_key, RENT_THRESHOLD, RENT_EXTEND_TO);
if receipt.kind != kind
|| receipt.pool_id != pool_id
|| receipt.participant != participant
Expand All @@ -631,14 +674,28 @@ fn read_idempotent_position(
return Err(ContractError::DuplicateReference);
}

let pos_key = DataKey::InvestorPosition(pool_id, participant);
let position = env
.storage()
.persistent()
.get(&pos_key)
.ok_or(ContractError::InvestorPositionNotFound)?;
env.storage().persistent().extend_ttl(&pos_key, RENT_THRESHOLD, RENT_EXTEND_TO);
return Ok(Some(position));
return Ok(Some(load_investor_position(env, pool_id, participant)?));
}

// Backward-compatible read for receipts written under the old, unscoped
// global key. Only an exact match for this operation's scope counts as a
// replay; any other scope simply ignores the legacy entry instead of
// erroring, since a stale global key must never be able to block (or be
// hijacked by) an unrelated pool/operation/actor.
let legacy_key = DataKey::Reference(reference.clone());
if let Some(receipt) = env
.storage()
.persistent()
.get::<DataKey, OperationReceipt>(&legacy_key)
{
if receipt.kind == kind
&& receipt.pool_id == pool_id
&& receipt.participant == participant
&& receipt.amount == amount
{
env.storage().persistent().extend_ttl(&legacy_key, RENT_THRESHOLD, RENT_EXTEND_TO);
return Ok(Some(load_investor_position(env, pool_id, participant)?));
}
}

Ok(None)
Expand All @@ -652,7 +709,7 @@ fn write_reference(
participant: Address,
amount: i128,
) {
let key = DataKey::Reference(reference);
let key = scoped_reference_key(env, &kind, pool_id, &participant, &reference);
env.storage().persistent().set(
&key,
&OperationReceipt {
Expand Down
Loading