A comprehensive testing and developer utilities library for Soroban smart contracts.
What this is: The community-facing equivalent of what Hardhat/Foundry did for Ethereum — a well-maintained, open-source collection of test helpers, mock environments, and assertion utilities purpose-built for Soroban.
Why it exists: Soroban introduced genuinely novel concepts — especially state expiration (ledger entries that don't persist indefinitely) — with almost no tooling or guidance. This library fixes that gap.
npm install soroban-test-utils
# or
yarn add soroban-test-utils| Module | What it does |
|---|---|
SorobanTestClient |
Deploy, invoke, and read state on testnet/mainnet/futurenet |
MockLedger |
In-memory ledger for fast, offline unit tests — no network |
assertResult() |
Fluent assertions on invoke/simulation results |
assertContractData() |
Assert on contract state and expiration |
ExpirationSimulator |
Test expiration edge cases deterministically, offline |
ExpirationAnalyzer |
Analyze live TTL state via RPC |
ResourceProfiler |
Track and compare resource usage across invocations |
ledgersToTime() |
Convert ledger counts ↔ human time |
NETWORK, LEDGERS_PER_DAY, ... |
All Soroban protocol constants in one place |
import { SorobanTestClient, assertResult, assertContractData } from 'soroban-test-utils';
const client = await SorobanTestClient.create('testnet');
// Fund a fresh keypair via Friendbot
const deployer = await client.generateFundedKeypair();
// Deploy your contract
const deployed = await client.deploy({
wasm: './target/wasm32-unknown-unknown/release/my_token.wasm',
deployer,
});
// Invoke a method
const result = await client.invoke<bigint>({
contractId: deployed.contractId,
method: 'balance',
args: [deployer.publicKey()],
caller: deployer,
});
// Fluent assertions
assertResult(result)
.toReturnBigInt(0)
.toHaveEmittedNoEvents()
.toUseFewerThanCpuInstructions(5_000_000)
.toBeWithinResourceLimits(80);
// Assert on contract state
const entry = await client.getContractData(
deployed.contractId,
{ tag: 'Balance', address: deployer.publicKey() },
'persistent'
);
assertContractData(entry)
.toExist()
.toHaveValue(0n)
.toHaveLedgersRemaining(1_000, await client.getCurrentLedger());No network. No RPC. No waiting. Just a predictable in-memory state machine.
import { MockLedger, assertContractData, LEDGERS_PER_DAY } from 'soroban-test-utils';
const CONTRACT = 'CTEST...';
it('balance persists across 30 days', () => {
const ledger = new MockLedger({ startingSequence: 1_000 });
ledger.setContractData(CONTRACT, 'BALANCE', 5_000n, 'persistent');
ledger.advanceLedgers(LEDGERS_PER_DAY * 30);
assertContractData(ledger.getContractData(CONTRACT, 'BALANCE'))
.toExist()
.toHaveValue(5_000n);
});
it('temporary session token expires', () => {
const ledger = new MockLedger({ startingSequence: 1_000 });
ledger.setContractData(CONTRACT, 'SESSION', 'token_abc', 'temporary', {
expiresAt: 1_050,
});
ledger.advanceLedgers(60); // now at 1060, past expiry
assertContractData(ledger.getContractData(CONTRACT, 'SESSION'))
.toBeExpiredBy(ledger.sequence);
});Soroban is the only smart contract platform where ledger entries expire. Every piece of contract storage has a TTL. This trips up developers coming from Ethereum.
There are three key concepts:
| Concept | Temporary | Persistent |
|---|---|---|
| Can expire? | ✅ Yes | ✅ Yes |
| Can be restored after expiry? | ❌ Never | ✅ Within restore window |
| Default min TTL | ~85 seconds | ~4 months |
import { ExpirationSimulator, ledgersToTime, MIN_TEMPORARY_TTL } from 'soroban-test-utils';
const sim = new ExpirationSimulator(100_000);
// Scenario: temporary entry cliff — gone forever after expiry
const cliff = sim.temporaryExpiryCliff('CTEST', 'SESSION_TOKEN', MIN_TEMPORARY_TTL);
console.log(cliff.wasAlive); // true
console.log(cliff.isGoneForever); // true
console.log(cliff.canRestore); // false — NEVER for temporary entries
// Scenario: persistent entry within restore window
sim.reset(100_000);
const restore = sim.persistentRestoreWindow('CTEST', 'USER_PROFILE', 200);
console.log(restore.wasRestoredSuccessfully); // true
console.log(restore.newExpiresAt); // extended by MIN_PERSISTENT_TTL
// Scenario: TTL bump keeps an entry alive
sim.reset(100_000);
const bump = sim.ttlBumpKeepsAlive('CTEST', 'CRITICAL_STATE', {
ttl: 500,
bumpAt: 300,
bumpBy: 5_000,
checkAfter: 2_000,
});
console.log(bump.isAliveAtEnd); // trueimport { ledgersToTime, timeToLedgers, LEDGERS_PER_DAY } from 'soroban-test-utils';
ledgersToTime(17);
// { seconds: 85, minutes: 1.4, hours: 0.02, days: 0.001, description: '~1.4 minute(s)' }
timeToLedgers({ days: 7 }); // → 120960 ledgers
timeToLedgers({ hours: 1 }); // → 720 ledgersimport { buildExpirationScenario } from 'soroban-test-utils';
const { ledger, contractId, entries } = buildExpirationScenario(50_000);
// entries.alive — healthy, long-lived
// entries.expiringSoon — expires within 1 hour
// entries.temporary — short-lived temporary entry
// entries.expiredRestorable — expired but within restore window
// entries.expiredGone — expired and past restore windowimport { SorobanTestClient, ExpirationAnalyzer } from 'soroban-test-utils';
const client = await SorobanTestClient.create('testnet');
const analyzer = new ExpirationAnalyzer(client);
// Check if an entry will expire within 7 days
const willExpire = await analyzer.willExpireWithinTime(
contractId, 'USER_DATA', { days: 7 }
);
// Get a human-readable description
const desc = await analyzer.describeExpiration(contractId, 'USER_DATA');
// → "⚠️ EXPIRING SOON: Expires at ledger 123456 (~2.5 hour(s) remaining, 1800 ledgers)."Track resource consumption across invocations and catch regressions before they hit production.
import { ResourceProfiler, SorobanTestClient } from 'soroban-test-utils';
const client = await SorobanTestClient.create('testnet');
const profiler = new ResourceProfiler();
const v1 = await client.simulate({ contractId: 'COLD_ADDR', method: 'transfer', args: [] });
profiler.record('transfer_v1', v1.resources);
const v2 = await client.simulate({ contractId: 'CNEW_ADDR', method: 'transfer', args: [] });
profiler.record('transfer_v2', v2.resources);
// Print a detailed report
console.log(profiler.report('transfer_v1'));
// Resource Profile: transfer_v1
// ─────────────────────────────────────────────────────
// CPU Instructions 1,000,000 [█░░░░░░░░░░░░░░░░░░░] 1%
// Memory Bytes 500,000 [░░░░░░░░░░░░░░░░░░░░] 1%
// ...
// Compare v1 vs v2
const diff = profiler.compare('transfer_v1', 'transfer_v2');
console.log(diff.summary);
// Comparison: transfer_v1 → transfer_v2
// Verdict: ✅ IMPROVED
// ✅ CPU Instructions 1,000,000 → 800,000 (-20%)
// ➖ Memory Bytes 500,000 → 500,000 (0%)
// ...
// Assert v2 stays under 80% of protocol limits (safe for CI)
profiler.assertWithinLimits('transfer_v2', 80);Chainable fluent assertions on InvokeResult or SimulationResult.
assertResult(result)
// Return value
.toReturnValue(42n)
.toReturnBigInt(42) // accepts number, converts to bigint
.toBeTruthy()
.toBeEmpty()
.toSatisfy(v => v > 0n)
.not.toReturnValue(0n) // negation
// Simulation-specific
.toSucceed()
.toFail('insufficient balance')
// Events
.toHaveEmittedEvent({ topics: ['transfer', 'alice'] })
.toHaveEmittedEventCount(2)
.toHaveEmittedNoEvents()
// Resources
.toUseFewerThanCpuInstructions(5_000_000)
.toUseFewerThanMemoryBytes(1_000_000)
.toWriteFewerThanBytes(10_000)
.toPayFewerThanStroops(5_000)
.toBeWithinResourceLimits(80); // all resources under 80% of protocol limitsassertContractData(entry)
.toExist()
.toNotExist()
.toHaveValue(1_000n)
.toExpireAfterLedger(currentLedger)
.toBeExpiredBy(someKnownLedger)
.toHaveLedgersRemaining(1_000, currentLedger);// In jest.setup.ts
import { registerJestMatchers } from 'soroban-test-utils';
registerJestMatchers();
// In tests
expect(result).toReturnSorobanValue(42n);
expect(result).toHaveEmittedSorobanEvent({ topics: ['transfer'] });
expect(result).toHaveCpuInstructionsBelow(5_000_000);import {
NETWORK, // { TESTNET, MAINNET, FUTURENET, STANDALONE }
RPC_URLS, // { TESTNET, MAINNET, FUTURENET }
LEDGER_CLOSE_TIME_SECONDS, // 5
LEDGERS_PER_MINUTE, // 12
LEDGERS_PER_HOUR, // 720
LEDGERS_PER_DAY, // 17_280
LEDGERS_PER_WEEK, // 120_960
MIN_TEMPORARY_TTL, // 17 ledgers (~85 seconds)
MAX_TEMPORARY_TTL, // 17_280 ledgers (~1 day)
MIN_PERSISTENT_TTL, // ~4 months in ledgers
MAX_PERSISTENT_TTL, // ~1 year in ledgers
PERSISTENT_RESTORE_WINDOW_LEDGERS,// ~7 days
STROOPS_PER_XLM, // 10_000_000
MAX_CPU_INSTRUCTIONS, // 100_000_000
MAX_MEMORY_BYTES, // 41_943_040 (40 MB)
MAX_LEDGER_READ_BYTES, // 200_000
MAX_LEDGER_WRITE_BYTES, // 66_560
} from 'soroban-test-utils';const ledger = new MockLedger({ startingSequence: 1_000 });
// Ledger control
ledger.advanceLedgers(500);
ledger.advanceTo(2_000);
ledger.sequence; // → current ledger
ledger.timestamp; // → current Unix timestamp
// Contract data
ledger.setContractData(contractId, key, value, 'persistent', { expiresAt: 5_000 });
ledger.getContractData(contractId, key);
ledger.deleteContractData(contractId, key);
ledger.bumpContractDataTTL(contractId, key, 1_000); // extend by 1000 ledgers
ledger.restoreContractData(contractId, key); // persistent only
ledger.getContractEntries(contractId); // all entries for a contract
// Expiration analysis
ledger.getExpiredEntries();
ledger.getEntriesExpiringWithin(1_000);
ledger.expirationReport(); // human-readable text report
// Account mocking
ledger.setAccount(publicKey, { balance: 10_000_000_000n, sequence: 1n });
ledger.getAccount(publicKey);
// Event log
ledger.emitEvent(contractId, topics, data);
ledger.getEvents(contractId);
ledger.clearEvents();
// Reset
ledger.reset({ startingSequence: 1_000 });const client = await SorobanTestClient.create('testnet');
// or: SorobanTestClient.create({ rpcUrl: '...', network: NETWORK.STANDALONE });
// Account management
await client.fundAccount(publicKey);
const keypair = await client.generateFundedKeypair();
// Contract lifecycle
const deployed = await client.deploy({ wasm, deployer, salt?, initArgs? });
const result = await client.invoke({ contractId, method, args?, caller, fee? });
const simResult = await client.simulate({ contractId, method, args? });
// State inspection
const ledger = await client.getCurrentLedger();
const info = await client.getLedgerInfo();
const entry = await client.getContractData(contractId, key, 'persistent');
const entries = await client.getLedgerEntries([xdrKeyBase64]);| Feature | soroban-test-utils | Stellar CLI | Lab 4.0 (SDF internal) |
|---|---|---|---|
| Offline unit tests | ✅ MockLedger | ❌ | ❌ |
| State expiration testing | ✅ ExpirationSimulator | ❌ | planned |
| Fluent assertions | ✅ assertResult | ❌ | ❌ |
| Resource profiling | ✅ ResourceProfiler | limited | planned |
| Jest integration | ✅ | ❌ | ❌ |
| TypeScript-first | ✅ | partial | unknown |
| Open source, community | ✅ | ✅ | ❌ |
PRs welcome. Key areas for contribution:
- More mock entry types — trustlines, claimable balances
- Snapshot testing — assert that state hasn't changed between runs
- Gas estimation utilities — predictive fee budgeting
- Multi-contract interaction mocks — cross-contract call simulation
- CLI tool —
soroban-test profile <wasm>for quick resource profiling
MIT