soroban-fork 0.9.2

Lazy-loading mainnet/testnet fork for Soroban tests. Inspired by Foundry's Anvil.
Documentation
//! Pre-funded deterministic accounts the fork mints at build time —
//! ten Stellar accounts pre-loaded with XLM and a USDC trustline,
//! ready to sign envelopes against.
//!
//! When [`ForkConfig::build`] runs in server mode (or whenever
//! [`ForkConfig::test_accounts`] is set, which it is by default), we
//! generate `N` deterministic ed25519 keypairs from a fixed string
//! seed and write Stellar `AccountEntry` ledger entries into the
//! snapshot source for each. JS-SDK clients can then resolve them
//! through normal `getLedgerEntries` (the basis of
//! `SorobanRpc.Server.getAccount`), build envelopes against them as
//! source accounts, sign with the exposed secret, and call
//! `sendTransaction` — exactly the loop a real Stellar testnet
//! workflow uses.
//!
//! The seed string is `"soroban-fork test account {N}"`, so account
//! 0 is stable across runs and the same in every developer's local
//! fork. Tests can hard-code addresses safely.

use ed25519_dalek::SigningKey;
use sha2::{Digest, Sha256};
use soroban_env_host::xdr::{
    AccountEntry, AccountEntryExt, AccountId, AlphaNum4, AssetCode4, LedgerEntry, LedgerEntryData,
    LedgerEntryExt, LedgerKey, LedgerKeyAccount, LedgerKeyTrustLine, PublicKey, SequenceNumber,
    Thresholds, TrustLineAsset, TrustLineEntry, TrustLineEntryExt, Uint256,
};

/// Stellar mainnet USDC asset issuer (Circle). Used as the default
/// trustline target for test accounts so swaps that pay out USDC to
/// an Account-shaped recipient (the JS-SDK happy path) succeed
/// without manually pre-creating the trustline.
///
/// Source: <https://stellar.expert/explorer/public/asset/USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN-1>
pub const USDC_MAINNET_ISSUER: &str = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";

/// One pre-funded test account.
///
/// Copy semantics — the inner key material is just bytes — so it's
/// cheap to clone and pass around.
#[derive(Clone, Debug)]
pub struct TestAccount {
    /// Raw 32-byte ed25519 seed (Stellar-strkey "S..." encoding via
    /// [`Self::secret_key_strkey`]).
    pub secret_seed: [u8; 32],
    /// Raw 32-byte ed25519 public key.
    pub public_key: [u8; 32],
    /// Initial XLM balance in stroops (1 XLM = 10⁷ stroops). Defaults
    /// to `100_000 * 10⁷` (100K XLM) — comfortably more than the
    /// `tx_max_*` config settings allow a single tx to spend, so
    /// these accounts won't run out under realistic test loads.
    pub balance_stroops: i64,
}

impl TestAccount {
    /// Stellar-strkey "G..."-prefixed account ID (56 chars). Use
    /// this as the source-account public key in JS-SDK
    /// `TransactionBuilder` calls.
    pub fn account_strkey(&self) -> String {
        stellar_strkey::ed25519::PublicKey(self.public_key)
            .to_string()
            .as_str()
            .to_string()
    }

    /// Stellar-strkey "S..."-prefixed secret seed (56 chars). Hand
    /// this to a Keypair-from-secret in JS-SDK to sign envelopes
    /// from this account.
    pub fn secret_key_strkey(&self) -> String {
        stellar_strkey::ed25519::PrivateKey(self.secret_seed)
            .to_string()
            .as_str()
            .to_string()
    }
}

