Soroban contract for storing non-transferable Stellar Wrap records by wallet and reporting successful wrap mints through events.
The current contract interface for version 0.1.0 is documented in CHANGELOG.md. Backend and frontend consumers should review the migration notes there before updating integrations, especially around the versioned mint-signature payload and the expanded query surface.
The contract is split into focused modules:
src/lib.rs: contract type and module wiringsrc/admin.rs: initialization and admin updatessrc/mint.rs: period validation, signature verification, wrap minting, event emissionsrc/bridge.rs: generic token bridge interface for cross-chain wrap interactionssrc/queries.rs: read-only queries and metadatasrc/errors.rs: contract error codessrc/storage_types.rs: storage keys and persisted record typessrc/test_utils.rs: shared test-only helpers (e.g. payload signing)
For detailed bridge architecture and cross-chain workflow, see docs/bridge-architecture.md.
Each wrap record stores:
timestamp: u64data_hash: BytesN<32>archetype: Symbolperiod: u64
period is encoded as YYYYMM and validated on mint:
- year must be between
2024and2100 - month must be between
01and12
Wrap records are implemented as non-transferable (soulbound) entries. The contract intentionally omits transfer, transfer_from, approve, and allowance methods. As a result:
balance_of(user)returns the number of wrap records minted foruser, not a tradable token balance.- records cannot be transferred between addresses by users.
- any future removal or replacement of a wrap record would require an admin-controlled operation, not a user-initiated transfer.
Returned by health(), reports:
initialized: bool— whetherinitialize()has been calledhas_admin: bool— whether an admin address is currently configuredhas_signing_key: bool— whether an admin signing key is currently configured
DataKey::AdminDataKey::AdminPubKeyDataKey::Wrap(Address, u64)DataKey::WrapCount(Address)DataKey::LatestPeriod(Address)DataKey::MigrationVersion
initialize(e: Env, admin: Address, admin_pubkey: BytesN<32>)update_admin(e: Env, new_admin: Address)mint_wrap(e: Env, user: Address, period: u64, archetype: Symbol, data_hash: BytesN<32>, signature: BytesN<64>)migrate(e: Env, version: u32)
The contract requires mint signatures over a versioned canonical payload. The current payload format is:
0x01— payload version byteXDR(contract_address)XDR(user)XDR(period)XDR(archetype)XDR(data_hash)
Backend signers must include this version byte in all new mint signatures. This version field allows the contract and backend to evolve safely without ambiguous verification behavior.
get_wrap(e: Env, user: Address, period: u64) -> Option<WrapRecord>
Returns the wrap record for the specified user and period. Safe to call before initialization — returnsNoneif the contract has not been initialized or if no wrap exists for the given user and period.balance_of(e: Env, user: Address) -> i128verify_data(e: Env, user: Address, period: u64, data: Bytes) -> boolverify_with_oracle(e: Env, oracle: Address, data_hash: BytesN<32>) -> boolget_latest_wrap(e: Env, user: Address) -> Option<WrapRecord>get_admin(e: Env) -> Option<Address>health(e: Env) -> ContractHealthname(e: Env) -> Stringsymbol(e: Env) -> Stringdecimals(e: Env) -> u32migration_version(e: Env) -> u32
verify_with_oracle performs a read-only cross-contract call to the supplied
oracle address. A compatible oracle exposes this ABI:
verify_data_hash(data_hash: BytesN<32>) -> bool
The hash is forwarded unchanged. The oracle returns true when its
decentralized verification process recognizes the hash and false when it
does not. Contract invocation failures, a missing method, and incompatible
return values propagate as call errors; they are never converted to false.
The caller supplies the oracle address, so a true response is only as
trustworthy as that selected oracle. Applications should use a vetted oracle
contract ID from their own configuration. This method does not mutate wrap
records and does not replace the local verify_data comparison.
Placeholder variables:
<CONTRACT_ID>— deployed contract address (e.g.C...)<USER_ADDRESS>— Stellar account address (e.g.G...)<PERIOD>— period encoded asYYYYMM(e.g.202401)<DATA_HEX>— hex-encoded raw data bytes
soroban contract invoke \
--id <CONTRACT_ID> \
-- \
get_wrap \
--user <USER_ADDRESS> \
--period <PERIOD>Returns Option<WrapRecord> — either the record (see WrapRecord) or null.
soroban contract invoke \
--id <CONTRACT_ID> \
-- \
get_latest_wrap \
--user <USER_ADDRESS>Returns Option<WrapRecord> — same shape as get_wrap, or null.
soroban contract invoke \
--id <CONTRACT_ID> \
-- \
balance_of \
--user <USER_ADDRESS>Returns an integer count of wraps for the user (e.g. 42).
soroban contract invoke \
--id <CONTRACT_ID> \
-- \
verify_data \
--user <USER_ADDRESS> \
--period <PERIOD> \
--data <DATA_HEX>Returns true if sha256(data) matches the stored data_hash, otherwise false.
Mint signatures are verified over a canonical payload that binds the request to:
- a domain separator (
stellar-wrap-v1) - the deploying contract instance address
- the target user address
- the period (
YYYYMM) - the archetype symbol
- the data hash
The payload is constructed by concatenating the XDR-encoded fields in the order above. Off-chain signers should use the same byte layout when creating signatures:
- encode the domain separator as raw bytes
- append the XDR encoding of the contract address
- append the XDR encoding of the user address
- append the XDR encoding of the period as
u64 - append the XDR encoding of the archetype symbol
- append the XDR encoding of the 32-byte data hash
This ensures that a signature for one contract instance cannot be replayed against another deployment with the same admin key.
Successful wrap mints emit one event:
- Topic 0:
mint(Symbol) - Topic 1:
user(Address) - The wallet address that received the wrap - Topic 2:
period(u64) - The period inYYYYMMformat (e.g.,202401) - Data:
archetype(Symbol) - The wrap archetype identifier
Example values:
- Topic 0:
mint - Topic 1:
GD5...(32-byte Stellar address) - Topic 2:
202401 - Data:
arch(or any short symbol)
Properties relevant to indexers:
- The event is emitted only after signature verification and storage writes succeed
- Duplicate
(user, period)mints are rejected, so one event equals one successful new wrap periodis always a validatedYYYYMMvalue (year: 2024-2100, month: 01-12)
Successful admin rotations emit one event:
- Topic 0:
admin(Symbol) - Topic 1:
updated(Symbol) - Data:
(old_admin, new_admin)(Address,Address) — previous admin and newly assigned admin
Example values:
- Topic 0:
admin - Topic 1:
updated - Data:
(GOLDADMIN..., GNEWADMIN...)
Properties relevant to indexers:
- The event is emitted only after the current admin authorizes the call and storage is updated
- Indexers can track admin rotations without polling
get_admin(e), but should still verify the live admin via that query when enforcing privileged flows
Revoke functionality is not implemented in this contract. Wraps are non-transferable and permanent once minted.
get_wrap(e, user, period)to retrieve full wrap recordbalance_of(e, user)to get total wrap count for a user
Issue #68 is implemented as an off-chain leaderboard strategy.
- Language: Rust
- Smart Contract Framework: Soroban SDK v21.7.1
- Build Tool: Cargo
- Target: WebAssembly (WASM) for Soroban runtime
- Testing: Soroban SDK testutils
Note: Dependency versions are pinned exactly (
=21.7.1) inCargo.toml. For reproducible builds, always build against the committedCargo.lock(runcargo build --locked/cargo test --locked) rather than letting Cargo re-resolve versions.
Reasoning:
- Soroban storage does not support efficient range scans for ranking
- maintaining an on-chain sorted top-N list would add write amplification and higher gas costs to every mint
- indexers already need mint events for analytics, so leaderboard aggregation fits the existing data flow
Recommended aggregation rule:
- index every
mintevent - group by topic 1 (
user) - count events per user
- sort descending by count to produce the leaderboard
Required tools:
- Rust and Cargo (for building)
- Stellar CLI (
stellar) - installation guide - Make (optional, for using the Makefile)
Required accounts:
- Deployer account with XLM on testnet (for paying deployment fees)
- Admin address (public Stellar address that will control the contract)
- Ed25519 signing key (private key used to sign mint payloads)
- Admin address: Public Stellar address stored on-chain for authorization
- Ed25519 signing key: Private key used to sign mint payloads (never stored on-chain)
- Keep the Ed25519 private key secure - it can authorize unlimited mints
# Using Make
make build
# Or using cargo directly
cargo build --release --target wasm32-unknown-unknownThis produces the WASM file at target/wasm32-unknown-unknown/release/stellar_wrap_contract.wasm.
Set your deployer secret key as an environment variable:
export STELLAR_DEPLOYER_SECRET="S..."Deploy the contract:
# Using Make
make deploy-testnet
# Or using stellar CLI directly
stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/stellar_wrap_contract.wasm \
--network testnet \
--source "$STELLAR_DEPLOYER_SECRET"Save the contract ID output - you'll need it for initialization.
You need:
CONTRACT_ID: From step 2ADMIN_ADDRESS: Your admin Stellar address (public)ADMIN_PUBKEY: The 32-byte public key of your Ed25519 signing key
To get your Ed25519 public key from your private signing key:
# If you have the private key in hex format
# This is a placeholder - use your actual Ed25519 key generation tool
# The public key is 32 bytesInitialize the contract:
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source "$STELLAR_DEPLOYER_SECRET" \
-- initialize \
--admin <ADMIN_ADDRESS> \
--admin_pubkey <ADMIN_PUBKEY_HEX>You need to sign a payload with your Ed25519 signing key. The payload includes:
- Contract address
- User address (who will receive the wrap)
- Period (YYYYMM format)
- Archetype (symbol)
- Data hash (SHA-256 of your wrap data)
Example using a signing script (you'll need to implement this based on your Ed25519 library):
# 1. Prepare your data and hash it
echo '{"score":100,"level":"gold"}' > data.json
DATA_HASH=$(sha256sum data.json | cut -d' ' -f1)
# 2. Sign the payload with your Ed25519 private key
# (Use your preferred Ed25519 signing tool)
SIGNATURE=$(sign-payload \
--contract <CONTRACT_ID> \
--user <USER_ADDRESS> \
--period 202401 \
--archetype "arch" \
--data_hash $DATA_HASH \
--private-key <ED25519_PRIVATE_KEY>)
# 3. Mint the wrap
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source <USER_ADDRESS_SECRET> \
-- mint_wrap \
--user <USER_ADDRESS> \
--period 202401 \
--archetype "arch" \
--data_hash $DATA_HASH \
--signature $SIGNATUREQuery the contract to verify the wrap was minted:
stellar contract read \
--id <CONTRACT_ID> \
--network testnet \
-- get_wrap \
--user <USER_ADDRESS> \
--period 202401This runbook covers the end-to-end process of upgrading a deployed contract to a new WASM version: building, uploading, capturing the WASM hash, invoking the admin-authorized upgrade, and validating the result.
Upgrading a Soroban contract replaces its executable code while preserving all storage (wrap records, admin config, migration state, etc.). No data is lost during the upgrade.
The contract exposes an upgrade(new_wasm_hash) function that:
- Verifies the contract has been initialized (
NotInitializedotherwise). - Requires authorization from the admin address (
Unauthorizedotherwise). - Emits an
upgradeaudit event containing the requested WASM hash. - Calls
e.deployer().update_current_contract_wasm(new_wasm_hash)to replace the code.
The upgrade function is defined in src/admin.rs. The contract code is
implemented in src/lib.rs.
Storage is preserved. Any changes to the storage layout must be shipped as a numbered migration via
migrate(version)— see the [Upgrade compatibility] section below.
- Stellar CLI (
stellar) — installation guide - Admin secret key for the deployed contract (the same address passed as
admintoinitialize()) - Deployer account with XLM to cover the upload and invocation fees
- The upgrade runbook assumes testnet; for mainnet replace
--network testnetwith--network mainnetthroughout.
make build
# or: cargo build --release --target wasm32-unknown-unknownThe WASM artifact is at:
target/wasm32-unknown-unknown/release/stellar_wrap_contract.wasm
Reproducible builds: Always build against the committed
Cargo.lock(cargo build --locked) to ensure the WASM hash matches across environments. The Dockerfile provides a fully isolated build:make docker-build
Upload the new WASM to the network. The CLI returns the WASM hash (a 32-byte hex-encoded SHA-256 of the WASM blob):
stellar contract upload \
--wasm target/wasm32-unknown-unknown/release/stellar_wrap_contract.wasm \
--network testnet \
--source <ADMIN_SECRET_KEY>Save the returned WASM hash. It will be passed as new_wasm_hash to the
upgrade() function in the next step.
Alternatively, using the Makefile:
export STELLAR_DEPLOYER_SECRET="S..." # or the admin secret
export CONTRACT_ID="<EXISTING_CONTRACT_ID>"
make deploy-testnetThe Makefile target prints the WASM hash to stdout. Capture it from the output.
Call the contract's upgrade function with the captured WASM hash:
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source <ADMIN_SECRET_KEY> \
-- \
upgrade \
--new_wasm_hash <WASM_HASH_HEX>Authorization: The --source account must match the admin address stored in
the contract. If it does not, the invocation panics with Unauthorized (code 3).
Failure mode — wrong WASM hash: If the hash does not correspond to a WASM blob previously uploaded on the same network, Soroban rejects the upgrade with a host error. The contract state is not modified — storage remains intact.
The health endpoint should still report the contract as initialized:
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source <ADMIN_SECRET_KEY> \
-- \
healthExpected output:
{"initialized": true, "has_admin": true, "has_signing_key": true}Existing wrap records must be readable after the upgrade:
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- \
get_wrap \
--user <USER_ADDRESS> \
--period <PERIOD>If records existed before the upgrade, they should still be returned. If no
records exist (fresh contract), this returns null.
If the new code introduces a storage migration, call migrate immediately
after the upgrade, in the same transaction batch if possible:
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source <ADMIN_SECRET_KEY> \
-- \
migrate \
--version <NEXT_VERSION>Verify the migration was applied:
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- \
migration_versionExpected output: NEXT_VERSION (or whatever version was passed to migrate).
If
migrateis called twice with the same version, it panics withMigrationAlreadyApplied(code 7) — see ERRORS.md.
Mint a new wrap to confirm the upgraded code handles write operations correctly:
# Follow the minting instructions in the testnet deployment walkthrough above| Symptom | Likely cause | Resolution |
|---|---|---|
Error(Contract, #2) — NotInitialized |
Contract has not been initialize()'d |
Call initialize(admin, admin_pubkey) first |
Error(Contract, #3) — Unauthorized |
--source is not the admin address |
Use the correct admin secret key |
HostError: ...wasm hash... |
WASM hash does not match any uploaded blob | Re-upload the WASM and verify the hash |
Error(Contract, #7) — MigrationAlreadyApplied |
migrate called twice with same version |
Check migration_version() first; this is not a real error |
| Contract behaves the same as before | Storage is preserved — expected behavior. Check the event log for the upgrade audit event |
Confirm the upgrade event was emitted: stellar contract event --id <CONTRACT_ID> |
| Unexpected storage behaviour | The new code changed a DataKey variant or record shape without a migration |
Add a migration step and re-upgrade |
An upgrade replaces contract code while keeping storage, so any change to the storage layout must ship as a numbered migration:
DataKey::MigrationVersionstores the highest migration version applied (0before any migration).migrate(version)is admin-only and only accepts a version greater than the stored one, so a migration can never run twice — a replay panics withMigrationAlreadyApplied(code 7).- Additive changes (new
DataKeyvariants, new methods) need no migration; changing or removing the shape of an existing key does, and the new code must bump the migration version. - Call
migratein the same transaction batch as the upgrade, and verify withmigration_version().
- The upgrade function is admin-only. If the admin keypair is compromised, an attacker can replace the contract WASM. Consider a time-lock or multi-sig admin for production deployments.
- Storage is never wiped. Sensitive data stored by a previous version
remains accessible after upgrade. Ensure the new code handles all existing
DataKeyvariants gracefully. - Audit trail. Every upgrade emits an
upgradeevent with the new WASM hash. Indexers and monitoring tools should watch for unexpected upgrade events. The event topic isupgradewith data being the new WASM hash. - Rollback. To revert an upgrade, build and upload the previous WASM, then
invoke
upgradewith the old WASM hash. Storage is preserved across rollbacks as well.
- Canonical signed payload encoding — exact field order, XDR encoding rules, and test vectors required by backend signing services (issue #213)
- Admin rotation procedure — safe procedure for rotating the admin address and signing pubkey, including verification, event monitoring, and rollback plan
The toolchain is pinned in rust-toolchain.toml (Rust 1.94.1 with the
wasm32-unknown-unknown target), so local, Docker, and CI builds match. With
rustup installed, the correct toolchain is selected automatically.
The frontend/ directory contains a React dApp for connecting
Freighter, reading a deployed contract, looking up wallet wraps, and submitting
signed mint_wrap transactions. See frontend/README.md
for configuration, architecture, security boundaries, and verification
commands.
Run the test suite with:
- Rust – install via rustup. The project targets a recent stable toolchain.
- wasm32 target – add the WebAssembly compilation target:
rustup target add wasm32-unknown-unknown
- Stellar CLI (recommended) – install from the Stellar soroban-cli releases or via
cargo:Alternatively, install the legacy Soroban CLI:cargo install stellar-cli
cargo install soroban-cli
| Action | Command |
|---|---|
| Format | cargo fmt |
| Format check (CI) | cargo fmt --check or make fmt-check |
| Lint | cargo clippy -- -D warnings or make lint |
| Test | cargo test or make test |
Fuzz mint_wrap |
make fuzz FUZZ_SECONDS=30 |
| Release build (WASM) | cargo build --release --target wasm32-unknown-unknown or make build |
| Deploy to testnet | make deploy-testnet |
| Docker reproducible build | make docker-build or docker build -t stellar-wrap-contract . |
See the Makefile for the full list of targets (make help).
This repo ships a cargo-fuzz target that
stresses mint_wrap with adversarial periods, hashes, and signatures
(fuzz/fuzz_targets/fuzz_mint_wrap.rs).
Prerequisites:
rustup install nightly
rustup component add rust-src --toolchain nightly
cargo install --locked cargo-fuzzBuild / run (ThreadSanitizer + build-std is required on macOS):
make fuzz-build
make fuzz FUZZ_SECONDS=30
# equivalent:
cargo +nightly fuzz run --sanitizer=thread --build-std fuzz_mint_wrap -- -max_total_time=30Invariants checked by the harness:
- Invalid periods never persist a wrap or change balances
- Rogue signatures never mint
- A valid admin signature + valid period mints exactly once
- Reminting the same
(user, period)always fails without changing balance
"target wasm32-unknown-unknown not installed"
rustup target add wasm32-unknown-unknownBuild the WASM artifact with:
cargo build --release --target wasm32-unknown-unknownSDK / toolchain mismatch errors (e.g. package \soroban-sdk` cannot be built because it requires a different Rust version`)
The Soroban SDK often tracks Rust nightly or a specific stable release. If you see version conflicts:
- Verify your Rust version matches what the lockfile expects:
rustup show rustup update stable
- If the SDK pins a nightly, install and use it:
rustup install nightly-YYYY-MM-DD rustup target add wasm32-unknown-unknown --toolchain nightly-YYYY-MM-DD cargo +nightly-YYYY-MM-DD build --release --target wasm32-unknown-unknown
- Clean stale artifacts before switching toolchains:
cargo clean
WASM build fails with link errors
Ensure wasm32-unknown-unknown is the active target and no host-specific native dependencies leak in. The Dockerfile provides a fully isolated environment for reproducible WASM builds.
Before deploying to mainnet, review the release checklist in MAINNET_RELEASE_CHECKLIST.md. It covers tests, optimized builds, release artifact hash verification, signer backup, initialization, and rollback guidance.
The contract includes gas analysis tests that measure CPU instructions and memory usage of mint operations. These tests always run assertions on resource bounds, but detailed budget tables are suppressed during normal test runs to keep CI output clean.
To run tests with full gas budget reporting:
make test-gas-report
# or
SOROBAN_GAS_REPORT=1 cargo test -- --nocaptureNote: The Soroban test framework automatically creates snapshot files under
test_snapshots/during test execution. These are already in.gitignoreand can be cleaned up withmake clean-snapshots.
The contract includes a DAO governance module for updating the contract's admin address via community/on-chain proposals.
- Create Proposal: Call
create_admin_proposal(proposer, proposed_admin, duration_seconds). Generates a proposal inActivestatus. - Cast Votes: Accounts vote via
vote_admin_proposal(voter, proposal_id, support). Double voting is prevented. - Execute Proposal: After
duration_secondselapses, callexecute_admin_proposal(proposal_id). Ifvotes_for > votes_against, the contract admin updates toproposed_admin. - Cancel Proposal: Proposer or current admin can cancel active proposals via
cancel_admin_proposal(caller, proposal_id).