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
44 changes: 22 additions & 22 deletions Stellar-contracts-v1/wpi-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub enum DataKey {
Allowance(Address, Address),
TotalSupply,
ProcessedDeposit(BytesN<32>),
ProcessedRedemption(BytesN<32>),
RedemptionNonce,
VolumeLimitConfig,
VolumeGeneration,
Expand Down Expand Up @@ -92,6 +93,7 @@ pub enum Error {
InvalidAmount = 10,
InvalidExpirationLedger = 11,
NoProposedAdmin = 12,
RedemptionAlreadyProcessed = 13,
}

#[contractevent]
Expand All @@ -105,6 +107,7 @@ pub struct DepositMinted {
#[contractevent]
pub struct RedemptionBurned {
#[topic]
pub redemption_id: BytesN<32>,
pub nonce: u64,
pub from: Address,
pub amount: i128,
Expand Down Expand Up @@ -137,6 +140,19 @@ pub struct VolumeLimitOverride {
pub reset_at: u64,
}

fn is_redemption_processed(env: &Env, redemption_id: &BytesN<32>) -> bool {
env.storage()
.persistent()
.get::<DataKey, bool>(&DataKey::ProcessedRedemption(redemption_id.clone()))
.unwrap_or(false)
}

fn mark_redemption_processed(env: &Env, redemption_id: &BytesN<32>) {
env.storage()
.persistent()
.set(&DataKey::ProcessedRedemption(redemption_id.clone()), &true);
}

#[contract]
pub struct WpiToken;

Expand Down Expand Up @@ -717,28 +733,6 @@ impl WpiToken {
Ok(true)
}

/// Minter-gated mint. It uses the same bridge-wide mint counter as
/// `mint_from_deposit`, so no privileged mint path bypasses the cap.
pub fn mint(env: Env, to: Address, amount: i128) -> Result<bool, Error> {
require_minter(&env);
if is_paused(&env) {
return Err(Error::Paused);
}
if amount <= 0 {
return Err(Error::InvalidAmount);
}
let balance = read_balance(&env, &to);
let supply = read_total_supply(&env);
let new_balance = balance.checked_add(amount).ok_or(Error::Overflow)?;
let new_supply = supply.checked_add(amount).ok_or(Error::Overflow)?;
if !record_bridge_volume(&env, symbol_short!("mint"), amount)? {
return Ok(false);
}
write_balance(&env, &to, new_balance);
write_total_supply(&env, new_supply);
Ok(true)
}

pub fn is_deposit_processed(env: Env, pi_deposit_id: BytesN<32>) -> bool {
is_deposit_processed(&env, &pi_deposit_id)
}
Expand Down Expand Up @@ -784,6 +778,7 @@ impl WpiToken {
from: Address,
amount: i128,
pi_destination: BytesN<32>,
redemption_id: BytesN<32>,
) -> Result<bool, Error> {
require_minter(&env);
if is_paused(&env) {
Expand All @@ -792,6 +787,9 @@ impl WpiToken {
if amount <= 0 {
return Err(Error::InvalidAmount);
}
if is_redemption_processed(&env, &redemption_id) {
return Err(Error::RedemptionAlreadyProcessed);
}
let balance = read_balance(&env, &from);
if balance < amount {
return Err(Error::InsufficientBalance);
Expand All @@ -804,7 +802,9 @@ impl WpiToken {
let nonce = next_redemption_nonce(&env)?;
write_balance(&env, &from, balance - amount);
write_total_supply(&env, new_supply);
mark_redemption_processed(&env, &redemption_id);
RedemptionBurned {
redemption_id,
nonce,
from,
amount,
Expand Down
55 changes: 40 additions & 15 deletions Stellar-contracts-v1/wpi-token/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ fn deposit_id(env: &Env, tag: u8) -> BytesN<32> {
BytesN::from_array(env, &[tag; 32])
}

fn redemption_id(env: &Env, tag: u8) -> BytesN<32> {
BytesN::from_array(env, &[tag; 32])
}

fn setup(
env: &Env,
mint_limit: i128,
Expand Down Expand Up @@ -85,19 +89,35 @@ fn burn_limit_is_tracked_independently_and_halts_activity() {
let destination = BytesN::from_array(&env, &[9; 32]);
client.mint_from_deposit(&user, &200, &deposit_id(&env, 1));

client.burn(&user, &60, &destination);
client.burn(&user, &40, &destination);
client.burn(&user, &60, &destination, &redemption_id(&env, 1));
client.burn(&user, &40, &destination, &redemption_id(&env, 2));

assert_eq!(client.balance(&user), 100);
assert!(client.paused());
assert_eq!(client.current_volume_window().burned, 100);
assert_eq!(client.current_volume_window().minted, 200);

let blocked = client.try_burn(&user, &1, &destination);
let blocked = client.try_burn(&user, &1, &destination, &redemption_id(&env, 3));
assert_eq!(blocked, Err(Ok(Error::Paused)));
assert_eq!(client.balance(&user), 100);
}

#[test]
fn burn_replay_is_rejected_for_the_same_redemption_id() {
let env = Env::default();
let (_admin, client, user) = setup(&env, 1_000, 1_000, 86_400);
let destination = BytesN::from_array(&env, &[9; 32]);
let redemption = redemption_id(&env, 1);
client.mint_from_deposit(&user, &200, &deposit_id(&env, 1));

client.burn(&user, &60, &destination, &redemption);

let replay = client.try_burn(&user, &40, &destination, &redemption);
assert_eq!(replay, Err(Ok(Error::RedemptionAlreadyProcessed)));
assert_eq!(client.balance(&user), 140);
assert_eq!(client.total_supply(), 140);
}

#[test]
fn expired_window_resets_volume_before_next_operation() {
let env = Env::default();
Expand Down Expand Up @@ -181,12 +201,12 @@ fn non_admin_signer_cannot_authenticate_mint() {
address: &attacker,
invoke: &MockAuthInvoke {
contract: &client.address,
fn_name: "mint",
args: (&user, &1i128).into_val(&env),
fn_name: "mint_from_deposit",
args: (&user, &1i128, &deposit_id(&env, 1)).into_val(&env),
sub_invokes: &[],
},
}])
.mint(&user, &1);
.mint_from_deposit(&user, &1, &deposit_id(&env, 1));
}

#[test]
Expand Down Expand Up @@ -285,7 +305,7 @@ fn minter_role_is_independent_from_bridge_admin() {
assert_eq!(client.minter(), bridge_ops);
assert_eq!(client.admin(), admin);

client.mint(&user, &1);
client.mint_from_deposit(&user, &1, &deposit_id(&env, 1));
assert_eq!(client.balance(&user), 1);
}

Expand All @@ -304,12 +324,12 @@ fn admin_cannot_authenticate_mint_after_minter_rotation() {
address: &admin,
invoke: &MockAuthInvoke {
contract: &client.address,
fn_name: "mint",
args: (&user, &1i128).into_val(&env),
fn_name: "mint_from_deposit",
args: (&user, &1i128, &deposit_id(&env, 1)).into_val(&env),
sub_invokes: &[],
},
}])
.mint(&user, &1);
.mint_from_deposit(&user, &1, &deposit_id(&env, 1));
}

/// Mirrors `bridge_admin_cannot_authenticate_as_volume_limit_admin_after_rotation`:
Expand Down Expand Up @@ -397,7 +417,7 @@ fn upgraded_deployment_without_stored_minter_or_pauser_falls_back_to_admin() {
assert_eq!(client.minter(), admin);
assert_eq!(client.pauser(), admin);

client.mint(&user, &10);
client.mint_from_deposit(&user, &10, &deposit_id(&env, 1));
assert_eq!(client.balance(&user), 10);
client.set_paused(&true);
assert!(client.paused());
Expand Down Expand Up @@ -428,7 +448,7 @@ fn every_role_can_be_a_contract_address_not_only_an_eoa() {
assert_eq!(client.volume_limit_admin(), policy_contract);

let user = Address::generate(&env);
client.mint(&user, &10);
client.mint_from_deposit(&user, &10, &deposit_id(&env, 1));
assert_eq!(client.balance(&user), 10);
}

Expand Down Expand Up @@ -524,14 +544,19 @@ proptest! {
) {
let env = Env::default();
let (client, _admin, users, destination) = property_setup(&env);
let mut mint_tag = 1u8;

for operation in operations {
match operation {
Op::Mint(user, amount) => {
let _ = client.try_mint(&users[user as usize], &amount);
let deposit = deposit_id(&env, mint_tag);
mint_tag = mint_tag.wrapping_add(1);
let _ = client.try_mint_from_deposit(&users[user as usize], &amount, &deposit);
}
Op::Burn(user, amount) => {
let _ = client.try_burn(&users[user as usize], &amount, &destination);
let redemption = redemption_id(&env, mint_tag);
mint_tag = mint_tag.wrapping_add(1);
let _ = client.try_burn(&users[user as usize], &amount, &destination, &redemption);
}
Op::Transfer(from, to, amount) => {
let owner = users[from as usize].clone();
Expand All @@ -551,7 +576,7 @@ proptest! {
let env = Env::default();
let (client, _admin, users, _destination) = property_setup(&env);
let user = users[user_index as usize].clone();
client.mint(&user, &mint_amount);
client.mint_from_deposit(&user, &mint_amount, &deposit_id(&env, 1));
let before = client.balance(&user);

let _ = client.try_transfer(&user, &user, &transfer_amount);
Expand Down
9 changes: 7 additions & 2 deletions docs/design/on-chain-reserve-oracle.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Pi custody balance
|
| get_reserve()
v
wpi-token.mint()
wpi-token.mint_from_deposit()
checks:
total_supply + amount
<= reserve * (1 - margin)
Expand Down Expand Up @@ -95,7 +95,12 @@ Add optional oracle binding (upgrade or new initialize param):
```rust
fn set_reserve_oracle(admin: Address, oracle: Option<Address>);

fn mint(admin: Address, to: Address, amount: i128) -> Result<(), Error> {
fn mint_from_deposit(
admin: Address,
to: Address,
amount: i128,
pi_deposit_id: BytesN<32>,
) -> Result<(), Error> {
Comment on lines +98 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Match the documented mint API to the contract.

mint_from_deposit is minter-authorized, takes (to, amount, pi_deposit_id), and returns Result<bool, Error> so callers handle Ok(false) volume-limit rejections. The documented admin parameter and Result<(), Error> will mislead the follow-up implementation.

Proposed fix
 fn mint_from_deposit(
-    admin: Address,
     to: Address,
     amount: i128,
     pi_deposit_id: BytesN<32>,
-) -> Result<(), Error> {
-    // existing admin auth...
+) -> Result<bool, Error> {
+    // existing minter auth...
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn mint_from_deposit(
admin: Address,
to: Address,
amount: i128,
pi_deposit_id: BytesN<32>,
) -> Result<(), Error> {
fn mint_from_deposit(
to: Address,
amount: i128,
pi_deposit_id: BytesN<32>,
) -> Result<bool, Error> {
🤖 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 `@docs/design/on-chain-reserve-oracle.md` around lines 98 - 103, Update the
documented mint_from_deposit API to remove the admin parameter, use the contract
argument order (to, amount, pi_deposit_id), and return Result<bool, Error>.
Ensure the documentation states that callers must handle Ok(false) for
volume-limit rejections.

// existing admin auth...
if let Some(oracle) = read_oracle(&env) {
let snap = oracle_client.get_reserve();
Expand Down
10 changes: 5 additions & 5 deletions relayer/src/stellar/redemptionWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { WpiContractClient } from './wpiContractClient.js';

/**
* Watches the wPi contract for `redemption_burned` events and releases the
* corresponding native Pi via `PiPayoutClient`, deduping by the event's
* globally-unique RPC id so a burn is never paid out twice.
* corresponding native Pi via `PiPayoutClient`, deduping by the contract's
* on-chain redemption ID so a burn is never paid out twice.
*/
export class RedemptionWatcher {
constructor(
Expand All @@ -26,17 +26,17 @@ export class RedemptionWatcher {
const { events, nextLedger } = await this.contractClient.getRedemptionBurnEvents(since);

for (const event of events) {
if (this.store.hasRedemption(event.eventId)) continue;
if (this.store.hasRedemption(event.redemptionId)) continue;
this.store.upsertRedemption({
redemptionId: event.eventId,
redemptionId: event.redemptionId,
Comment on lines +29 to +31

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 legacy event-ID records before switching the dedupe key.

Records persisted by the previous watcher are keyed by RPC eventId. If an already-processed event is replayed after rollout, hasRedemption(event.redemptionId) misses that record, requeues it, and can release Pi twice. Migrate existing records or perform a legacy-key lookup during the transition.

#!/bin/bash
# Map the idempotency-store surface before inspecting persistence-key usage.
ast-grep outline relayer/src --items all --type class,interface,function --match 'Idempotency|Store'

# Locate the concrete storage implementation and any migration/compatibility logic.
rg -n -C 4 --glob '*.ts' \
  'hasRedemption\s*\(|upsertRedemption\s*\(|listRedemptionsByStatus\s*\(|eventId|redemptionId' \
  relayer/src
🤖 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 `@relayer/src/stellar/redemptionWatcher.ts` around lines 29 - 31, Update the
redemption deduplication flow around hasRedemption and upsertRedemption to
remain compatible with records keyed by the legacy RPC eventId. Before
processing a replay, migrate the existing record to redemptionId or perform a
legacy eventId lookup and treat it as already processed; ensure the
migrated/compatibility path prevents duplicate redemption release while
preserving the new redemptionId key for future records.

nonce: event.nonce,
amountStroops: event.amountStroops,
piDestination: event.piDestination,
status: 'observed',
updatedAt: new Date().toISOString(),
});
this.log.info('observed wPi redemption burn, queued for Pi release', {
redemptionId: event.eventId,
redemptionId: event.redemptionId,
piDestination: event.piDestination,
});
}
Expand Down
10 changes: 6 additions & 4 deletions relayer/src/stellar/sorobanWpiContractClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,11 @@ export class SorobanWpiContractClient implements WpiContractClient {

const events: BurnEvent[] = [];
for (const event of response.events) {
const nonceTopic = event.topic[1];
if (!nonceTopic) continue;
const nonce = Number(scValToNative(nonceTopic) as bigint);
const redemptionTopic = event.topic[1];
if (!redemptionTopic) continue;
const redemptionId = Buffer.from(scValToNative(redemptionTopic) as Buffer).toString('hex');
const data = scValToNative(event.value) as {
nonce: bigint;
from: string;
amount: bigint;
pi_destination: Buffer;
Expand All @@ -117,7 +118,8 @@ export class SorobanWpiContractClient implements WpiContractClient {
ledger: event.ledger,
txHash: event.txHash,
eventId: event.id,
nonce,
redemptionId,
nonce: Number(data.nonce),
from: data.from,
amountStroops: data.amount.toString(),
piDestination: piDestinationToStrKey(Buffer.from(data.pi_destination)),
Expand Down
7 changes: 4 additions & 3 deletions relayer/src/stellar/wpiContractClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ export interface WpiContractClient {
isDepositProcessed(depositIdHex: string): Promise<boolean>;

/**
* `redemption_burned` events emitted by `burn`, in ascending ledger order,
* starting at `sinceLedger` (inclusive). Returns the ledger to resume
* from on the next poll.
* `redemption_burned` events emitted by `burn`, keyed by their on-chain
* redemption ID and returned in ascending ledger order starting at
* `sinceLedger` (inclusive). Returns the ledger to resume from on the
* next poll.
*/
getRedemptionBurnEvents(sinceLedger: number): Promise<{ events: BurnEvent[]; nextLedger: number }>;
}
4 changes: 3 additions & 1 deletion relayer/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface BurnEvent {
txHash: string;
/** RPC-assigned globally unique event id (encodes ledger/tx/op/event order). */
eventId: string;
/** On-chain redemption ID supplied to `burn`; this is the relayer's dedupe key. */
redemptionId: string;
/** Monotonic per-contract nonce assigned by the `burn` call. */
nonce: number;
/** Stellar address that burned wPi. */
Expand All @@ -64,7 +66,7 @@ export interface BurnEvent {
export type RedemptionStatus = 'observed' | 'releasing' | 'released' | 'failed';

export interface RedemptionRecord {
/** Same as the source `BurnEvent.eventId` — already globally unique. */
/** Same as the source `BurnEvent.redemptionId` — the on-chain dedupe key. */
redemptionId: string;
nonce: number;
amountStroops: string;
Expand Down
3 changes: 2 additions & 1 deletion relayer/test/redemptionWatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function burnEvent(overrides: Partial<BurnEvent> = {}): BurnEvent {
ledger: 100,
txHash: 'stellar-tx-1',
eventId: '0000000429496729600-0000000001',
redemptionId: 'redemption-1',
nonce: 1,
from: 'GBURN',
amountStroops: '500',
Expand Down Expand Up @@ -86,7 +87,7 @@ describe('RedemptionWatcher', () => {
contract.latestLedger = 100;
const store = new MemoryStore();
store.upsertRedemption({
redemptionId: burnEvent().eventId,
redemptionId: burnEvent().redemptionId,
nonce: 1,
amountStroops: '500',
piDestination: 'GPI'.padEnd(56, 'A'),
Expand Down
Loading