Soroban contract for storing non-transferable Stellar Wrap records by wallet and reporting successful wrap mints through events.
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/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)
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)
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) -> 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
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.
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)
The update_admin function does not emit an event. To track admin changes, indexers should:
- Query the
get_admin(e)function periodically - Store the current admin address and detect changes across queries
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 202401To upgrade an existing contract instead of deploying fresh:
export CONTRACT_ID="<EXISTING_CONTRACT_ID>"
make deploy-testnetThis will upload the new WASM without creating a new contract instance.
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(#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().
- Canonical signed payload encoding — exact field order, XDR encoding rules, and test vectors required by backend signing services (issue #213)
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.
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 |
| 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).
"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.