Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Move -> Soroban: the differential cheat sheet

For Aptos Move developers shipping Soroban contracts. Each entry is composed in the following structure:

  • What it is in Move
  • What it is in Soroban
  • Where the analogy breaks.
  • The breakage is the payload.

Legend:

  • ⚠ FALSE FRIEND: your Move instinct produces compiling, working-looking Soroban code that is wrong.
  • ∅ NO ANALOGUE: nothing on the other side, don’t force one.
  • 🔧 DIRECT SWAP: mechanical rename, low risk.

Verified against soroban-sdk 27.0.6 · rustc 1.91.1 · Aptos Move 9.4.0 Every Soroban snippet in this document compiles and, where it asserts behaviour, runs green, see Verification, which also records how this was drafted. Move snippets are illustrative and not compiled.

Version note. soroban-sdk major = Stellar protocol version (SDK 27 ↔ protocol 27). Network limits and TTL floors are set by validator vote; the numbers in §6.4 match InvocationResourceLimits::mainnet() as baked into SDK 27.0.6 (a snapshot dated 2026-07-10; the SDK itself notes these are hardcoded and require an SDK update to refresh). Check live values before relying on them: Stellar Lab network limits · stellar network settings --network mainnet · crates.io/soroban-sdk.


Quick map

#ConceptMoveFlagSoroban§
1Transaction authority&signer⚠Address param + addr.require_auth()1.1
2Caller identitysigner::address_of(s)🔧the Address param itself1.1
3Multi-party authorizationmulti-agent tx (N signers)N auth entries in one tx1.2
4Programmatic signingresource account + SignerCapabilitycustom account (__check_auth) / invoker auth1.4
5Module-to-module accessfriend / public(friend)∅∅, auth checks replace visibility1.5
6Delegated authoritycapability structs (MintCap, AdminCap…)⚠role Address in storage + require_auth2.1
7Composable asset primitiveAptos object model (Object<T>, refs)∅∅, keyed entries or factory instances2.2
8Enforced obligationhot potato (no-ability struct)∅∅, runtime checks in one invocation2.3
9Storage operationsmove_to / borrow_global / exists / move_from⚠storage().{set,get,has,remove,update}3.1
10State ownershipresources under user accountsall state under the contract3.2
11Mutable state accessborrow_global_mut writes through⚠get returns a copy; write only on set3.3
12Access declarationacquires (explicit or inferred)transaction footprint, from simulation3.4
13State lifetimestate persists once paid∅ ⚠durability tiers + TTL + archival3.5
14Type-level guaranteesabilities key/store/copy/drop∅ ⚠∅, every value is plain data4.1
15Asset representationCoin<T> value in hand⚠∅ value objects; funds move only via token calls4.2
16Type-level asset segregationgeneric entry fns, phantom params⚠monomorphic ABI; asset identity = Address4.3
17Data modellingMove 2 enums / positional structs🔧#[contracttype] enums / tuple structs4.4
18Numeric semanticssigned types since Move 2.3, but amounts are u64⚠token amount i128 is signed4.5
19Code deployment unitmodule published at an addressWasm hash (upload) + instances (deploy)5.1
20Dependency linkingcompile-time use other_module⚠runtime client to an address held in storage5.2
21Function visibilityentry / public / privateone exported namespace, all-public5.3
22Initializationinit_module(&signer)🔧__constructor(env, args…)5.4
23Upgradeabilityupgrade policy in Move.tomlupgrade entry point compiled in, or immutable by omission5.5
24Transaction compositionscripts compose calls in one tx∅exactly one invocation per tx5.6
25Failure signallingabort / assert! codes#[contracterror] + Result / panic_with_error!6.1
26Failure propagationabort always propagates∅callee errors catchable via try_ clients6.2
27Overflow safetyoverflow aborts, always⚠Rust semantics behind a profile flag4.5
28Call dispatchstatic dispatch, reentrancy impossibledynamic dispatch, host blocks reentrancy6.3
29Metering modelgas schedule, no declarationmultidimensional declared resources6.4
30Off-chain observability#[event] + event::emit⚠#[contractevent], topics vs data, ~7-day retention7
31Fungible token standardCoin / Fungible Asset standards⚠SEP-41 + Stellar Asset Contract8.1
32Non-fungible token standardDigital Asset standard (token objects + refs)⚠SEP-50 (draft), and no SAC-equivalent trustable tier8.2
33Unit testing#[test(a = @0x1)], expected_failure⚠Env testutils, mock_all_auths9
34Formal verificationMove Prover (first-party, in-language spec {})⚠no first-party tool; third-party Certora Sunbeam, Komet9
35Read-only queries#[view] + /view endpoint∅ ⚠∅ attribute, any fn via simulateTransaction; view is a call mode5.7
36Customizing token behaviourstatic dispatch + DFA escape hatch (AIP-73)⚠dynamic dispatch is the only mechanism; the token is the hook5.8
37Event access on-chainreading emitted events on-chain∅ ⚠∅ on both; write-only in production, readable in tests only7.1
38Compile-time inlininginline fun (+ lambda params)🔧∅ keyword; ordinary private fns + closures5.9
39Paying out a sharesplitting a Coin<T> is the value moving⚠arithmetic, plus a token call you can omit4.6

Contents

  1. Identity & authorization
  2. Capabilities & the object model
  3. State & storage
  4. Types & abilities
  5. Modules vs. contracts
  6. Execution semantics
  7. Events
  8. Tokens
  9. Testing & assurance
  10. Toolchain quick map
  11. Porting reflexes, the grep-able checklist
  12. Verification
  13. Primary sources

Contributing

Corrections are the most valuable contribution. Missing Move concepts are equally welcome as issues even without an answer. See CONTRIBUTING.md; All Soroban snippets must compile.

1. Identity & authorization

1.1 &signer → Address + require_auth()

Move, signer is an unforgeable capability minted only by the VM for actual transaction signers; possession is the proof of consent. It has only drop; even its scarcity is ability-encoded. Move: signer · Soroban: authorization

Soroban, there is no msg.sender and no signer type. The acting party is an ordinary Address parameter (plain, cloneable data), and consent is a separate runtime host query:

// Move: the type is the gate
public entry fun contribute(donor: &signer, amount: u64) { … }
#![allow(unused)]
fn main() {
// Soroban: the parameter is data; the gate is a call you must remember to make
pub fn contribute(env: Env, from: Address, asset: Address, amount: i128) {
    from.require_auth();   // did `from` sign THIS contract, THIS fn, THESE args, in this tx?
    // require_auth_for_args((&asset, amount).into_val(&env)) narrows the signed payload
}
}

signer::address_of(&s) 🔧 disappears; the Address is already what you have.

