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
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ min-compatible = "0.1.0"

[workspace.dependencies]
soroban-sdk = { version = "21.0.0" }
# 0.0.8 is the API the SDK is written against (`stellar_strkey::ed25519::*`
# and `stellar_strkey::Contract`); it is also the version `stellar-xdr` 21
# already pulls in, so this removes the duplicate 0.0.2 copy. The 0.0.2 pin
# only exposed `Strkey::{PublicKeyEd25519, PrivateKeyEd25519}` and had no
# `ed25519` module, so `sdk` could not compile against it (issue #5).
stellar-strkey = "0.0.8"
stellar-xdr = "21.0.0"
reqwest = { version = "0.12", features = ["json"] }
Expand Down
8 changes: 8 additions & 0 deletions contracts/escrow/src/cross_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ pub fn finalize_atomic_commit(
marker.state = AtomicCommitState::Settled;
marker.settled_ledger = Some(env.ledger().sequence());
storage::save_atomic_marker(env, &marker);

// EVENT (#978): the multi-step atomic-commit flow is only observable at the
// end; publishing here means a client watching for the final state does not
// have to poll `get_atomic_commit` after each of the four steps.
env.events().publish(
(symbol_short!("escrow"), symbol_short!("settled")),
(commission_id.clone(), env.ledger().sequence()),
);
Ok(marker)
}

Expand Down
154 changes: 154 additions & 0 deletions contracts/escrow/src/funding_event_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//! Tests for the escrow funding notification (#978).
//!
//! These assert the *additive* `escrow`/`funded` topic and the atomic-commit
//! finalization event, and — importantly — that the pre-existing
//! `escrow`/`created` payload is unchanged (it is indexed downstream).

extern crate std;
use soroban_sdk::{
contract, contractimpl, symbol_short, testutils::Address as _, testutils::Events, token,
Address, Bytes, Env, Symbol, TryFromVal, Val,
};

use crate::cross_contract::AtomicCommitState;
use crate::{EscrowContract, EscrowContractClient};

const FEE_BPS: u32 = 500;
const AMOUNT: i128 = 10_000;

/// Minimal platform-config stand-in so `create_escrow` can resolve fee/token.
#[contract]
pub struct MockConfig;

#[contractimpl]
impl MockConfig {
pub fn init(env: Env, admin: Address, usdc: Address, platform_wallet: Address) {
env.storage().instance().set(&0u32, &admin);
env.storage().instance().set(&1u32, &usdc);
env.storage().instance().set(&2u32, &platform_wallet);
}
pub fn get_adm(env: Env) -> Address {
env.storage().instance().get(&0u32).unwrap()
}
pub fn get_usdc(env: Env) -> Address {
env.storage().instance().get(&1u32).unwrap()
}
pub fn get_pw(env: Env) -> Address {
env.storage().instance().get(&2u32).unwrap()
}
pub fn get_fee_b(_env: Env) -> u32 {
FEE_BPS
}
}

struct Fixture<'a> {
env: Env,
escrow: EscrowContractClient<'a>,
config: Address,
usdc: Address,
client: Address,
artist: Address,
commission_id: Bytes,
}

fn setup<'a>() -> Fixture<'a> {
let env = Env::default();
env.mock_all_auths();

let token_admin = Address::generate(&env);
let usdc = env
.register_stellar_asset_contract_v2(token_admin.clone())
.address();
let admin = Address::generate(&env);
let client = Address::generate(&env);
let artist = Address::generate(&env);
let platform_wallet = Address::generate(&env);

token::StellarAssetClient::new(&env, &usdc).mint(&client, &1_000_000);

let config = env.register_contract(None, MockConfig);
MockConfigClient::new(&env, &config).init(&admin, &usdc, &platform_wallet);

let escrow_id = env.register_contract(None, EscrowContract);
let escrow = EscrowContractClient::new(&env, &escrow_id);

Fixture {
env,
escrow,
config,
usdc,
client,
artist,
commission_id: Bytes::from_slice(&env, b"comm-001"),
}
}

