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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The workspace contains **four** contracts, not two or three as earlier drafts of
| Contract | Purpose |
|---|---|
| `ephemeral_account` | Enforces single-payment, expiry, and sweep-authorization state machine for a temporary account |
| `sweep_controller` | Validates Ed25519-signed sweep authorization and executes SEP-41 token transfers |
| `sweep_controller` | Validates Ed25519-signed sweep authorization and marks the account swept; the SDK performs the custodial payout |
| `reserve_contract` | Stores/serves the network base-reserve amount (admin-set config value) used by `ephemeral_account` when reclaiming reserve |
| `account_factory` | Batch-deploys and initializes many `ephemeral_account` instances in one transaction |

Expand All @@ -25,7 +25,7 @@ The workspace contains **four** contracts, not two or three as earlier drafts of
|----------|----------|--------|-------|
| `verify_sweep_authorization` | `EphemeralAccount` | **Not a real signature check** | Ignores the `auth_signature` argument entirely (parameter is prefixed `_`). Authorization instead comes from `authorized_controller.require_auth()` - i.e. it trusts whichever address was set as the controller at `initialize()`. Calling `sweep()` directly (not via `SweepController`) will fail `require_auth` for anyone who isn't that controller, but it performs **no cryptographic verification of the signature itself**. |
| `verify_sweep_auth` | `SweepController` | **Fully implemented** | Real Ed25519 verification (`env.crypto().ed25519_verify`) over `hash(destination + nonce + contract_id)`, with nonce-based replay protection. |
| `execute_transfers` | `SweepController` | **Fully implemented** | Calls SEP-41 `TokenClient::transfer()` for every recorded payment. |
| `sweep_account` | `SweepController` | **Fully implemented** | Verifies the signature/nonce, authorizes and marks the account swept, emits `SweepCompleted`. Does **not** move tokens - the SDK performs the custodial payout via Horizon. |
| `batch_initialize` | `AccountFactory` | **Implemented, error detail dropped** | On per-account init failure it returns `error: None` instead of the actual error - see `lib.rs` comment `"In a real implementation, we'd serialize errors"`. Caller can see *that* an account failed but not *why*. |

### Implementation Notes
Expand Down
33 changes: 9 additions & 24 deletions contracts/sweep_controller/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
mod authorization;
mod errors;
mod storage;
mod transfers;