Where it breaks, internalize all four:

  • ⚠ FALSE FRIEND, an Address parameter proves nothing by itself. Forgetting require_auth compiles, runs, and passes naive tests. Every Address arg forces a decision: does this call spend, reduce, or reconfigure something this address owns? If yes → auth it. Reading public data / crediting a balance → no.
  • ⚠ FALSE FRIEND, who.require_auth() on a caller-supplied who proves the caller controls who, not that who is privileged. For admin paths, load the role address from storage and auth that (Move analogue: assert!(address_of(s) == @admin), same discipline, but here it’s the only line of defense).
  • Auth is a signed invocation tree, and it does not cascade. The user signs “A.foo(args), and within it B.bar(args)”. require_auth deep in the stack passes only if the actual call path matches the signed tree; each node matches at most once per tx. Refactoring your call graph changes the tree shape and silently breaks integrators signing the old shape; the tree is ABI.
  • ⚠ FALSE FRIEND (the #1 real-world Soroban auth bug), auth replay through middleware. If settle(user, amt) calls token.transfer(&user, …) but never calls user.require_auth(), anyone can call settle(victim, …) and consume victim’s pre-signed inner transfer auth. Re-auth at every layer that exercises an address’s authority.

Replay protection (nonces, expirations on auth entries) is host-managed; you never track nonces. Auth starter guide · Aptos security guidelines (signer-check discipline)

1.2 Multi-agent transactions → multiple auth entries

Move, a script/entry fn takes several &signers; all sign the one transaction. Soroban, one transaction carries multiple SorobanAuthorizationEntrys, one per authorizing Address; the contract just calls require_auth() on each party (atomic swap: a.require_auth(); b.require_auth();). Only the tx source pays fees; other parties sign auth entries, not the envelope. Move: signer (multi-signer scripts) · Soroban: authorization

1.3 Contracts as authorizing parties (invoker auth)

∅ in Move (a module is code, not a party; you mint signers for that, §1.4). In Soroban a contract address is a first-class auth party. When contract A directly calls B, B’s a_address.require_auth() passes automatically; the direct call is the authorization. That covers B’s immediate frame only; if B will make a deeper call needing A’s authority, A pre-authorizes it:

#![allow(unused)]
fn main() {
env.authorize_as_current_contract(vec![&env, InvokerContractAuthEntry::Contract(
    SubContractInvocation {
        context: ContractContext { contract: token, fn_name: symbol!("transfer"),
                                   args: (me, dest, amt).into_val(&env) },
        sub_invocations: vec![&env],
    })]);
}

This is your zakat_pool → SAC.transfer path when the pool pays out: the pool’s own address authorizes the transfer by being the direct invoker.

1.4 Resource accounts / SignerCapability → custom accounts + invoker auth

Move, a resource account is a keyless account whose SignerCapability lets a module mint its signer programmatically; the capability struct is the authority. Aptos: resource accounts

Soroban, split by what you used it for:

  • “My module acts as its own account” → free. env.current_contract_address() holds tokens and passes auth as invoker (§1.3). No capability object to store or guard.
  • “Programmable signing policy” (multisig, passkeys, spend limits) → a custom account: a contract implementing CustomAccountInterface; the host calls its __check_auth(env, signature_payload: crypto::Hash<32>, signature, auth_contexts: Vec<Context>) whenever that contract address must authorize something. Note the payload type: crypto::Hash<32>, not BytesN<32>, a host-constructed type you cannot forge in safe code, which is the type system doing the one job here it still does. Rules: verify that payload itself (verifying anything else authorizes arbitrary calls); enforce policy from auth_contexts (match on Context::Contract for the invoked contract and fn_name) or the policy is decorative; keep it lean; its cost rides on every tx the account signs. Protocol 27 (CAP-71) adds delegate_auth for modular signer contracts; delegate addresses arrive unsanitized, check registration first. Custom account example

This is the natural home for an attestation-authority quorum: the pool does attest_addr.require_auth_for_args((recipient, asset, category, amount).into_val(&env)) and the 2-of-3 policy lives in __check_auth; host-managed nonces replace a hand-rolled sequence scheme and remove its head-of-line blocking.

1.5 friend visibility → ∅, auth replaces visibility

Move, public(friend) / public(package) restrict callers at the type level. Move: friends

Soroban, every pub fn in #[contractimpl] is callable by anyone, externally and cross-contract. No friend, no package visibility. Replacements, in order of preference:

  1. Design the function to be safe for any caller (a pure policy.allocate(amount) computation needs no gate; the caller validates the result).
  2. Role auth from storage (§2.1) for privileged paths.
  3. Pin the expected caller: store contract B’s Address, and expected_caller.require_auth(); passes only when B is the direct invoker (§1.3).

Private helpers = ordinary non-pub Rust fns, or fns outside #[contractimpl].


← Cheat sheet index · 2. Capabilities & the object model →

2. Capabilities & the object model

2.1 Capability structs → role addresses + require_auth ⚠

Move, authority as a value: MintCapability, BurnCapability, your AdminCap. Unforgeability comes from the constructor being private to the defining module; possession is permission because you cannot mint one yourself. The ability set is worth reading rather than assuming: MintCapability<phantom CoinType> has copy, store is duplicable and not droppable (hence coin::destroy_mint_cap), while a hand-rolled AdminCap has store is neither. “Capabilities are linear” is already false on the Move side, before Soroban enters it.

Object and FA refs, ExtendRef, TransferRef, MintRef, BurnRef, are the same mechanic wearing the object model’s clothes: per-object rather than per-type, minted at creation instead of at module init. They decompose the same way, and carry one extra guarantee Soroban cannot reproduce (§2.2). Move: structs & resources · abilities · coin.move

Soroban, ⚠ FALSE FRIEND: there are no scarce values. Any code can construct any #[contracttype] value; a DistributorCap struct passed as an argument is trivially forgeable, and one stored by your contract grants nothing by existing; nothing checks for it. Porting the capability pattern literally produces decorative security.

The replacement is where + who, not what: authority = an Address held in your contract’s storage, consumed via require_auth:

#![allow(unused)]
fn main() {
// The whole of "DistributorCap":
fn require_distributor(env: &Env) {
    let d: Address = env.storage().instance().get(&DataKey::Distributor).unwrap();
    d.require_auth();
}
}

What you lose vs. Move: capabilities were transferable, storable, divisible values (hand a MintCap to another module, wrap it, time-lock it). The Soroban equivalents: rotate the role address (governance setter), point the role at a custom account contract encoding the fancier policy (§1.4), or split into multiple role keys, exactly the governance / distributor / attestation-authority triple.

2.2 The Aptos object model → ∅, decomposition

Move/Aptos, Object<T>: an address-identified resource group with ObjectCore (ownership, transferability), capability refs minted from ConstructorRef (ExtendRef, TransferRef, DeleteRef), objects owning objects, the substrate under FA, DA, and AIP-76 composability. Aptos: objects · creating / using · AIP-76

Soroban, no object model, and no middle layer at all: the platform has exactly contracts and flat KV entries. Aptos pulled contract-shaped things down into the data model; on Soroban you push object-shaped things up or down; each object use decomposes separately:

Object usage in AptosSoroban replacement
Identity + attached resources (state bundle)Entries under a #[contracttype] key in the managing contract, the default, and almost always right
Ownable / transferable thingowner: Address field in the entry; mutations gated by owner.require_auth()
Autonomous actor holding assetsFactory-deployed contract instance, deterministic address from (deployer, salt) via env.deployer().with_address(me, salt).deploy_v2(wasm_hash, args); deployed_address() precomputes it. The instance holds tokens and is an auth party
ConstructorRef → Extend/Transfer/DeleteRef∅ capability values. Role addresses + auth checks; “delete” = storage().remove()
Objects owning objects (AIP-76 composition)Contract addresses as owners/actors; composition is cross-contract calls, not resource attachment

Deployer example

Rule of thumb: reach for a keyed entry first; deploy an instance only when the thing must independently hold tokens or authorize.

⚠ The creation-time freeze is the part with no Soroban analogue at all. Refs can only be minted from a ConstructorRef, and struct ConstructorRef has drop, no store, so it cannot outlive the transaction that created the object. The set of refs that will ever exist is therefore fixed at creation, which makes absence a permanent, externally verifiable property: “this FA has no TransferRef” means nobody can mint one later, including the issuer, and a holder can check that before touching it.

Soroban cannot express this. The nearest construction is an admin contract with no such entry point, which is only as strong as its upgrade policy (§5.5), a property of the deployed Wasm and of whoever may replace it, not of the asset. Porting a design that leans on a deliberately absent ref means rebuilding that guarantee as immutability (no upgrade path at all) and documenting it, because the platform will not enforce it and no counterparty can verify it from the asset alone.

Note the ability sets while you are here, since they invert the classic capabilities in §2.1: ExtendRef/TransferRef/DeleteRef/DeriveRef are drop, store (storable, droppable, not copyable) and LinearTransferRef is drop alone, one-shot by construction. object.move · fungible_asset.move

2.3 Hot potato → ∅

A no-ability struct that must be consumed in the same transaction (flash-loan receipts, forced-callback patterns) has no Soroban equivalent; no abilities means no un-droppable values. Two partial substitutes: a Soroban tx is a single invocation anyway (§5.6), so “same-tx” is often structural; and obligations become explicit runtime checks before returning (assert the loan repaid, assert the invariant). Type-enforced obligations are simply gone.


← Cheat sheet index · ← 1. Identity & authorization · 3. State & storage →

3. State & storage

3.1 Global storage operators → env.storage()

MoveSorobanNotes
move_to(&s, r)storage().<tier>().set(&key, &val)no signer needed; it’s your storage (§3.2)
borrow_global<T>(a)get::<K, V>(&key) -> Option<V>owned copy, not a borrow ⚠ §3.3
borrow_global_mut<T>(a)update(&key, |cur: Option<V>| { … })the only safe read-modify-write idiom
exists<T>(a)has(&key)
move_from<T>(a)get + removeremove returns nothing; atomicity holds within the invocation anyway

Move: global storage operators · Soroban: persisting data

Keys are anything #[contracttype], always a dedicated enum:

#![allow(unused)]
fn main() {
#[contracttype] #[derive(Clone)]
pub enum DataKey {
    Governance, Distributor, PolicyAddr, AttestAddr, Paused,
    ApprovedAssets,
    Buckets(Address),           // all eight category balances for one asset, one entry
}
}

Untyped/ad-hoc keys can silently collide; the enum is the schema, and adding variants is the forward-compatible way to grow it.

3.2 Ownership inversion: state lives under the contract

Move, resources live under user accounts; your module is the API over state it doesn’t hold (Balance under each user’s address). The user pays their own storage; move_to needs their signer. Move: global storage structure

Soroban, a contract reads and writes only its own storage. “The user’s balance” is your entry Balance(Address). Consequences: no signer needed to write (auth is orthogonal, §1.1); no exists-under-user checks or per-user initialization (unwrap_or(default) replaces the register-then-use dance); the contract’s footprint is the universe; nothing of yours lives anywhere else; and you carry the growth and rent of per-user state (§3.5) rather than distributing it to users. Bounding what grows is now an architecture decision, not a billing default.

3.3 get returns a copy ⚠ FALSE FRIEND

The first-hour bug, and it produces no error of any kind:

#![allow(unused)]
fn main() {
// WRONG; compiles, reads back correctly within this call, never touches the ledger
let mut b: BucketSet = env.storage().persistent().get(&DataKey::Buckets(asset)).unwrap();
b.riqab += amount;          // mutated a deserialized local
// …no set → the write is lost
}

get deserializes an owned value out of the host; there is no borrow_global_mut, no write-through reference, and the borrow checker cannot help because nothing is borrowed. Codebase rule worth enforcing with review, not memory: get + field mutation is banned; every read-modify-write goes through update/try_update:

#![allow(unused)]
fn main() {
env.storage().persistent().update(&DataKey::Buckets(asset), |cur: Option<BucketSet>| {
    let mut b = cur.unwrap_or_default();
    b.add(category, amount);           // checked_add inside
    b
});
}

Also ⚠: #[contract] pub struct ZakatPool { … } fields do not persist; the struct is a namespace, not a resource. All state goes through env.storage().

3.4 acquires → footprints, and parallelism

Move, acquires (explicit, or inferred by the Move 2 compiler) is function-level bookkeeping of global-storage access; Block-STM discovers conflicts optimistically at runtime. Move 2 release notes

Soroban, nothing at the function level. Every transaction declares its read/write footprint up front (computed by simulation, embedded in the tx). Transactions touching the same read-write entry serialize; disjoint ones parallelize; pessimistic declaration vs. Block-STM’s optimistic detection. Design consequences:

  • Fine-grained keys are the parallelism lever. Balance(Address) per user, never one giant Map; a giant map is one entry, so every caller conflicts with every other and pays to read the whole thing.
  • Everything touched must be in the footprint whether used or not, and entries-per-tx is capped (mainnet: 200 read / 200 write). Unbounded iteration over entries is both a fee and a ceiling problem.
  • If actual state diverges from simulation (concurrent writes between simulate and submit), declared costs can be wrong → leave headroom.

3.5 Storage durability, TTL, archival; ∅

Nothing in Move prepares you for this: Aptos state persists indefinitely once paid (deposit refunded on deletion). Soroban storage is rented, in three tiers:

TierLifetimeOn expiryUse for
instance()one TTL shared with the contract instance; single entry, ≤64KB serializedarchived with the contract; restorableadmin/config/small globals; one extend_ttl bumps everything
persistent()per-key TTLarchived, restorable, and since protocol 23 auto-restored when in a tx’s read-write footprint (you re-pay rent)balances, anything that must survive
temporary()per-key TTLdeleted, permanentlycaches, quotes, inherently time-bounded data

TTL is counted in ledgers (~5s, 17,280/day). Mainnet at time of writing: new persistent entries ~120 days, temporary ~1 day, ceiling ~180 days; network-configured, check live. extend_ttl(threshold, extend_to) is floor-only and idempotent: no-op unless current TTL < threshold. Standard pattern: extend the touched entry on every write (“active users pay for their own state”), extend instance TTL at the top of busy entry points. Protocol 26 adds extend_ttl_with_limits to stop callers forcing arbitrary rent on you.

#![allow(unused)]
fn main() {
const DAY: u32 = 17_280;
env.storage().instance().extend_ttl(30 * DAY, 120 * DAY);
env.storage().persistent().extend_ttl(&DataKey::Buckets(asset), 30 * DAY, 120 * DAY);
}
  • ⚠ FALSE FRIEND, temporary() for anything load-bearing = permanent, silent data loss. Expired means gone, no restore path.
  • ⚠ FALSE FRIEND, TTL is not a security mechanism. Anyone can extend any entry’s TTL via the ExtendFootprintTTLOp transaction op, no contract auth involved. “This permission dies when its entry expires” is broken by design; store an explicit deadline in the value and check it.
  • Operational, not launch-time: max TTL ~180 days means an immutable custody contract stays alive only if a keep-alive job runs forever. That job is part of the system, with an owner and a runbook; an unowned cron is how “nothing load-bearing archives” quietly stops being true.
  • Never assume an entry you wrote is present later: unwrap_or(default) / has() where absence is meaningful.

State archival · Choosing the right storage


← Cheat sheet index · ← 2. Capabilities & the object model · 4. Types & abilities →

4. Types & abilities

4.1 Abilities → ∅, the headline difference

key/store/copy/drop did two structurally different jobs, and both are gone:

  1. Conservation. Coin<T> without copy/drop cannot be forged, duplicated, or silently discarded; the type checker was your solvency proof.
  2. Authority. signer without store/copy cannot be minted or stashed; possession was proof.

Every #[contracttype] value is plain Rust data: cloneable, droppable, constructible by anyone. Both invariants collapse into runtime checks you write and test: conservation becomes checked arithmetic plus an asserted invariant (below); authority becomes require_auth (§1) and role addresses (§2.1). This is the axis where Soroban is genuinely worse than Move; the two most expensive Soroban bug classes in the wild (missing auth, balance-accounting drift) are exactly what abilities used to prevent, and nothing warns you they’re missing.

The replacement for Coin linearity in a pool, concretely, a compiled artifact, not a comment:

#![allow(unused)]
fn main() {
fn assert_solvent(env: &Env, asset: &Address) {
    let held: i128 = token::TokenClient::new(env, asset)
        .balance(&env.current_contract_address());
    let booked: i128 = env.storage().persistent()
        .get::<_, BucketSet>(&DataKey::Buckets(asset.clone()))
        .unwrap_or_default().total();          // checked_add inside; traps on overflow
    if booked > held { panic_with_error!(env, Error::Insolvent); }  // > : surplus is legal
}
}

Call it at the end of every value-moving function, and make it the oracle for property tests (§9). Move: abilities · Soroban: custom types example

4.2 No value objects: funds never pass through your code

Move, coin::withdraw puts a Coin in your hand; you hold it, split it, merge it, deposit it. Escrow = storing the Coin in your resource.

Soroban, ⚠ there is nothing to hold. A token is an external contract keeping balances internally; “moving funds” is calling it, and the transfer completes inside that call:

#![allow(unused)]
fn main() {
token::TokenClient::new(&env, &asset)
    .transfer(&from, &env.current_contract_address(), &amount);
// no value returned; nothing in hand; your address's balance in the token contract went up
}

Escrow/custody = the token contract says your address holds N, plus your own bookkeeping of who it’s for; the eight-bucket accounting is the replacement for held Coins, which is why §4.1’s invariant is load-bearing. “Split” and “merge” are arithmetic on your books, not operations on values.

4.3 Generics → monomorphic ABI; asset = Address value

Move, Coin<USDC> and Coin<EURC> are distinct types; phantom parameters give type-level asset segregation, and mixing them is a compile error. Move: generics

Soroban, contract entry points are monomorphic; nothing generic crosses the ABI (Rust generics are fine internally). Asset identity is an Address value at runtime. Type-level segregation becomes key discipline: Buckets(Address) per asset, an approved_assets allowlist at the boundary, and per-asset invariants. ⚠ The compiler will never again catch USDC amounts credited to EURC buckets; only your keys and your tests do. (Deterministic SAC addresses help: pin the USDC/EURC addresses as reviewed constants per network rather than discovering them at runtime.)

4.4 Structs, enums, receiver functions 🔧

  • Move 2 enums → #[contracttype] enums (unit + tuple variants; the idiomatic DataKey). Adding variants is forward-compatible; reshaping existing ones breaks stored-data decoding (§5.5).
  • Positional structs → Rust tuple structs (supported by #[contracttype]).
  • Receiver-style value.method() → ordinary Rust methods internally; exported entry points are associated fns in #[contractimpl] taking Env first, no receiver syntax at the ABI.
  • Type names crossing the ABI are exported flat into the contract spec; keep them unique per contract.
  • SDK types replace std (#![no_std]): soroban_sdk::{String, Vec, Map, Bytes, BytesN, Symbol}, host-object handles, not std collections. Symbol ≤32 chars [a-zA-Z0-9_]; symbol_short! ≤9.

Move 2 release notes · docs.rs: contracttype

4.5 Integers: signedness & overflow ⚠ FALSE FRIEND ×2

Move, signed types do exist as of Move 2.3 (i8…i256 alongside u8…u256, literals like -1i8), but the money path never meets them: Coin/FA amounts are u64, so a negative amount stays unrepresentable exactly where it would hurt. And every arithmetic op, signed and unsigned alike, aborts on overflow, underflow, or divide-by-zero with no opt-out; division truncates, negating a type’s minimum aborts, and as casts abort when the value doesn’t fit. Move: primitive types · Move 2.3 release notes · abort & assert

Soroban, two independent traps:

  • ⚠ The canonical token amount is i128, signed. transfer(from, to, -1000) is a withdrawal from to if unchecked. Validate amount > 0 at every entry point taking an amount. Having i128 in the language is not the same as meeting it on the money path: Aptos amounts are u64, so no Move reflex was ever trained against this attack.
  • ⚠ Overflow safety is a build-profile flag, not a language guarantee: Rust wraps in release unless overflow-checks = true is set (the Stellar scaffold sets it; a copied Cargo.toml without it wraps silently). Use explicit checked_add/sub/mul on every money path regardless; it survives profile mistakes and fails with your typed error. U256/I256 have checked_* (protocol 26+).

4.6 The split is not the settlement ⚠ FALSE FRIEND

Move, you cannot write this bug. Splitting a payment means holding the value: coin::withdraw puts a Coin<T> in your hand, coin::extract cuts it in two, and every piece has to be deposited, merged, or explicitly destroyed before the function returns (§4.1). Computing a share and moving it are one act, because the share is the value.

Soroban, they are two unrelated acts and only one of them is enforced. The split is i128 arithmetic on plain data; the movement is a call to a token contract (§4.2) that you can simply not make. Omit it and the code compiles, the function succeeds, the state updates, the event fires, and the tests stay green.

#![allow(unused)]
fn main() {
let cut = mul_div_bps(&env, amount, bps);      // arithmetic: always runs
token::TokenClient::new(&env, &asset)          // ← delete these two lines and the
    .transfer(&payer, &beneficiary, &cut);     //   function still succeeds
Settled { beneficiary, asset, amount: cut }.publish(&env);
}
  • ⚠ An event is not a receipt. §7.1 settles that no contract can read its own events; the corollary for the reviewer is that nobody should read them as evidence of transfer either. A dashboard reporting fees paid may be reporting arithmetic. If value moved, the token contract emitted its own transfer event beside yours (§7) and the recipient’s balance changed.
  • ⚠ Auth is not payment. require_auth() proves the caller authorized this invocation, not that anything was paid for it. The Move reflex is safe by accident: the counterparty’s Coin had to arrive in the same call for the call to typecheck. The Soroban equivalent is making the transfer yourself, since TokenClient::transfer(&payer, …) re-enters the payer’s auth for that exact amount, and that is the check. A state change gated only on the beneficiary’s own require_auth is free to whoever calls it first.
  • ⚠ “The caller pays first, then invokes us” cannot be made atomic client-side. By §5.6 a Soroban transaction carries exactly one operation, so there is no payment-op-plus-invocation pairing and no multicall to fall back on. Either the transfer runs inside the invocation or the halves are separate transactions, and either half can land alone. An entry point whose doc comment explains what the caller was supposed to have already done has an unenforced precondition, not a protocol.
  • The grep: every function computing a rate, a fee, a share, or a payout should hold a token::TokenClient call in the same body. Split computed and only published ⇒ that function settled nothing.
  • The test blind spot has the same shape. mock_all_auths plus an assertion that ownership flipped passes over a payment path that never existed, and the test name will still say it enforces the fee. Assert each participant’s balance across the call; §4.1’s solvency invariant is the oracle (§9).

← Cheat sheet index · ← 3. State & storage · 5. Modules vs. contracts →

5. Modules vs. contracts

5.1 Module → Wasm hash + contract instance

Move, publishing puts module bytes at an address; code identity ≡ address; one deployment per address. Move: modules & scripts · packages

Soroban, two-level, content-addressed: upload Wasm once (identified by hash, deduplicated network-wide), then deploy instances (address + wasm-hash pointer + instance storage). Many instances per binary, ∅ in Aptos Move, and the substrate for factories (§2.2). stellar contract upload for code-only (feeds factories and upgrades); stellar contract deploy for an instance. Addresses: accounts G…, contracts C…, both unify as Address. Getting started: deploy

5.2 Cross-contract: compile-time linking → runtime dispatch ⚠ FALSE FRIEND

Move, use other_addr::vault; is static linking: callee identity fixed at compile time, verified at publish, dispatch static.

Soroban, every cross-contract call is dynamic dispatch to an Address you hold at runtime, usually from your own storage:

#![allow(unused)]
fn main() {
mod policy_contract { soroban_sdk::contractimport!(file = "policy.wasm"); }  // typed client from wasm

let policy: Address = env.storage().instance().get(&DataKey::PolicyAddr).unwrap();
let split = policy_contract::Client::new(&env, &policy).allocate(&amount);

// SEP-41 tokens need no import; the SDK ships the client:
let t = soroban_sdk::token::TokenClient::new(&env, &asset);

// Unknown interface at compile time:
let x: i128 = env.invoke_contract(&target, &symbol, args);
}
  • ⚠ FALSE FRIEND, a callee address is arbitrary code wearing an interface. It can lie, trap, burn budget, and emit misleading events (it cannot re-enter you, §6.3). Any user-supplied contract address must be allowlisted (approved_assets), and any callee return value must be validated, sum/range/length-check the allocation, freshness-check the oracle. “Untrusted collaborator” is the correct posture even for contracts you deployed, since governance can repoint them.
  • The pinned-address-in-storage pattern is also your inter-contract upgrade mechanism (§5.5).

Cross-contract example · docs.rs: contractimport

5.3 Visibility: one flat exported namespace

entry vs public vs private → gone. Every pub fn in #[contractimpl] is simultaneously the external entry point and the cross-contract interface; there is no “callable by transactions but not by contracts” or vice versa. Access control is done with auth (§1.5), never with visibility, because there is none to do it with. Move: functions

5.4 init_module → __constructor 🔧 (and strictly better)

Move, init_module(&signer) runs at publish, takes only the publisher’s signer; real parameterization needs a follow-up call.

Soroban, __constructor(env, args…) (exact name) runs once, atomically, at deploy, with arbitrary typed args passed after -- in the deploy command. Failure aborts the deployment; it never re-runs (not on upgrade). This kills the deploy-then-initialize front-running window; prefer it always. A contract deployed without one can’t gain one retroactively; if forced into a guarded initialize (legacy), check-and-set an Initialized flag or anyone can capture admin.

5.5 Upgrade policy → compiled-in upgrade fn

Move, policy declared in the manifest: compatible (layout/signature-compatible upgrades) or immutable. Move: package upgrades

Soroban, no manifest, no compatibility checker. A contract is mutable iff it exports a function calling env.deployer().update_current_contract_wasm(new_hash); gate it with admin auth. Immutability = omission: no such call site anywhere, no other path exists (no delegatecall). Address and all storage survive an upgrade; __constructor does not re-run.

⚠ FALSE FRIEND, there is no compatible-policy safety net: new code reading an old key whose #[contracttype] shape changed fails to decode at runtime. Schema migration is entirely on you: store a schema version, add enum variants rather than reshaping, ship an idempotent admin-gated migrate enforcing new > current. And error codes are ABI; never renumber (§6.1). The three-contract split gives the complementary mechanism: repoint a stored dependency address (§5.2) instead of mutating code; a contract should have one upgrade path, not both. Upgrading contracts

5.6 Transaction shape: one invocation per transaction ∅

Move, a script sequences arbitrary calls atomically; entry fns compose framework calls freely. Move: scripts

Soroban, a transaction carries exactly one contract invocation (one InvokeHostFunction op; Soroban txs are single-operation). There is no client-side multicall. Atomic multi-step = a contract function making the calls (a periphery/router contract if the steps span protocols). Anything your docs describe as “then the client calls X and Y” is actually one contract function or two transactions; decide which.

Adjacent quick hit: Aptos fee-payer/sponsored txs ↔ Stellar fee-bump transactions (fees) + sponsored reserves (account/trustline reserves), protocol features, not contract code. Sponsored reserves

5.7 #[view] functions → any function, invoked in simulation

Move/Aptos, #[view] is an opt-in marker gating the node’s free read path: only tagged fns are callable through the fullnode /view endpoint or aptos move view (calling an untagged fn there fails). No signer, no gas; the API discards any state mutation (#[view] is not compile-time purity; the read path just drops writes). It exists partly because entry-fn return values are inaccessible to the submitter, so computed reads need a dedicated surface. Aptos: fullnode REST API & view functions

Soroban, ∅ attribute, and none is needed: “view” is a call mode, not a function property. Any pub fn can be executed through RPC simulateTransaction, no signature (auth runs in recording mode: require_auth is noted, never enforced), no fee, execution effects discarded, return value in the simulation result. Return values are also accessible from submitted txs, so the entry/view split never existed: one bucket_balance(asset, category) serves cross-contract callers, off-chain readers, and transactions alike.

#![allow(unused)]
fn main() {
pub fn bucket_balance(env: Env, asset: Address, category: Category) -> i128 {
    env.storage().persistent()
        .get::<_, BucketSet>(&DataKey::Buckets(asset))
        .unwrap_or_default()
        .get(category)          // pure: no writes, no TTL bumps, see the ⚠ below
}
}
stellar contract invoke --id C… --source-account any --network mainnet -- \
  bucket_balance --asset C… --category Fuqara     # answered from simulation, never submitted

Read paths side by side:

ReadAptosSoroban
Computed (runs code)/view endpoint, aptos move viewsimulateTransaction; CLI invoke answers read calls from simulation (--send=no forces); TS bindings resolve read calls from simulation, no signAndSend
Raw storage/accounts/{addr}/resource/{type}getLedgerEntries with the XDR-encoded contract-data key
HistoryindexergetEvents (~7-day RPC window) + history archives, §7

Breaks:

  • No marker, no enforcement, no ABI signal. Aptos at least gates the read endpoint on the attribute; the Soroban contract spec carries no read-only flag, so tooling distinguishes readers from writers only by simulating and inspecting the footprint. Purity is a review-enforced convention.
  • ⚠ FALSE FRIEND, side effects in getters silently don’t happen. Simulation discards writes, and nobody submits pure reads (why pay?). So extend_ttl inside a getter, the reflexive Soroban keep-alive idiom, never lands for view traffic: “read traffic keeps the state alive” is false. Same for lazy migration or access counters on read. Getters stay pure; TTL bumps live in mutating entry points plus the owned ops job (§3.5). (Aptos’s API also discards view-path mutations, but Move norms never pushed writes into getters, so the instinct isn’t there to betray you.)
  • Simulate-anything cuts both ways. Recording-mode auth means anyone can dry-run any function, admin paths included, and see exactly what it would do, which auths it needs, what it costs. Superb pre-flight UX; never evidence of authorization. And auth-gating a getter hides nothing: every entry is publicly readable via getLedgerEntries regardless (true of Aptos resources too, neither chain has read ACLs).
  • ∅ Views depend on rent. A read can hit an archived persistent entry (simulation answers with a restorePreamble, restore, then read) or an expired temporary one (absence, permanently). An Aptos resource is always readable; a Soroban answer is conditional on TTL; one more reason getters unwrap_or(default) where absence is meaningful.
  • Simulation runs against the RPC node’s recent ledger snapshot, slightly stale is possible; getLedgerEntries reads latest confirmed state.

Free simulation is worth designing for: a solvency(asset) -> (booked, held) getter lets anyone verify the §4.1 invariant at zero cost, with no account.

Transaction simulation · simulateTransaction deep dive

5.8 Dispatchable FAs (AIP-73) & dynamic dispatch → the only dispatch there is

Move/Aptos, static dispatch is the rule; AIP-73’s Dispatchable Fungible Asset is the fenced exception. An issuer registers hooks once, at asset creation: dispatchable_fungible_asset::register_dispatch_functions(&constructor_ref, withdraw, deposit, derived_balance) (plus register_derive_supply_dispatch_function), each an Option<FunctionInfo> naming module address/name/function. Signatures are type-verified at registration; integrators must call the dispatchable_fungible_asset::withdraw/deposit wrappers (raw fungible_asset:: aborts on hook-bearing tokens, EINVALID_DISPATCHABLE_OPERATIONS); the native dispatcher guards against re-entrant dispatch. APT itself bypasses dispatch, and the Confidential Asset standard flatly rejects dispatchable FAs. FA standard, DFA section · AIP-73 · dispatchable_fungible_asset

Soroban, the defaults invert: dynamic dispatch is the only cross-contract mechanism there is (§5.2). DFA’s entire apparatus dissolves into it:

AIP-73 conceptSoroban
Hook registration at creation∅, the token is the hook: every DFA use case (tax/deflation, allowlist/KYC, predicated transfer, loyalty, rebasing derived_balance) is just an implementation of SEP-41
dispatchable_fungible_asset::withdraw/deposit wrappersTokenClient, always dispatching; no vanilla/dispatchable split to route around, no EINVALID_DISPATCHABLE_OPERATIONS analogue
FunctionInfo (addr, module, fn); fixed hook names/signaturesan Address, plus a runtime-chosen Symbol via env.invoke_contract, which is more dynamic than DFA allows
Signature verification at registration∅ ⚠, conformance is checked only at call time, per call, as host decode errors
APT exempt from dispatch (trustable fast path)the SAC: platform-fixed token code, never hookable, the mirror image (Aptos: framework token, issuer-hookable; Stellar: built-in asset contract, hookable by no one)
The dispatcher’s reentrancy guardthe host’s general reentrancy block (§6.3), covers all calls, not just token dispatch
#![allow(unused)]
fn main() {
// A "predicated-transfer" DFA on Soroban is not a hook; it's the token:
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
    from.require_auth();
    require_allowlisted(&env, &to);          // the "hook", inline in the SEP-41 impl
    move_balance(&env, &from, &to, amount);  // + emit the standard transfer event (§7)
}
}

Breaks:

  • ⚠ FALSE FRIEND, every unknown token is DFA-grade. Aptos partitions tokens into vanilla (framework code, trustable) and dispatchable (arbitrary code; even Aptos’s own Confidential Asset refuses them). Soroban has no vanilla tier among custom contracts: any token address may tax transfers, lie in balance(), or gate recipients. The token-consumer checklist (§8.1) is the DFA-integrator posture applied to everything, except allowlisted SACs, whose behavior is fixed protocol code. That certainty is load-bearing: it is what lets a pool book amount on a SAC transfer without measuring the balance delta. Admit one non-SAC token to the allowlist and that shortcut dies; measure before/after instead.
  • ⚠ No registration step means no conformance check until the call. contractimport! type-checks your call sites against the wasm you built against, not what is deployed at the address, an upgrade or governance repoint changes the callee under a still-compiling client, and the failure is a runtime InvokeError, not a deploy-time rejection. Rehearse repoints with fork tests against the actually deployed wasm (stellar contract fetch, §9); a timelock on repointing is what buys the window to do it.
  • Can’t hook a SAC → stand in front of it. DFA-style compliance hooks on USDC have exactly one shape on Stellar: a custody contract whose entry points wrap the transfers. The hook layer is enforced by holding the funds, not by dispatch registration.
  • ∅ No function values. Nothing function-shaped crosses the ABI or enters storage; a “callback” is a stored Address implementing an expected interface. The platform’s own hook points work exactly this way, __check_auth (§1.4) is Soroban’s AIP-73: register a contract, the host natively dispatches into a fixed signature under special rules. For strategy variation inside one contract, prefer enum + match (static, cheap, auditable) over a Map<Symbol, Address> handler table: every dynamic hop is full invocation overhead and widens both the trust surface and the auth tree.

5.9 inline functions → ordinary private fns 🔧

Move, inline fun bodies are expanded at the call site at compile time and never appear in bytecode as separate functions. One keyword does three unrelated jobs: it removes call overhead (and some gas), it is the only place a lambda may be passed (f: |Accumulator, Element| Accumulator, which is why the whole vector::for_each / fold / filter family is public inline fun), and it loosens borrow checking, since borrow checking runs after expansion; an inline fn may return a reference into global storage and may take aliased &mut/& arguments that a normal fn cannot. Move: inline functions

Soroban, ∅ keyword, and nothing to port. Everything outside #[contractimpl] is an ordinary Rust fn: private by default (§5.3), free to be generic, free to take a closure, and inlined by LLVM whether or not you write #[inline], which is a hint rather than a directive. The restriction that motivated most inline fun declarations does not exist, so the higher-order helper needs no modifier at all:

// Move: `fold` is a `public inline fun`; the lambda is legal only because of that
let dust = vector::fold(amounts, 0, |acc, x| if (x < 1000) acc + x else acc);
#![allow(unused)]
fn main() {
// Soroban: a private fn, generic over any closure. No `inline` anywhere.
fn sum_where(env: &Env, amounts: &Vec<i128>, keep: impl Fn(i128) -> bool) -> i128 {
    let mut total: i128 = 0;
    for x in amounts.iter() {
        if keep(x) {
            total = match total.checked_add(x) {
                Some(t) => t,
                None => panic_with_error!(env, Error::Overflow),
            };
        }
    }
    total
}

let dust = sum_where(&env, &amounts, |x| x < 1_000);   // bound `amounts`: the loop is metered, §6.4
}

Breaks:

  • The size trade-off stops being advice and becomes a ceiling. Move warns that excessive inlining grows bytecode and may trip size restrictions; on Soroban that limit is 131,072 B of contract code (§6.4), network-configured, and the wasm is charged at upload (§5.1). Monomorphization is the real inliner here, one instantiation per concrete type per generic fn, and #[inline(always)] on a widely called helper compounds it. The fix is the release profile rather than hand-tuning: opt-level = "z", lto = true, codegen-units = 1, panic = "abort", strip = "symbols", debug = 0, plus overflow-checks = true (§4.5); the Stellar docs note that a Rust contract without these “almost always” exceeds the size limit. stellar contract build optimizes by default.
  • ∅ The reference-returning use case has nothing to port to. inline fun borrow_state(): &mut State exists to hand out a live borrow_global_mut handle to the caller. There are no references into Soroban storage at all: get returns an owned copy and only set writes (§3.3). The idiom is not restricted here, it is meaningless here.
  • ∅ Function values stop at the module boundary. Move 2.2 made functions first-class (|u64|bool has copy+drop+store, #[persistent] on a non-public fn to make it storable), so a Move dispatch table can hold a function and a struct field can persist one. Soroban has no function-shaped value anywhere: nothing function-shaped crosses the ABI or enters storage (§5.8), and the nearest equivalent is a stored Address plus a Symbol, at full cross-contract invocation cost and full trust surface. Move meets the reentrancy this opens with a targeted guard (the VM locks the re-entered module’s resources; #[module_lock] to lock harder); Soroban’s block is blanket and host-level (§6.3). Move: function values
  • The public inline fun footgun is gone. In Move, an exported inline fn touching module-private constants or methods expands into a foreign module and breaks there, so the privacy check effectively happens at the wrong place. Rust privacy is resolved before inlining, so the same mistake does not compile.

Soroban: release profile and contract size


← Cheat sheet index · ← 4. Types & abilities · 6. Execution semantics →

6. Execution semantics

6.1 Abort codes → #[contracterror]

// Move
const E_INSUFFICIENT: u64 = 2;
assert!(bal >= amt, E_INSUFFICIENT);        // or: abort E_INSUFFICIENT
#![allow(unused)]
fn main() {
// Soroban
#[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)]
pub enum Error { NotInitialized = 1, InsufficientBalance = 2, InvalidAmount = 3 }

pub fn distribute(…) -> Result<(), Error> {
    if bal < amt { return Err(Error::InsufficientBalance); }   // preferred: typed Result
    // or: panic_with_error!(&env, Error::InsufficientBalance);
    Ok(())
}
}
  • ⚠ FALSE FRIEND, bare panic!("msg"), unwrap(), expect() surface as opaque host errors callers can’t match on. Move’s abort always carried your code; here only panic_with_error! / Err do. Ban unwrap/expect on any externally reachable path.
  • Error codes are public ABI; never renumber across upgrades. With multiple contracts, partition now (pool 1xx, policy 2xx, attestation 3xx) so errors are attributable across try_ boundaries.
  • An Err/panic rolls back all state changes of the failing invocation, including its nested calls’ writes.

Move: abort & assert · docs.rs: contracterror · errors example

6.2 Catchable callee failures ∅

Move has no try/catch: any abort anywhere kills the transaction. Soroban generated clients expose both foo() (panics on failure) and try_foo() returning a doubly nested Result. The shape depends on whether the contract fn returns Result, and the typed error lands in Err(Ok(_)), not Ok(Err(_)), which is the intuitive guess and is wrong:

#![allow(unused)]
fn main() {
// fn contribute(..) -> Result<(), Error>
let r: Result<Result<(), ConversionError>, Result<Error, InvokeError>>
     = client.try_contribute(&donor, &asset, &0);
assert_eq!(r, Err(Ok(Error::InvalidAmount)));   // ← your #[contracterror], verified

// fn bucket_balance(..) -> i128   (no Result)
let r2: Result<Result<i128, soroban_sdk::Error>, Result<soroban_sdk::Error, InvokeError>>
      = client.try_bucket_balance(&asset, &Category::Fuqara);   // Ok(Ok(0))
}

Read it as: outer Err = the call failed, and its payload distinguishes your typed error (Ok(e)) from a host-level failure (Err(InvokeError): budget, bad auth, type mismatch). The inner Ok position is the conversion result for the return value. A caught callee failure rolls back the callee’s writes; the caller continues with its own state intact. Budget exhaustion ends the whole transaction regardless; it is not catchable.

Design consequence with no Move reflex: a caller can tolerate a failing dependency (skip a broken oracle, degrade gracefully). Conversely ⚠: try_ on a token transfer followed by continuing on Err is exactly the silent-accounting-error shape; on money paths, use the panicking client or make the Err arm abort. And note TokenClient::transfer returns (); “checking the transfer result” isn’t a thing; a failed transfer traps, which is the guarantee.

6.3 Dispatch and reentrancy

Move, static dispatch; reentrancy structurally impossible; the compiler sees the whole call graph. Soroban, dynamic dispatch by address (§5.2), but the host blocks reentrancy at runtime, direct and indirect, and there is no delegatecall (no foreign bytecode in your context; proxy hijacks don’t exist). Same guarantee as Move, enforced one layer later. Still write checks-effects-interactions: external calls remain failure/budget/side-effect boundaries, callees are arbitrary code (§5.2), and the pattern survives a platform change the assumption wouldn’t.

6.4 Gas → declared resources, and hard ceilings

Move, one gas meter, pay-as-you-go, plus a refundable storage deposit; no pre-declaration. Aptos: gas & txn fees

Soroban, multidimensional and declared up front: CPU instructions, ledger entries + bytes read/written, tx size, events size, rent, computed by simulation, embedded in the tx; exceed your declaration and the tx fails (rent/events refundable if unused; instructions/IO charged as declared). Per-transaction mainnet ceilings, from InvocationResourceLimits::mainnet() in SDK 27.0.6 (network-configured; check live): 400M instructions · 40MiB memory · 200 disk-read entries / 200 write entries / 400 ledger entries total · 200KB read / 132,096B written · 65,536B per data entry (the whole instance-storage entry included; keys ≤250B) · 16,384B events + return value · 131,072B contract code.

What actually moves the needle: minimize distinct entries touched (fixed cost each, and footprint members cost whether used or not); never loop over user-controlled collection sizes (budget DoS / fee-griefing; cap explicitly); events instead of storage for off-chain-only data; bound signature counts in __check_auth. Profile with stellar contract invoke … --send=no or env.cost_estimate().resources() in tests. Resource limits & fees

6.5 Environment quick hits

  • timestamp::now_seconds() 🔧 → env.ledger().timestamp() (u64 seconds); ledger sequence via env.ledger().sequence().
  • ⚠ Aptos has a secure on-chain randomness API; env.prng() is seeded from ledger state and predictable; never for auth, keys, or stakes.
  • No I/O, no networking, no floats, no clock beyond the ledger. Everything reaches the world through Env.

← Cheat sheet index · ← 5. Modules vs. contracts · 7. Events →

7. Events

Move, #[event] struct + event::emit(e); indexers consume typed module events; events persist in the indexer’s history. Aptos framework: event

Soroban,

#![allow(unused)]
fn main() {
#[contractevent]                      // topics: ("contribution", donor); data: {asset, amount, split}
pub struct Contribution {
    #[topic] pub donor: Address,
    pub asset: Address, pub amount: i128, pub split: BucketSet,
}
Contribution { donor, asset, amount, split }.publish(&env);
}
  • Structure is split and load-bearing: #[topic] fields (plus the snake-cased struct name, first by default) are what indexers filter on; the rest is the data payload. Participant addresses belong in topics.
  • 🔧 the older env.events().publish(topics, data) is deprecated; use the macro.
  • ⚠ FALSE FRIEND, events are ephemeral at the RPC layer: typical retention ~7 days; full history lives in Stellar’s history archives. An “events + indexer” record design must ship the archive-ingestion path, and anything needed on-chain long-term must be derivable from state, not events. Your event schema is ABI for the indexer; version it deliberately.
  • Diagnostic log! output is off by default on nodes and outside consensus, never logic-bearing.
  • Emitting is far cheaper than storing: the correct home for audit-trail data (per-contribution records) that contracts never re-read.
  • Token movements must emit the standard SEP-41 event shapes exactly (transfer/mint/burn/approve/clawback); wallets and indexers depend on them; the SAC emits them for you.

Events guide · docs.rs: contractevent

7.1 Reading events on-chain → ∅ on both chains

Symmetric dead end, and the symmetry is the useful fact: no contract on either platform can read emitted events during execution. Aptos states the mechanism plainly; events live in a per-transaction event accumulator, a separate merkle tree that is ephemeral and independent of the state tree, so the MoveVM has no read access to them in production. Soroban is the same by construction: events land in transaction meta, never in ledger state; Env exposes publish and no reader. Both are write-only sinks feeding indexers.

Both do expose event reading in tests only, and they line up almost exactly:

Aptos (test-only)Soroban (testutils)
All events of a typeevent::emitted_events<T>(): vector<T>env.events().all() → ContractEvents (most recent invocation only)
Assert one was emittedevent::was_event_emitted<T>(&e): boolcompare against e.to_xdr(&env, &contract_id)
#![allow(unused)]
fn main() {
use soroban_sdk::{testutils::Events as _, Event as _};
let expected = Contribution { donor, asset, amount: 1_000, split };
// ContractEvents impls PartialEq<std::vec::Vec<xdr::ContractEvent>>, so compare directly.
// filter_by_contract is usually needed: a token transfer emits the SAC's event too.
assert_eq!(
    env.events().all().filter_by_contract(&contract_id),
    std::vec![expected.to_xdr(&env, &contract_id)],
);
// Raw slice if you need to inspect: env.events().all().events()  ; note: no .len(), no .iter()
}

⚠ Note the Soroban asymmetry: env.events().all() covers only the most recent invocation and resets on the next client call (like env.auths(), §9), whereas emitted_events<T>() accumulates across the test. Assert immediately after the call under test.

The design rule this forces (same on both): anything a contract must decide on has to be state; events are for parties outside the VM. If a value is load-bearing for contract logic, store it, and if the audit trail must also carry it, emit it too and accept the duplication. Concretely: your distribution_seq, category balances, and pending-governance-change record are storage precisely because distribute and contribute branch on them; the per-contribution record is events-only precisely because no contract ever reads it back. That split is correct and is the same split you’d draw in Move.

⚠ FALSE FRIEND, the indexer is not a read path. “The indexer reads our events and feeds a total back in” turns a convenience service into a trusted oracle: it becomes a signer whose word moves money, with all of §5.2’s untrusted-callee problems plus off-chain compromise. If a contract needs a running total, maintain it in storage as you go. Reserve the event trail for reconstruction by humans and services, where you already have it right.

(If you’re thinking of a recent Aptos AIP here: AIP-44 Module Events replaced EventHandle streams with #[event] struct-typed module events; better emission, indexing, and parallelism, plus the test-only readers above. It did not grant contracts read access, and no AIP has.)

Aptos: events · AIP-44 Module Events · aptos_framework::event


← Cheat sheet index · ← 6. Execution semantics · 8. Tokens →

8. Tokens

8.1 Fungible: Coin/FA → SEP-41 + SAC

Move, assets are typed values (Coin<T>) or FA objects (Metadata + FungibleStores) living in holders’ storage; your code holds and moves the value itself (§4.2). Aptos: fungible asset · standards overview

Soroban, a token is an external contract implementing SEP-41; balances live inside it; you call it (§4.2). Three things may stand behind one “token address”: a custom contract, native XLM’s SAC, or a classic Stellar asset’s Stellar Asset Contract, a built-in SEP-41 wrapper at a deterministic per-asset address. Circle’s USDC on Stellar is a classic asset ⇒ your custody path is the SAC path.

#![allow(unused)]
fn main() {
use soroban_sdk::token::{TokenClient, StellarAssetClient};
let t = TokenClient::new(&env, &asset);          // SEP-41: balance/transfer/approve/burn…
t.transfer(&from, &to, &amount);
let sac = StellarAssetClient::new(&env, &asset); // SAC admin surface: mint/clawback/set_admin…
}

What has no Move reflex behind it:

  • Auth pattern: transfer/approve/burn auth from; transfer_from/burn_from auth the spender (the allowance pre-authorized the from side). Reads need no auth.
  • Allowances expire: approve(from, spender, amount, expiration_ledger). Re-approving overwrites → the classic front-run race (spend old + new); approve-to-0 first, or prefer exact-amount auth (require_auth_for_args) over standing allowances.
  • ⚠ FALSE FRIEND, the SAC drags classic-asset semantics into your contract. Account (G…) balances live in trustlines: recipient missing one, or unauthorized under AUTH_REQUIRED, ⇒ the transfer traps (also: trustline balances cap at i64). Issuer flags apply; freezes (AUTH_REVOCABLE), clawback. Transfers to the issuer burn; from the issuer mint. Contract (C…) balances live in contract storage with full i128 and no trustline. A distribution path paying G… recipients must treat “recipient not ready” as an expected failure mode, not an anomaly.
  • Amounts i128 and signed, §4.5. Decimals: query per token at registration, never assume 7. transfer’s destination is a MuxedAddress (plain Address converts) for exchange sub-account IDs.

SEP-41 · Token interface · SAC

8.2 Non-fungible: Digital Assets → SEP-50

Move/Aptos, the Digital Asset standard, built directly on the object model (§2.2): a Collection object plus Token objects, each token holding a reference to its parent collection (the collection does not own the token). Authority is capability refs minted at creation and only then; MutatorRef (edit description/URI), BurnRef, TransferRef; royalties configurable per collection or per token, mint_soul_bound for untransferable tokens, and NFTs that can own NFTs. It supersedes legacy Token v1, whose transfers required recipient opt-in. Aptos: digital asset · aptos-token-objects

Soroban, SEP-50, an ERC-721-shaped contract interface: balance / owner_of / transfer / transfer_from / approve / approve_for_all / get_approved / is_approved_for_all, plus name / symbol / token_uri. It deviates from ERC-721 deliberately: transfer() is added (owner-initiated transfers are the common case and cheaper than the approve-then-pull dance), safeTransferFrom is dropped (auth entries already cover the class of mistake it guarded), and token_id is format-agnostic, sequential, UUID, or hash.

Where the analogy breaks:

  • ⚠ FALSE FRIEND, there is no SAC for NFTs. SEP-41’s saving grace is that the most important fungible tokens are SACs: fixed protocol code, behaviour you can assume (§5.8). SEP-50 has no protocol-level implementation at all. Every NFT you touch is arbitrary contract code that may lie in owner_of, charge on transfer, or refuse recipients. The §5.8 “every unknown token is DFA-grade” posture applies here with no trustable tier to fall back on.
  • ⚠ SEP-50 is a Draft (0.1.0, March 2025), not a settled standard, unlike SEP-41. Authored by OpenZeppelin with Boyan Barakov and Özgün Özerk. Treat the interface as likely-stable rather than frozen, and pin the implementation you integrated against.
  • ∅ Refs don’t port, MutatorRef/BurnRef/TransferRef are capability values, and §2.1 already settled that: no scarce values, so authority becomes role Addresses in storage plus require_auth. “Mint-time only” scarcity becomes “set once in __constructor, never expose a setter” (§5.4).
  • ∅ NFTs owning NFTs has no substrate (§2.2). An owner is an Address; since a contract address is an Address, composition is a cross-contract relationship you design, not resource attachment you get.
  • ⚠ Every token is a rent liability. Each NFT’s ownership entry is a persistent ledger entry with its own TTL (§3.5), so a 10,000-piece collection is 10,000 entries someone must keep alive, an operational cost that scales with supply and has no Aptos analogue, where state persists once paid. This is the consideration that should drive variant choice, not gas.
  • Implementation: OpenZeppelin’s stellar-contracts non-fungible module, Base (most cases), Consecutive (batch minting; stores ownership only at range boundaries and infers the rest, materially cheaper for large drops), Enumerable (on-chain enumeration, which you pay for in entries), plus Burnable, Royalties, and Votes extensions. The variants override each other’s internals, so mixing them freely is explicitly discouraged.
  • The pre-Soroban path still exists: SEP-39 NFTs as classic assets with supply 1 and off-chain metadata. Cheap and needs no contract, but the “fungibility” is nominal and metadata lives off-chain; fine for tickets and badges, wrong for anything wanting on-chain behaviour.

SEP-50 · OpenZeppelin non-fungible · NFT example contract


← Cheat sheet index · ← 7. Events · 9. Testing & assurance →

9. Testing & assurance

Move, #[test(admin = @0x1)] injects real signers; #[expected_failure(abort_code = …)]; aptos move test; the Prover for specs. Move: unit testing · Move Prover

Soroban, native Rust tests against a full in-process host (extern crate std; is fine in tests):

#![allow(unused)]
fn main() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let id = env.register(ZakatPool, (admin.clone(),));   // tuple = __constructor args
let client = ZakatPoolClient::new(&env, &id);
}

The differential traps:

  • ⚠ FALSE FRIEND, mock_all_auths() approves everything, so it hides missing require_auth; the §1.1 bug ships green under it. Always pair with assertions, immediately after the call under test:

    #![allow(unused)]
    fn main() {
    client.contribute(&donor, &usdc, &1_000);
    let auths = env.auths();                 // most-recent invocation ONLY; resets on the next
    assert_eq!(auths[0].0, donor);           // client call, including reads
    }

    Assert the full tree (fn, args, sub-invocations) on money paths, or use mock_auths(&[MockAuth{…}]) to approve one specific invocation. env.events().all() has the same reset-per-call behavior. Contracts using authorize_as_current_contract need mock_all_auths_allowing_non_root_auth().

  • expected_failure → assert on try_ results’ typed-error position (§6.2); no attribute equivalent.

  • Time/ledger: env.ledger().set_timestamp(t) / set_sequence_number(n); set, act, advance, act.

  • TTL is testable, but get_ttl lives on traits you must import by name; there is no testutils::Storage umbrella:

    #![allow(unused)]
    fn main() {
    use soroban_sdk::testutils::storage::{Instance as _, Persistent as _};
    env.as_contract(&id, || env.storage().persistent().get_ttl(&key));   // instance(): get_ttl() takes no key
    }
  • Cross-contract against real Wasm: env.register(other::WASM, args) after contractimport!; test the actual binary, not a mock.

  • Mainnet resource limits are enforced in unit tests by default (InvocationResourceLimits::mainnet()): a budget-blowing function panics in cargo test, naming the limit exceeded, before it fails in production. Read usage with env.cost_estimate().resources() → InvocationResources { instructions, mem_bytes, disk_read_entries, memory_read_entries, write_entries, disk_read_bytes, write_bytes, contract_events_size_bytes, persistent_rent_ledger_bytes, … }. Override with env.cost_estimate().enforce_resource_limits(limits), or env.cost_estimate().disable_resource_limits() for deliberately heavy experiments.

  • The default assurance stack is fuzz → property → mutation → fork: #[contracttype] types implement SorobanArbitrary (cargo-fuzz works out of the box on try_contribute/try_distribute with the solvency invariant as oracle); proptest locks findings into CI; cargo mutants finds untested code; Env::from_ledger_snapshot_file replays real network state (stellar snapshot create), including upgrade rehearsals against the deployed Wasm (stellar contract fetch). Static analysis: CoinFabrik Scout (cargo scout-audit).

Formal verification is the one real regression, and it’s a tooling gap rather than a semantic one. ⚠ Do not read the stack above as a Prover substitute: fuzzing samples the input space, the Prover discharges proof obligations over all inputs. Different guarantees, and the difference is exactly what you’d reach for the Prover to get.

Move ProverSoroban
Provenancefirst-party, ships with the Aptos toolchain (aptos move prove)none first-party, two third-party tools
Specs livein-language spec {} blocks beside the code, versioned with itoutside the language: Certora Sunbeam (#[rule] fns using the CVLR macro library, cvlr_assert!, cvlr_assume!, cvlr_satisfy!); Runtime Verification Komet (property tests written as Rust test contracts)
VerifiesMove bytecode, via Boogie/Z3Wasm bytecode; Sunbeam via SMT, Komet via K/KWasm symbolic execution
Maturitymature, widely used on the framework itselfSunbeam used in production audits (e.g. Blend); Komet SCF-funded and newer. Both hit timeouts/Unknown on complex functions; Sunbeam’s invariant and parameterized-rule support is still thinner than CVL’s on EVM

Verifying Wasm has one structural advantage over verifying Move bytecode: Soroban’s guest/host split moves most complexity into the host, so contract code is small and self-contained, and verification reduces to the contract given a correct host. Practical read for a solvency invariant: express it as a Sunbeam rule if you want a proof, and keep the fuzz harness regardless; it’s cheap, it runs in CI, and it catches the same class of bug faster during development.

  • Test snapshots (test_snapshots/ JSON of events + final state), commit them; diffs expose unintended behavior changes.

Fuzzing · Fork testing · Mutation · Snapshots · Certora Sunbeam · Komet · Move Prover


← Cheat sheet index · ← 8. Tokens · 10. Toolchain quick map →

10. Toolchain quick map

Move (Aptos)Soroban
Buildaptos move compilestellar contract build (→ target/wasm32v1-none/release/*.wasm, optimized)
Testaptos move testcargo test (native host, debugger-friendly)
Publishaptos move publishstellar contract deploy (instance) / upload (code only)
Callaptos move runstellar contract invoke --id C… -- fn --arg v
Dry-run/profilesimulation APIstellar contract invoke … --send=no (instructions, IO, fees)
ManifestMove.toml, named addressesCargo.toml, crate-type = ["lib","cdylib"], overflow-checks = true mandatory in release
TargetMove bytecodeWasm, wasm32v1-none (Rust ≥1.84), 128KB limit (cargo bloat when over)
Local netaptos node run-local-testnetstellar container start local (RPC localhost:8000/soroban/rpc)
Keysprofilesstellar keys generate alice --network testnet --fund
Testnetpersistentresets quarterly; script every deployment; runs the next protocol before mainnet, so it’s your upgrade rehearsal
ExplorerAptos ExplorerStellar Lab, state, TTLs, archived-entry restore, live network limits

Stellar CLI manual


← Cheat sheet index · ← 9. Testing & assurance · 11. Porting reflexes, the grep-able checklist →

11. Porting reflexes, the grep-able checklist

  1. Every Address parameter → an explicit auth decision; privileged paths auth a stored role address, never the parameter.
  2. Every layer that exercises an address’s authority calls require_auth, including the outermost one (middleware replay).
  3. get + mutate is banned; read-modify-write goes through update.
  4. checked_* on every money path; amount > 0 at every amount-taking entry point (i128 is signed).
  5. No unwrap/expect/bare panic! on externally reachable paths, panic_with_error! or Err only; error codes frozen forever.
  6. Nothing load-bearing in temporary(); TTL is never a permission; deadlines live in values.
  7. Fine-grained storage keys; no per-tx iteration over user-controlled collection sizes; instance storage stays small and global.
  8. Every user-supplied contract address is allowlisted; every callee return value is validated (length, sign, sum, freshness).
  9. extend_ttl in hot paths and an owned, runbooked keep-alive job; max TTL makes liveness an operational property.
  10. mock_all_auths never appears without an env.auths() assertion on the same invocation.
  11. Solvency (and every conservation law the type system used to give you) is asserted in code and is the fuzz oracle.
  12. One upgrade path per contract: either a gated update_current_contract_wasm or a repointable address in a caller; never both, ideally neither on custody.
  13. Getters are pure; no writes, no TTL bumps: view traffic is simulation, and simulation discards writes.
  14. Nothing the contract branches on lives only in events; events are write-only in production; if logic reads it, store it.
  15. Every function computing a share, fee, or payout makes the transfer call in the same body; a split that is only published settled nothing, and only a balance assertion catches it.

← Cheat sheet index · ← 10. Toolchain quick map · 12. Verification →

12. Verification

Every Soroban snippet in this document exists as compiling code under verify/, and every behavioural claim is an assertion rather than prose. CI runs it on push and weekly, so SDK drift shows up as a red build instead of a quietly stale doc.

cd verify && cargo test

src/lib.rs holds contract-side code (storage, auth, events, errors, TTL, tokens); src/advanced.rs holds auth trees, the deployer, and the custom account; tests/ holds the runtime assertions, auth/event reset behaviour, try_ nesting, TTL reads, cost estimates, contractimport! against a real Wasm binary.

Four claims in the first draft of this document were wrong and were caught by compiling them. They are listed here because the shape of the errors is instructive; all four are places where the plausible guess is wrong:

Claim as first writtenActual (SDK 27.0.6)
try_ typed error at Ok(Err(e))Err(Ok(e)), outer Err means “call failed”; its payload splits your error from host failure
__check_auth(signature_payload: BytesN<32>, …)crypto::Hash<32>, host-constructed, unforgeable in safe code
get_ttl via “testutils traits”needs use soroban_sdk::testutils::storage::{Instance as _, Persistent as _}; there is no testutils::Storage
assert_eq!(env.events().all(), vec![…])needs .filter_by_contract(&id); a token transfer also emits the SAC’s own event

Move snippets are illustrative and are not compiled.

How this was written

These notes were drafted with AI assistance against the primary sources in §13, then checked by compiling.

Move snippets are not compiled, and the analogies (what maps to what, and where the mapping breaks) are argument and not test output.


← Cheat sheet index · ← 11. Porting reflexes, the grep-able checklist · 13. Primary sources →

13. Primary sources

Soroban, skills.stellar.org / smart-contracts (+ its development, testing, security deep-dives) · developer docs · soroban-examples · docs.rs/soroban-sdk · example contracts index

Move, Move Book on Aptos · smart-contracts hub · security guidelines

Link note: developers.stellar.org and aptos.dev/build/smart-contracts/book/* paths verified Aug 2026; a handful of Aptos-specific deep links (prover, gas, randomness) follow the current nav structure but reorganize occasionally; one hop from the hub pages above if any has moved.


← Cheat sheet index · ← 12. Verification

Contributing

This started as one developer’s porting notes (Aptos Move → Soroban) and is public because the notes were more useful than expected. It is not a committee document. That shapes what helps:

What’s most useful

  1. Corrections. A wrong mapping is worse than a missing one; someone will act on it. If a claim here is false, say so, even bluntly, even without a fix.
  2. Missing Move concepts. If you reached for something from Move and couldn’t find the Soroban answer here, that gap is worth an issue on its own. You don’t need the answer.
  3. Staleness. The SDK moves. If a snippet stopped compiling, the CI may not have caught it yet.

Opinions on style, ordering, or tone are welcome but lower priority. The bias is toward density: this is written for people who already know Move well and want the differences, not an introduction.

The one hard rule: Soroban snippets must compile

Every Soroban snippet in docs/ exists as real code under verify/, and CI runs it on every pull request and weekly: a blocking job against the pinned SDK, verify/Cargo.lock is committed and the job runs --locked, so the version in the docs/index.md header is the version actually tested, plus a non-blocking job against the latest published release. Documentation that can rot silently is how a reference becomes a liability.

If you change or add a Soroban snippet:

cd verify
cargo test          # must pass

Put the snippet in src/lib.rs (contract-side code), src/advanced.rs (auth trees, deployer, custom accounts), or tests/ (anything asserting runtime behaviour). Behavioural claims, “X resets after Y”, “the error lands in this position”, should be an assert!, not prose. That is how the try_ nesting error in the first draft was caught.

Move snippets are illustrative and are not compiled. If one is wrong, an issue is fine.

Layout

The book is generated with mdBook and published to GitHub Pages from main. docs/ is the book source, so every file in it ends up on the site:

  • docs/index.md, the landing page and the quick map, the 38-row table that indexes everything.
  • docs/NN-slug.md, one page per numbered section.
  • docs/SUMMARY.md, the sidebar. A page that isn’t listed here doesn’t appear in the book.
  • book.toml, mdBook configuration, at the repo root.

The root README.md is the GitHub landing page only. It is a pointer to the site and is not part of the book.

Adding a section means: a new docs/NN-slug.md, a row (or rows) in the quick map, an entry in the contents list in docs/index.md, and a line in docs/SUMMARY.md.

Cross-references between pages are relative Markdown links (index.md, 03-state-and-storage.md#33-get-returns-a-copy--false-friend) so they resolve on GitHub and on the published site alike. mdBook rewrites .md to .html at build time; it does not rewrite README.md to index.html, which is why the landing page is named index.md.

To preview locally:

cargo install mdbook   # or: brew install mdbook
mdbook serve --open

Scope

In: Move↔Soroban differentials, traps, platform semantics (storage, auth, metering, dispatch), tooling equivalences.

Out: Soroban tutorials from scratch (the Stellar docs do this well), Sui Move specifics (different enough to deserve its own document), chain advocacy in either direction.

Provenance

The Soroban material follows Stellar’s stellar-dev-skill and the official developer docs; the Move material follows the Aptos docs and Move book. Both are credited in §13. If you add material, link the primary source; an unsourced claim here is a future issue.