/// Return the data of the first two-topic event matching `t0`/`t1`.
fn two_topic_data(f: &Fixture, t0: &str, t1: &str) -> Option<Val> {
let want0 = Symbol::new(&f.env, t0);
let want1 = Symbol::new(&f.env, t1);
for (_contract, topics, data) in f.env.events().all().iter() {
if topics.len() != 2 {
continue;
}
let a: Symbol = Symbol::try_from_val(&f.env, &topics.get(0).unwrap()).unwrap();
let b: Symbol = Symbol::try_from_val(&f.env, &topics.get(1).unwrap()).unwrap();
if a == want0 && b == want1 {
return Some(data);
}
}
None
}

#[test]
fn create_escrow_emits_funding_event_with_expected_values() {
let f = setup();
f.escrow
.create_escrow(&f.commission_id, &f.client, &f.artist, &AMOUNT, &f.config);

let data = two_topic_data(&f, "escrow", "funded")
.expect("expected an (escrow, funded) event");
let (commission_id, funder, amount, asset, ledger): (Bytes, Address, i128, Address, u32) =
TryFromVal::try_from_val(&f.env, &data).unwrap();

assert_eq!(commission_id, f.commission_id);
assert_eq!(funder, f.client);
assert_eq!(amount, AMOUNT);
assert_eq!(asset, f.usdc);
assert_eq!(ledger, f.env.ledger().sequence());
}

#[test]
fn created_payload_is_unchanged() {
let f = setup();
f.escrow
.create_escrow(&f.commission_id, &f.client, &f.artist, &AMOUNT, &f.config);

let data =
two_topic_data(&f, "escrow", "created").expect("expected an (escrow, created) event");
let (commission_id, amount): (Bytes, i128) = TryFromVal::try_from_val(&f.env, &data).unwrap();
assert_eq!(commission_id, f.commission_id);
assert_eq!(amount, AMOUNT);
}

#[test]
fn finalize_atomic_commit_publishes_settled_event() {
let f = setup();
f.escrow
.create_escrow(&f.commission_id, &f.client, &f.artist, &AMOUNT, &f.config);

let marker = f.escrow.begin_atomic_commit(&f.commission_id);
assert_eq!(marker.state, AtomicCommitState::InProgress);

f.escrow.confirm_atomic_step(&f.commission_id, &f.client);
f.escrow.confirm_atomic_step(&f.commission_id, &f.artist);

let final_marker = f.escrow.finalize_atomic_commit(&f.commission_id);
assert_eq!(final_marker.state, AtomicCommitState::Settled);

let data = two_topic_data(&f, "escrow", "settled")
.expect("expected an (escrow, settled) event on finalization");
let (commission_id, ledger): (Bytes, u32) = TryFromVal::try_from_val(&f.env, &data).unwrap();
assert_eq!(commission_id, f.commission_id);
assert_eq!(ledger, f.env.ledger().sequence());
}
17 changes: 16 additions & 1 deletion contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,11 +311,24 @@ impl EscrowContract {
&client, &env.current_contract_address(), &amount,
);

// EVENT
// EVENT — additive funding notification (#978). The existing
// `created` topic and payload are unchanged; `funded` carries the
// extra facts (funder, asset, ledger) a client needs to notify an
// artist without polling `get_escrow`.
env.events().publish(
(symbol_short!("escrow"), symbol_short!("created")),
(commission_id.clone(), amount),
);
env.events().publish(
(symbol_short!("escrow"), symbol_short!("funded")),
(
commission_id.clone(),
client.clone(),
amount,
usdc_token.clone(),
env.ledger().sequence(),
),
);
correlation_publish(&env, "escrow", symbol_short!("created"), &commission_id, &commission_id);
Ok(())
})
Expand Down Expand Up @@ -1038,3 +1051,5 @@ mod atomic_flow_tests;
mod correlation_tests;
#[cfg(test)]
mod invariant_tests;
#[cfg(test)]
mod funding_event_tests;
41 changes: 41 additions & 0 deletions docs/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ Emitted by `contracts/escrow` during payment custody lifecycle.
| `amount` | `i128` | Total locked token amount (in stroops) |
| `fee_bps` | `u32` | Platform fee in basis points (e.g. 500 = 5%) |

