Version: 1.2 (corrected against actual main source + pagination)
Last Updated: September 24, 2026
Status: MVP — not audited
Change from v1.1: Added cursor-based pagination support for list-returning functions (AccountFactory batch_initialize, EphemeralAccount get_info/simulate_sweep) to prevent resource limit failures as data grows. Updated EphemeralAccount initialize signature to include
adminparameter.
- Introduction
- System Architecture
- Contract Details
- Data Flow
- Design Decisions
- Integration Points
- Limitations
Bridgelet Core is a suite of Soroban smart contracts that enable secure, single-use ephemeral accounts on the Stellar network. The system enforces business logic restrictions on temporary accounts, ensuring they can only receive tracked payments and be swept to a pre-authorized destination or expired to a recovery address.
- SDK Developers building on top of Bridgelet Core
- Security Auditors reviewing contract design
- Integration Partners connecting external systems
- Contract Maintainers working on the codebase
The workspace (Cargo.toml) declares five members — four deployable contracts plus a shared library:
contracts/
├── ephemeral_account/ # Per-transfer temporary account + state machine
├── sweep_controller/ # Signature/claim verification + sweep authorization
├── reserve_contract/ # Standalone base-reserve config store
├── account_factory/ # Batch deployer/initializer for ephemeral_account
└── shared/ # Common types (Payment, AccountStatus, AccountInfo, ...)
┌──────────────────────────────────────────────────────────────────────┐
│ Off-chain SDK / Relayer │
│ monitors payments (Horizon), signs sweep authorizations, submits │
│ transactions to invoke the contracts below │
└───────────────┬───────────────────────────┬───────────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌───────────────────────┐
│ AccountFactory │───────▶│ EphemeralAccount │
│ batch-deploys N │ deploys│ (one instance per │
│ ephemeral accounts │ │ temporary account) │
└─────────────────────┘ └───────────┬────────────┘
│ sweep() / sweep_claim()
▼
┌───────────────────────┐
│ SweepController │
│ verifies Ed25519 sig, │
│ calls back into the │
│ account to mark it │
│ swept; does NOT move │
│ tokens itself │
└───────────────────────┘
Funds sit on each ephemeral account's classic `G...` address (the sender
paid it via Horizon); after the controller marks the account swept, the
SDK performs the custodial payout with a classic Horizon payment. The
controller never calls `TokenClient` and holds no balance.
┌─────────────────────┐
│ ReserveContract │ standalone config contract — stores an
│ (admin-set base │ admin-set base reserve amount; NOT
│ reserve amount) │ currently wired into EphemeralAccount's
└─────────────────────┘ own internal reserve bookkeeping (see
Limitations)
- Monitors incoming payments via Horizon and calls
record_payment() - Holds (or has access to, via HSM/relayer) the Ed25519 private key matching
authorized_signeronSweepController - Signs sweep-authorization messages and/or submits
claim()transactions on the recipient's behalf
- Enforces single-payment-per-asset, expiry, and status transitions
- Gates
sweep()/sweep_claim()behindauthorized_controller.require_auth() - Tracks and reclaims an internal base-reserve amount on sweep/expiry
- Independently verifies Ed25519 signatures over
hash(destination + nonce + contract_id) - Enforces nonce-based replay protection
- Authorizes the sweep and marks the account swept; it does not move tokens (the SDK performs the classic Horizon payout — see Transfer Mechanism below)
- Optionally locks all sweeps to one pre-set destination address
- Simple
initialize/set_base_reserve/get_base_reserve/has_base_reserveinterface - Admin-gated writes; bounded to 100,000,000,000 stroops (10,000 XLM)
- No other contract currently reads from this contract on-chain — see Limitations
- Stores a WASM hash for
ephemeral_accountatinitialize() batch_initialize()deploys and initializes N ephemeral accounts in one transaction using deterministic salts (index-based)- Swallows individual initialization error detail (returns
success: false, error: None)
Same Soroban RPC / Horizon endpoints used across all four contracts; no contract-specific network requirements beyond standard Stellar testnet/mainnet RPC access.
Source: contracts/ephemeral_account/src/lib.rs
AccountStatus: Active (0) → PaymentReceived (1) → Swept (2), or Active → Expired (3) via expire() after expiry_ledger.
Creator, status, expiry ledger, recovery address, authorized controller, admin, per-asset payments, swept-to destination, internal reserve-tracking fields (BASE_RESERVE_STROOPS = 1_000_000_000).
fn initialize(
env: Env,
creator: Address,
expiry_ledger: u32,
recovery_address: Address,
authorized_controller: Address,
admin: Address,
) -> Result<(), Error>;
fn record_payment(env: Env, amount: i128, asset: Address) -> Result<(), Error>;
fn sweep(env: Env, destination: Address, auth_signature: BytesN<64>) -> Result<(), Error>;
fn sweep_claim(env: Env, destination: Address) -> Result<(), Error>;
fn is_expired(env: Env) -> bool;
fn expire(env: Env) -> Result<(), Error>;
fn get_status(env: Env) -> AccountStatus;
fn get_info(env: Env) -> Result<AccountInfo, Error>;
fn get_info_paginated(env: Env, params: PaginationParams) -> Result<PaginatedPaymentResponse, Error>;
fn get_payment_count(env: Env) -> u32;
fn recover(env: Env, caller: Address) -> Result<(), Error>;
fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error>;
fn simulate_sweep(env: Env, destination: Address) -> (Vec<Payment>, u32);
fn simulate_sweep_paginated(env: Env, destination: Address, params: PaginationParams) -> Result<PaginatedPaymentResponse, u32>;
fn get_reserve_remaining(env: Env) -> i128;
fn get_reserve_available(env: Env) -> i128;
fn is_reserve_reclaimed(env: Env) -> bool;
fn get_last_reserve_event(env: Env) -> Option<ReserveReclaimed>;
fn get_reserve_reclaim_event_count(env: Env) -> u32;- Checks initialized / not-swept / payment-received / not-expired.
- Calls
verify_sweep_authorization(&env, &destination, &auth_signature). - That function ignores both arguments (
_destination,_signature) and instead does:i.e. it only verifies that the call is authorized as coming from whatever address was stored aslet controller = storage::get_authorized_controller(env).ok_or(Error::Unauthorized)?; controller.require_auth(); Ok(())
authorized_controlleratinitialize()time. No Ed25519 check happens inside this contract. - Sets status to
Sweptbefore reserve reclaim (reentrancy guard). - Emits
SweepExecutedMulti, then callsreclaim_reserve_to().
Practical implication: an EphemeralAccount can only be swept by whatever address you set as authorized_controller — normally SweepController's contract address — because that address must satisfy require_auth(). The actual signature verification that makes this safe happens one layer up, in SweepController.
Identical state checks, but skips the signature path entirely: it requires controller.require_auth() directly, no signature parameter at all. Used by SweepController::claim().
AlreadyInitialized, NotInitialized, PaymentAlreadyReceived, InvalidAmount, InvalidExpiry, NotExpired, AlreadySwept, Unauthorized, InvalidSignature, NoPaymentReceived, AccountExpired, InvalidStatus, DuplicateAsset, TooManyPayments, NotUpgradeAdmin
Note: InvalidSignature (9) exists in the error enum but sweep()'s current implementation has no code path that returns it, since the signature is never actually checked in this contract.
Source: contracts/sweep_controller/src/lib.rs, authorization.rs
This is where real cryptographic authorization lives.
fn initialize(
env: Env,
creator: Address,
authorized_signer: BytesN<32>,
authorized_destination: Option<Address>,
) -> Result<(), Error>;
fn execute_sweep(
env: Env,
ephemeral_account: Address,
destination: Address,
auth_signature: BytesN<64>,
) -> Result<(), Error>;
fn claim(env: Env, recipient: Address, ephemeral_account: Address) -> Result<(), Error>;
fn can_sweep(env: Env, ephemeral_account: Address) -> bool;
fn update_authorized_destination(env: Env, new_destination: Address) -> Result<(), Error>;verify_sweep_auth() in authorization.rs:
- Builds a message:
SHA256(destination.to_xdr() ++ nonce_be_u64 ++ contract_id.to_xdr()), wherenonceis this controller's current stored sweep nonce. - Calls
env.crypto().ed25519_verify(authorized_signer, message, signature)— this traps/panics on an invalid signature (standard Soroban host-function behavior), so a failed verification aborts the transaction rather than returning anErr. - On success,
execute_sweep()increments the nonce (replay protection) before calling intoEphemeralAccount. - Uses
env.authorize_as_current_contract()with aSubContractInvocationcontext so that the downstreamEphemeralAccount::sweep()call satisfies itsauthorized_controller.require_auth()check.
The controller does not transfer tokens. sweep_account() verifies the signature and nonce, calls EphemeralAccount::sweep(), emits SweepCompleted, and returns. The recorded payments are bookkeeping only: the sender funds each ephemeral account's classic G... address via Horizon, so the contract holds no balance. The SDK's SweepsService performs the actual payout with a classic Horizon payment after the sweep is confirmed.
This is why the controller can never call TokenClient::transfer(from = <ephemeral contract>, ...): a contract must authorize its own outgoing transfer (which EphemeralAccount::sweep() never does) and the funds are not on the contract anyway. Doing so would also double-pay the SDK's payout.
recipient.require_auth() (Soroban native auth on the outer transaction) replaces the Ed25519 signature entirely; the controller then authorizes itself as invoker of EphemeralAccount::sweep_claim(). This lets a relayer submit and pay fees while only the recipient signs.
If authorized_destination was set at initialize(), every execute_sweep/claim call is checked against it (validate_destination) and can be changed via update_authorized_destination() — but only before any sweep has occurred (nonce == 0 check).
InvalidAccount, TransferFailed, AuthorizationFailed, InsufficientBalance, AccountNotReady, AccountExpired, AccountAlreadySwept, InvalidSignature, SignatureVerificationFailed, AuthorizedSignerNotSet, InvalidNonce, UnauthorizedDestination (discriminant 12 is unused/skipped — likely a removed variant; harmless in Rust but worth a cleanup pass). TransferFailed is no longer produced by any code path now that the controller does not move tokens.
Source: contracts/reserve_contract/src/lib.rs
A small, standalone admin-config contract:
fn initialize(env: Env, admin: Address) -> Result<(), Error>;
fn set_base_reserve(env: Env, amount: i128) -> Result<(), Error>; // admin-gated, bounded to 100_000_000_000 stroops
fn get_base_reserve(env: Env) -> Option<i128>;
fn require_base_reserve(env: Env) -> Result<i128, Error>;
fn has_base_reserve(env: Env) -> bool;
fn get_admin(env: Env) -> Option<Address>;Not currently integrated: EphemeralAccount computes its own reserve figures internally (BASE_RESERVE_STROOPS constant + its own storage), and nothing in the codebase has EphemeralAccount call into ReserveContract to read a live value. If the intent is for ReserveContract to become the single source of truth for the network base reserve, that cross-contract call does not exist yet.
Source: contracts/account_factory/src/lib.rs
fn initialize(env: Env, ephemeral_account_wasm_hash: BytesN<32>);
fn batch_initialize(
env: Env,
creator: Address,
requests: Vec<AccountInitRequest>,
) -> Vec<AccountInitResult>;
fn batch_initialize_paginated(
env: Env,
creator: Address,
requests: Vec<AccountInitRequest>,
params: PaginationParams,
) -> PaginatedAccountInitResultResponse;
fn batch_initialize_count(env: Env, requests: Vec<AccountInitRequest>) -> u32;Deploys a new ephemeral_account instance per request via env.deployer().with_current_contract(salt).deploy_v2(...), using an index-derived salt, then calls try_initialize() on each. All accounts created this way get authorized_controller = creator and admin = creator (the factory passes creator for both of the last two initialize args).
Pagination: batch_initialize_paginated processes a subset of requests per call, returning a page of results with a cursor for the next page. This prevents resource limit failures for large batches.
Known gap: on a per-account failure, AccountInitResult.error is hardcoded to None (see inline comment: "In a real implementation, we'd serialize errors"). Callers can detect success: false but not the cause.
Not wired into tooling: not built by scripts/build.sh, not deployed by scripts/deploy-testnet.sh, not covered by either CI workflow.
creator → EphemeralAccount::initialize() (or via AccountFactory::batch_initialize() for many at once) → account is Active.
SDK observes inbound payment via Horizon → calls record_payment(amount, asset) → status becomes PaymentReceived.
SDK/relayer builds hash(destination ++ nonce ++ sweep_controller_address), signs with the private key matching authorized_signer → calls SweepController::execute_sweep(ephemeral_account, destination, signature) → controller verifies signature, authorizes itself as invoker, calls EphemeralAccount::sweep() → controller reads get_info() and emits SweepCompleted (no token transfer) → EphemeralAccount reclaims its internal reserve tracking. The SDK then pays out the funds from the ephemeral account's classic G... address via a classic Horizon payment.
Recipient signs a Soroban auth entry for SweepController::claim(recipient, ephemeral_account) → relayer submits and pays fees → controller authorizes itself as invoker of EphemeralAccount::sweep_claim() → same no-token-transfer, reserve-reclaim, then off-chain payout tail as above.
Past expiry_ledger with no sweep → anyone calls expire() (or recover()) → funds path returns to recovery_address.
Separating signature verification (SweepController) from account state (EphemeralAccount) lets the signing/authorization scheme evolve (e.g. multi-sig, threshold signatures) without touching account state logic. ReserveContract and AccountFactory are separable, optional concerns — but as shipped, ReserveContract isn't yet consumed by the others, so the separation hasn't paid off in this MVP.
Why Soroban native require_auth() for the controller gate, instead of doing the Ed25519 check inside EphemeralAccount itself?
Keeps signature-scheme changes isolated to SweepController. Trade-off: anyone reading only ephemeral_account/src/lib.rs in isolation could reasonably (and incorrectly) assume auth_signature is verified there — it isn't, and that's the single most important thing to communicate to new contributors or auditors.
Because one SweepController deployment can service many ephemeral accounts (that's the point of the split), replay protection has to live wherever the signature is actually checked — SweepController — not on the individual account.
- Monitor payments via Horizon, call
record_payment(). - Hold/access the private key matching
SweepController'sauthorized_signer, or drive theclaim()gas-free path instead. - Never call
EphemeralAccount::sweep()directly from SDK code with an ad hoc signature — it isn't verified there; always go throughSweepController.
- Event indexing for
AccountCreated,PaymentReceived/MultiPaymentReceived,SweepExecutedMulti,AccountExpired,ReserveReclaimed(fromEphemeralAccount) andSweepCompleted,DestinationAuthorized,DestinationUpdated(fromSweepController). - Payment monitoring service watching Horizon for inbound transfers to each ephemeral account address.
- No independent signature check inside
EphemeralAccount. All cryptographic authorization is centralized inSweepController. If you ever callEphemeralAccountdirectly (bypassingSweepController), theauth_signatureparameter is decorative. ReserveContractis unintegrated. It exists, builds, and has admin-gated setters, but nothing reads from it on-chain yet;EphemeralAccountmaintains its own separate, hardcoded reserve constant.AccountFactoryis undocumented in tooling. Not built, deployed, or tested by any script or CI workflow in this repo as committed.- Batch-creation error detail is dropped.
AccountFactory::batch_initializereports failure but not cause per account. - CI is currently non-functional as committed.
test.ymlis fully commented out;deploy-testnet.ymlruns validation only — its actual deploy/artifact/summary steps are commented out. See the rootREADME.mdfor details. scripts/deploy-testnet.shhas a live bug: it references$RESERVE_CONTRACT_IDin its final output/artifact-writing steps but never assigns it (the script doesn't deployreserve_contractat all), and withset -euo pipefailthis will abort with an unbound-variable error rather than silently omitting the value.- Not audited. No
docs/security-audit.mdexists yet ("coming soon" per the previous README); treat this MVP accordingly before moving real value through it.