mod ephemeral_account_contract {
soroban_sdk::contractimport!(
Expand All @@ -18,7 +17,7 @@ use soroban_sdk::{
};

use authorization::AuthContext;
use bridgelet_shared::{AccountStatus, Payment};
use bridgelet_shared::AccountStatus;
pub use errors::Error;

#[contract]
Expand Down Expand Up @@ -76,7 +75,6 @@ impl SweepController {
/// # Errors
/// Returns Error::AuthorizationFailed if signature is invalid
/// Returns Error::InvalidAccount if account is not in valid state
/// Returns Error::TransferFailed if token transfer fails
/// Returns Error::UnauthorizedDestination if destination doesn't match authorized destination (when set)
pub fn execute_sweep(
env: Env,
Expand Down Expand Up @@ -182,28 +180,15 @@ impl SweepController {
return Err(Error::AccountNotReady);
}

// Execute the actual token transfers for all recorded payments.
// No token transfer happens here. The controller only validates state
// and authorizes the sweep; the funds live on the ephemeral account's
// classic `G...` address (the sender paid it via Horizon), not on this
// contract. The SDK performs the custodial payout afterwards with a
// classic Horizon payment. Any SEP-41 transfer from here would both be
// unauthorized (the contract never authorizes its own outgoing
// transfer) and double-pay the SDK's payout.
//
// info.payments yields ephemeral_account_contract::Payment (the
// contractimport!-generated type) — structurally identical to
// bridgelet_shared::Payment but a distinct Rust type, since
// contractimport! derives its own types from the wasm's interface
// metadata rather than reusing the shared crate. Convert explicitly
// field-by-field; transfers::execute_transfers expects the
// bridgelet_shared version.
let mut payments_vec = Vec::new(env);
for payment in info.payments.iter() {
payments_vec.push_back(Payment {
asset: payment.asset.clone(),
amount: payment.amount,
timestamp: payment.timestamp,
});
}

transfers::execute_transfers(env, &ephemeral_account, &destination, &payments_vec)
.map_err(|_| Error::TransferFailed)?;

// Emit sweep completed event after successful transfer.
// Emit sweep completed event after the account state transition succeeds.
emit_sweep_completed(env, ephemeral_account, destination, amount);

Ok(())
Expand Down
34 changes: 0 additions & 34 deletions contracts/sweep_controller/src/transfers.rs

This file was deleted.

133 changes: 121 additions & 12 deletions contracts/sweep_controller/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use soroban_sdk::{
testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation},
Address, BytesN, Env, IntoVal,
};
use sweep_controller::{Error, SweepController, SweepControllerClient};
use sweep_controller::{SweepController, SweepControllerClient};

fn generate_test_keypair(env: &Env) -> (BytesN<32>, BytesN<64>) {
let public_key = BytesN::from_array(
Expand Down Expand Up @@ -81,9 +81,11 @@ fn setup_ready_account(
);

let asset_id = Address::generate(&env);
env.mock_all_auths_allowing_non_root_auth();
// `record_payment` is bookkeeping only and requires no authorization, so
// call it directly. The claim in each test below then runs with only the
// explicit `mock_auths` for the recipient - no blanket auth mocking that
// could hide a missing authorization in the sweep path.
ephemeral_client.record_payment(&100, &asset_id);
env.set_auths(&[]);

(controller_client, ephemeral_client, ephemeral_id)
}
Expand Down Expand Up @@ -111,34 +113,67 @@ fn test_initialize_prevents_double_init() {
assert!(result.is_err());
}

/// Test that valid signatures are accepted
/// An invalid signature must be rejected by the sweep path even when every
/// other authorization is provided explicitly. No `mock_all_auths()` is used
/// here, so a missing authorization would surface as a failed test.
#[test]
fn test_execute_sweep_with_valid_signature() {
fn test_execute_sweep_rejects_invalid_signature() {
let env = Env::default();
env.mock_all_auths();

let _creator = Address::generate(&env);
// Deploy and initialize controller
// Deploy and initialize controller (creator authorizes `initialize` only).
let controller_id = env.register(SweepController, ());
let controller_client = SweepControllerClient::new(&env, &controller_id);

let creator = Address::generate(&env);
let (authorized_signer, _) = generate_test_keypair(&env);
controller_client.initialize(&creator, &authorized_signer, &None);
controller_client
.mock_auths(&[soroban_sdk::testutils::MockAuth {
address: &creator,
invoke: &soroban_sdk::testutils::MockAuthInvoke {
contract: &controller_id,
fn_name: "initialize",
args: (&creator, &authorized_signer, &None::<Address>).into_val(&env),
sub_invokes: &[],
},
}])
.initialize(&creator, &authorized_signer, &None);

// Deploy ephemeral account
// Deploy ephemeral account.
let ephemeral_id = env.register(EphemeralAccountContract, ());
let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id);

// Setup
let creator = Address::generate(&env);
let account_creator = Address::generate(&env);
let recovery = Address::generate(&env);
let destination = Address::generate(&env);
let _asset = Address::generate(&env);
let expiry = env.ledger().sequence() + 1000;

// Initialize ephemeral account, authorizing this SweepController to call sweep()
ephemeral_client.initialize(&creator, &expiry, &recovery, &controller_id, &creator);
ephemeral_client
.mock_auths(&[soroban_sdk::testutils::MockAuth {
address: &account_creator,
invoke: &soroban_sdk::testutils::MockAuthInvoke {
contract: &ephemeral_id,
fn_name: "initialize",
args: (
&account_creator,
&expiry,
&recovery,
&controller_id,
&account_creator,
)
.into_val(&env),
sub_invokes: &[],
},
}])
.initialize(
&account_creator,
&expiry,
&recovery,
&controller_id,
&account_creator,
);

// Create an invalid signature (all zeros - different from valid signature)
let invalid_sig = BytesN::from_array(&env, &[0u8; 64]);
Expand Down Expand Up @@ -211,6 +246,80 @@ fn test_claim_succeeds_with_recipient_auth_and_relayable_flow() {
assert_eq!(info.swept_to, Some(recipient));
}

/// The claim path must not proceed when the controller is not the account's
/// `authorized_controller`. Only the recipient's authorization is mocked here,
/// so the missing controller authorization has to fail the test.
#[test]
fn test_claim_fails_when_account_not_bound_to_controller() {
let env = Env::default();

let controller_id = env.register(SweepController, ());
let controller_client = SweepControllerClient::new(&env, &controller_id);

let creator = Address::generate(&env);
let (authorized_signer, _) = generate_test_keypair(&env);
controller_client
.mock_auths(&[soroban_sdk::testutils::MockAuth {
address: &creator,
invoke: &soroban_sdk::testutils::MockAuthInvoke {
contract: &controller_id,
fn_name: "initialize",
args: (&creator, &authorized_signer, &None::<Address>).into_val(&env),
sub_invokes: &[],
},
}])
.initialize(&creator, &authorized_signer, &None);

let ephemeral_id = env.register(EphemeralAccountContract, ());
let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id);

let account_creator = Address::generate(&env);
let recovery = Address::generate(&env);
let other_controller = Address::generate(&env);
let expiry = env.ledger().sequence() + 1_000;
ephemeral_client
.mock_auths(&[soroban_sdk::testutils::MockAuth {
address: &account_creator,
invoke: &soroban_sdk::testutils::MockAuthInvoke {
contract: &ephemeral_id,
fn_name: "initialize",
args: (
&account_creator,
&expiry,
&recovery,
&other_controller,
&account_creator,
)
.into_val(&env),
sub_invokes: &[],
},
}])
.initialize(
&account_creator,
&expiry,
&recovery,
&other_controller,
&account_creator,
);

let asset_id = Address::generate(&env);
ephemeral_client.record_payment(&100, &asset_id);

let recipient = Address::generate(&env);
controller_client.mock_auths(&[soroban_sdk::testutils::MockAuth {
address: &recipient,
invoke: &soroban_sdk::testutils::MockAuthInvoke {
contract: &controller_client.address,
fn_name: "claim",
args: (&recipient, &ephemeral_id).into_val(&env),
sub_invokes: &[],
},
}]);

let result = controller_client.try_claim(&recipient, &ephemeral_id);
assert!(result.is_err());
}

#[test]
fn test_claim_records_recipient_authorization_context() {
let env = Env::default();
Expand Down
10 changes: 5 additions & 5 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ fn record_payment(env: Env, amount: i128, asset: Address) -> Result<(), Error>

#### `sweep`

Marks the account as swept and authorizes fund transfers to `destination`. All recorded payments are included. The actual token transfers are executed by `SweepController` after this call completes.
Marks the account as swept and records `destination` as the payout target. All recorded payments are included. No token transfer happens here or in `SweepController`: the sender funds the ephemeral account's classic `G...` address via Horizon and the SDK performs the classic payment payout afterwards.

```rust
fn sweep(
Expand Down Expand Up @@ -389,7 +389,7 @@ fn simulate_sweep_paginated(env: Env, destination: Address, params: PaginationPa

## SweepController Contract

Orchestrates sweep authorization using Ed25519 signature verification and executes atomic token transfers.
Orchestrates sweep authorization using Ed25519 signature verification and authorizes the ephemeral account's sweep. It does not move tokens itself; the SDK performs the custodial payout.

### Functions

Expand Down Expand Up @@ -428,7 +428,7 @@ fn initialize(

#### `execute_sweep`

Verifies the Ed25519 authorization signature, then calls `EphemeralAccount::sweep()` and executes the token transfers to `destination`.
Verifies the Ed25519 authorization signature, then calls `EphemeralAccount::sweep()` and emits `SweepCompleted`. It does **not** transfer tokens: the funds live on the ephemeral account's classic `G...` address, not on any contract, and the SDK performs the classic Horizon payout afterwards.

```rust
fn execute_sweep(
Expand Down Expand Up @@ -456,7 +456,6 @@ fn execute_sweep(
| `AuthorizedSignerNotSet` | Ed25519 public key has not been stored. |
| `SignatureVerificationFailed` | Signature does not verify against the current nonce and destination. |
| `AccountNotReady` | Ephemeral account has no recorded payments or zero total amount. |
| `TransferFailed` | A SEP-41 token `transfer()` call failed. |

**Signature message format:**

Expand Down Expand Up @@ -631,7 +630,8 @@ fn example_multi_asset(
ephemeral.record_payment(&100_000_000, usdc_addr);
ephemeral.record_payment(&5_000_000_000, xlm_addr); // 500 XLM in stroops

// Sweep transfers ALL recorded assets atomically
// Sweep marks the account swept for ALL recorded assets; the SDK pays the
// funds out from the ephemeral account's classic G... address afterwards.
controller.execute_sweep(ephemeral_id, destination, &auth_sig);
}
```
Expand Down
Loading
Loading