#### `escrow` / `funded`
* **Trigger:** `create_escrow`
* **Condition:** Successful lock of client token funds into escrow storage. Emitted alongside `created`, which is **unchanged**; the `created` payload stays `(commission_id, amount)` and `funded` is the additive topic carrying the extra funding facts (#978).
* **Payload:**

| Field | Type | Description |
|---|---|---|
| `commission_id` | `Bytes` | Unique commission identifier |
| `funder` | `Address` | Address that funded the escrow (the client) |
| `amount` | `i128` | Total locked token amount (in stroops) |
| `asset` | `Address` | USDC token contract that was transferred |
| `ledger` | `u32` | Ledger sequence at which funding was recorded |

#### `escrow` / `released`
* **Trigger:** `release_payment`
* **Condition:** Client authorizes full release of funds to artist; net payout and fee transfers succeed.
Expand Down Expand Up @@ -150,6 +163,34 @@ Emitted by `contracts/escrow` during payment custody lifecycle.
| `client_amount` | `i128` | Amount returned to client |
| `artist_amount` | `i128` | Amount paid to artist |

#### `escrow` / `settled`
* **Trigger:** `finalize_atomic_commit`
* **Condition:** Final step of the multi-transaction atomic-commit flow, once every participant has confirmed. Emitted so a client watching the final state does not have to poll `get_atomic_commit` after each of the four steps (#978). `atomic_escrow_to_commission` keeps its existing `escrow`/`released` event and its `corr`-topic `settled` correlation event; this two-topic `settled` is the additive no-poll marker on the stepwise path.
* **Payload:**

| Field | Type | Description |
|---|---|---|
| `commission_id` | `Bytes` | Unique commission identifier |
| `ledger` | `u32` | Ledger sequence at which the commit settled |

#### State-transition audit (#978)

Every escrow state transition now publishes a topic, with the funding gap closed here:

| Transition | Function | Event |
|---|---|---|
| create + fund | `create_escrow` | `escrow`/`created` + `escrow`/`funded` |
| partial release | `partial_release` | `escrow`/`partial` |
| full release | `release_payment` | `escrow`/`released` |
| auto release | `auto_release_on_deadline` | `escrow`/`autorls` |
| refund | `refund_client` | `escrow`/`refunded` |
| dispute open | `open_dispute` | `escrow`/`disputed` |
| dispute resolve | `release_payment` / `refund_client` (arbiter decides the winner) | `escrow`/`released` or `escrow`/`refunded` |
| cancel | `cancel_escrow` | `escrow`/`cancelled` |
| expire | `expire_escrow` | `escrow`/`expired` |
| atomic commit finalize | `finalize_atomic_commit` | `escrow`/`settled` (new) |
| atomic commit rollback | `rollback_atomic_commit` | `corr`-topic `rollback` |

#### Correlation Events (Escrow)

Cross-contract correlation points emitted by `contracts/escrow` (from
Expand Down
7 changes: 7 additions & 0 deletions docs/SDK_VERSION_COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ Every contract crate inherits this via `soroban-sdk.workspace = true`, so there
| 20.x | Untested | Predates several APIs this workspace relies on (e.g. `env.deployer().update_current_contract_wasm`, the `#[contracterror]` discriminant conventions used across every contract's error enum). Not verified; likely requires code changes, not just a version bump. |
| 22.x+ | Untested | Not yet evaluated. Before upgrading, check the SDK's own changelog for storage-layout or auth-model changes, since this workspace stores non-trivial `contracttype` enums directly as persistent-storage keys (see e.g. `contracts/withdrawal/src/lib.rs`'s `DataKey`) — any change to how the SDK encodes those would be a breaking migration, not just a recompile. |

## Related dependency pins

| Crate | Pinned | Why |
|---|---|---|
| `stellar-strkey` | `0.0.8` | The `sdk` crate is written against the 0.0.8 API — `stellar_strkey::ed25519::{PublicKey, PrivateKey}` and the `stellar_strkey::Contract` newtype. The previous `0.0.2` pin only exposed `Strkey::{PublicKeyEd25519, PrivateKeyEd25519}` and had no `ed25519` module, so `sdk` could not compile against it. `stellar-xdr` 21 already pulls in 0.0.8, so pinning to it also collapses a duplicate. |
| `ed25519-dalek` | `=2.2.0` | Used by `sdk::utils::keypair` and `sdk::setup::token_setup` to derive a public key from a secret key. |

## Why this isn't (yet) a CI version matrix

The acceptance criteria for #645 also call for adding a version matrix to CI and fuzzing/compatibility test automation. That requires actually building and testing against each candidate SDK version in a CI job — a workflow-level change, not a documentation one. This file exists so that follow-up work (or a maintainer deciding to attempt a 20.x/22.x build) starts from a written record of what's known instead of from scratch.
Expand Down
29 changes: 28 additions & 1 deletion docs/event_schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,31 @@ Emitted when a dispute is resolved.
| Field | Type | Description |
|------------|--------|-------------------------------|
| dispute_id | u64 | ID of the resolved dispute |
| winner | String | Address of the winning party |
| winner | String | Address of the winning party |

---

## EscrowFunded

Emitted when an escrow is funded (`create_escrow`). Additive topic `escrow`/`funded`
(#978); the pre-existing `escrow`/`created` payload is unchanged.

| Field | Type | Description |
|----------------|--------|-----------------------------------------------|
| commission_id | Bytes | Unique commission identifier |
| funder | String | Address that funded the escrow (the client) |
| amount | i128 | Total locked amount, in stroops |
| asset | String | USDC token contract address |
| ledger | u32 | Ledger sequence at which funding was recorded |

---

## EscrowSettled

Emitted when the multi-transaction atomic-commit flow finalizes
(`finalize_atomic_commit`), so clients need not poll `get_atomic_commit` (#978).

| Field | Type | Description |
|----------------|-------|---------------------------------------------|
| commission_id | Bytes | Unique commission identifier |
| ledger | u32 | Ledger sequence at which the commit settled |
45 changes: 44 additions & 1 deletion sdk/src/batch.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
Expand All @@ -18,14 +19,17 @@ pub enum BatchError {
EmptyBatch,
#[error("batch size exceeds maximum ({0})")]
TooLarge(usize),
/// A checkpoint store failed to persist or load batch state (#979).
#[error("batch checkpoint error: {0}")]
Checkpoint(String),
}

// ---------------------------------------------------------------------------
// Contract call descriptor
// ---------------------------------------------------------------------------

/// A single contract call to be batched.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContractCall {
/// Contract address (C... strkey).
pub contract_id: String,
Expand Down Expand Up @@ -142,6 +146,15 @@ pub enum BatchResult {
///
/// If the batch submission fails, each call is retried individually.
/// This ensures that partial progress is still possible.
///
/// # Durability
///
/// `Submit` handles an in-process failure. A *process* that dies between two
/// submissions is handled by [`crate::batch_checkpoint`], which persists the
/// pending calls and per-call outcomes to a caller-supplied store. The two are
/// complementary: [`crate::retry`] handles transient errors within a run, the
/// checkpoint handles a run that ended. `take_calls()` remains the raw drain;
/// `checkpoint()` is the resumable equivalent.
pub struct BatchBuilder {
calls: Vec<ContractCall>,
max_batch_size: usize,
Expand Down Expand Up @@ -200,6 +213,36 @@ impl BatchBuilder {
std::mem::take(&mut self.calls)
}

/// Snapshot the accumulated calls into a resumable [`BatchCheckpoint`].
///
/// Additive (#979): the checkpoint can be handed to a
/// [`BatchCheckpointStore`] so a crash no longer loses the batch. See
/// [`crate::batch_checkpoint`] for the at-least-once guarantee.
///
/// [`BatchCheckpoint`]: crate::batch_checkpoint::BatchCheckpoint
/// [`BatchCheckpointStore`]: crate::batch_checkpoint::BatchCheckpointStore
pub fn checkpoint(&self) -> crate::batch_checkpoint::BatchCheckpoint {
crate::batch_checkpoint::BatchCheckpoint::new(self.calls.clone())
}

/// Rebuild a builder from a checkpoint's pending calls.
///
/// Additive (#979). Only calls that have not already succeeded are
/// re-queued, so resuming does not re-submit completed work.
pub fn from_checkpoint(checkpoint: &crate::batch_checkpoint::BatchCheckpoint) -> Self {
let pending: Vec<ContractCall> = checkpoint
.retryable_indices()
.into_iter()
.map(|i| checkpoint.calls[i].clone())
.collect();
let max_batch_size = pending.len().max(1);
Self {
calls: pending,
max_batch_size,
metrics: Arc::new(Mutex::new(BatchMetrics::default())),
}
}

/// Group calls by contract address for transaction optimization.
///
/// Calls to the same contract can be merged into a single transaction
Expand Down
Loading
Loading