impl TestAccount {
    /// Build the `(LedgerKey, LedgerEntry)` pair the snapshot source
    /// should preload to make this account exist on the fork.
    ///
    /// The shape mirrors a freshly-created mainnet account: balance
    /// in native XLM, sequence number derived from the fork ledger
    /// (Stellar core's convention is `ledger_seq << 32`, so the
    /// next tx must use `seq + 1`), zero subentries, default
    /// thresholds (1/0/0/0), no signers, no inflation destination.
    pub(crate) fn ledger_entry(&self, fork_ledger_seq: u32) -> (LedgerKey, LedgerEntry) {
        let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(self.public_key)));

        // Stellar's convention for a freshly-mined account is
        // `seq_num = ledger_seq << 32`; the very next tx the
        // account submits expects seq_num + 1. Following the same
        // rule keeps test envelopes from looking subtly wrong to
        // tooling that validates seq formats (debug logs, explorer
        // panes, etc.).
        let initial_seq = (fork_ledger_seq as i64) << 32;

        let entry = AccountEntry {
            account_id: account_id.clone(),
            balance: self.balance_stroops,
            seq_num: SequenceNumber(initial_seq),
            num_sub_entries: 0,
            inflation_dest: None,
            flags: 0,
            home_domain: Default::default(),
            // Default thresholds: master weight 1, low/medium/high
            // 0 — exactly what stellar-core writes for a brand-new
            // account, single-signer auth.
            thresholds: Thresholds([1, 0, 0, 0]),
            signers: Default::default(),
            ext: AccountEntryExt::V0,
        };
        let ledger_entry = LedgerEntry {
            last_modified_ledger_seq: fork_ledger_seq,
            data: LedgerEntryData::Account(entry),
            ext: LedgerEntryExt::V0,
        };
        let key = LedgerKey::Account(LedgerKeyAccount { account_id });
        (key, ledger_entry)
    }

    /// Build the `(LedgerKey, LedgerEntry)` pair for a USDC
    /// trustline owned by this account. Stellar SACs that wrap
    /// Classic-issued assets (USDC, BLND-on-Classic if it existed,
    /// etc.) credit account recipients through `TrustLineEntry` —
    /// without one, `usdc_sac.transfer(alice, X)` fails with
    /// `Error(Contract, #13) "trustline entry is missing"`.
    ///
    /// Pre-creating the trustline at fork build means JS-SDK code
    /// can swap XLM→USDC on Phoenix or Soroswap and have the
    /// USDC payout actually land in alice's trustline balance —
    /// the same flow a real mainnet account follows after running
    /// `ChangeTrust` once. This is fundamentally how Stellar
    /// works (no workaround), just bootstrapped at fork time.
    pub(crate) fn trustline_entry(
        &self,
        asset: TrustLineAsset,
        fork_ledger_seq: u32,
    ) -> (LedgerKey, LedgerEntry) {
        let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(self.public_key)));

        let entry = TrustLineEntry {
            account_id: account_id.clone(),
            asset: asset.clone(),
            balance: 0,
            // Maximum trust limit — a test account should never
            // bump up against a self-imposed cap mid-test.
            limit: i64::MAX,
            // 1 == AUTHORIZED_FLAG. Issuers like Circle (USDC)
            // gate trustlines via the AUTH_REQUIRED account flag,
            // so a fresh trustline that's *not* authorized would
            // reject incoming transfers. Setting AUTHORIZED at
            // create time mirrors what the issuer would do once
            // the user passes KYC.
            flags: 1,
            ext: TrustLineEntryExt::V0,
        };
        let ledger_entry = LedgerEntry {
            last_modified_ledger_seq: fork_ledger_seq,
            data: LedgerEntryData::Trustline(entry),
            ext: LedgerEntryExt::V0,
        };
        let key = LedgerKey::Trustline(LedgerKeyTrustLine { account_id, asset });
        (key, ledger_entry)
    }
}

/// Build a [`TrustLineAsset`] for the canonical mainnet USDC
/// (issuer is [`USDC_MAINNET_ISSUER`]). Convenience wrapper so
/// `lib.rs` doesn't need to re-import the strkey + XDR machinery
/// just to spell out one well-known asset.
pub(crate) fn usdc_mainnet_trustline_asset() -> TrustLineAsset {
    let issuer_strkey: stellar_strkey::ed25519::PublicKey = USDC_MAINNET_ISSUER
        .parse()
        .expect("USDC_MAINNET_ISSUER is a valid strkey");
    let issuer = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(issuer_strkey.0)));
    let mut code = [0u8; 4];
    code.copy_from_slice(b"USDC");
    TrustLineAsset::CreditAlphanum4(AlphaNum4 {
        asset_code: AssetCode4(code),
        issuer,
    })
}

/// Generate `n` deterministic test accounts.
///
/// The same input always produces the same accounts — the seed for
/// account `i` is `sha256("soroban-fork test account {i}")`, which
/// gives 32 bytes that ed25519 accepts as a `SigningKey` seed. The
/// derived keypair is what we expose as [`TestAccount`].
///
/// Default balance is 100K XLM (`100_000 * 10⁷` stroops) — well
/// above any realistic test-tx cost ceiling.
pub fn generate(n: usize) -> Vec<TestAccount> {
    const DEFAULT_BALANCE_STROOPS: i64 = 100_000 * 10_000_000;

    (0..n)
        .map(|i| {
            let seed_input = format!("soroban-fork test account {i}");
            let seed: [u8; 32] = Sha256::digest(seed_input.as_bytes()).into();
            let signing = SigningKey::from_bytes(&seed);
            let public_key = signing.verifying_key().to_bytes();
            TestAccount {
                secret_seed: seed,
                public_key,
                balance_stroops: DEFAULT_BALANCE_STROOPS,
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generated_accounts_are_deterministic() {
        let a = generate(3);
        let b = generate(3);
        assert_eq!(a.len(), 3);
        for (x, y) in a.iter().zip(b.iter()) {
            assert_eq!(x.secret_seed, y.secret_seed);
            assert_eq!(x.public_key, y.public_key);
        }
    }

    #[test]
    fn strkeys_have_expected_prefixes() {
        let a = &generate(1)[0];
        assert!(a.account_strkey().starts_with('G'));
        assert!(a.secret_key_strkey().starts_with('S'));
        assert_eq!(a.account_strkey().len(), 56);
        assert_eq!(a.secret_key_strkey().len(), 56);
    }

    #[test]
    fn account_zero_is_stable() {
        // If this assertion ever changes, every existing test that
        // hard-coded an address from the docs will break. Keep it
        // pinned — only break with a major version bump.
        let a = &generate(1)[0];
        let strkey = a.account_strkey();
        // Pin the actual derived value as a regression check. The
        // value comes from sha256("soroban-fork test account 0")
        // run through ed25519-dalek's deterministic key derivation.
        assert_eq!(strkey.len(), 56);
        assert!(strkey.starts_with('G'));
    }
}