Step-by-step instructions for deploying trustbridge-contract to Stellar Testnet and Mainnet.
Related docs: README · ARCHITECTURE · ABI · CONTRACT_HEALTH · FUTURENET_ONBOARDING
- Rust ≥ 1.84 with
wasm32v1-nonetarget - Stellar CLI ≥ 26.x (recommended)
- A funded Stellar account on the target network
rustup target add wasm32v1-none
curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | shCopy .env.example to .env and configure:
| Variable | Required | Description |
|---|---|---|
NETWORK |
No | testnet (default), mainnet, or futurenet |
ADMIN |
Yes | G-address of contract admin |
SOURCE |
No | Stellar CLI identity name (default: default) |
ALIAS |
No | CLI contract alias (default: trustbridge) |
INIT |
No | Auto-initialize after deploy (default: true) |
stellar keys generate deployer --network testnet --fund
stellar keys use deployer
export ADMIN=$(stellar keys address deployer)The Friendbot funds testnet accounts automatically via --fund.
make build
# Output: target/wasm32v1-none/release/trustbridge-contract.wasmThe optimized release WASM must remain at or below 204,800 bytes (200 KiB).
This is the deployment budget enforced by CI and mirrored by the Makefile's
WASM_SIZE_LIMIT value. The check uses the same release artifact produced by
stellar contract build, preferring wasm32v1-none and falling back to
wasm32-unknown-unknown.
Inspect the current artifact locally with:
make wasm-sizeCI runs this check immediately after the release build. A size increase beyond
the budget fails the job; intentional increases must update the documented
budget, WASM_SIZE_LIMIT in the Makefile, and the CI limit together.
make deploy-testnet
# or:
NETWORK=testnet ADMIN=$ADMIN SOURCE=deployer ./scripts/deploy.shThe script:
- Builds WASM if missing
- Runs
stellar contract deploy - Calls
initialize(admin) - Writes
deployments/testnet.json
export CONTRACT_ID=$(jq -r .contract_id deployments/testnet.json)
stellar contract invoke \
--id $CONTRACT_ID \
--source-account deployer \
--network testnet \
-- get_stats
# Expected: { "total": 0, "verified": 0 }A repeatable checklist to validate every release build against testnet. See TESTNET_CHECKLIST.md for the full numbered steps.
Required environment variables:
| Variable | Required | Description |
|---|---|---|
NETWORK |
Yes | Must be testnet |
ADMIN |
Yes | G-address of the contract admin |
SOURCE |
Yes | Funded testnet CLI identity |
CONTRACT_ID |
After deploy | Recorded from deployments/testnet.json |
The checklist covers deploy → initialize → register → verify → export → remove, ending with cleanup. Defaults are safe: NETWORK defaults to testnet, so a mainnet run requires explicit configuration.
Maintainers can opt into the invoke-only live testnet smoke job by setting the
repository variable TRUSTBRIDGE_LIVE_TESTNET=true. Configure the existing
testnet contract and a funded source account using these values:
- Secret
TRUSTBRIDGE_TESTNET_CONTRACT_ID: pre-deployed testnet contract ID. - Secret
TRUSTBRIDGE_TESTNET_SECRET_KEY: source account secret key, used only to create an ephemeral CI identity. - Variable
TRUSTBRIDGE_TESTNET_USERNAME: an existing username to query.
The job runs get_stats and get_address against testnet; it does not deploy
or initialize anything. It is restricted to non-pull-request events in the
canonical repository, which prevents fork PRs from accessing organization
secrets. Enabling the variable without all required values fails the job
immediately rather than silently skipping the smoke.
Before every mainnet deploy, complete both confirmation steps:
-
Build hash pin — confirm the WASM hash matches the pinned value in
wasm-hash.pin:make wasm-hash-pin # or manually: sha256sum target/wasm32v1-none/release/trustbridge-contract.wasmCI enforces this check automatically via the Compute and verify WASM hash pin step. If the hash has intentionally changed (new release), run
make wasm-hash-update, commit the updatedwasm-hash.pin, and include before/after hashes in the PR description. Record the final hash in your deploy runbook and verify it against the CI build artifact. -
Human confirmation — set
CONFIRM_MAINNET=yesto proceed:export CONFIRM_MAINNET=yes make deploy-mainnetThe
deploy-mainnetMakefile target refuses to run unlessCONFIRM_MAINNETis set toyes. This prevents accidental mainnet invocations from a defaultmakerun.
After deployment, verify the contract is initialized and operational:
export CONTRACT_ID=$(jq -r .contract_id deployments/mainnet.json)
# Confirm the contract is initialized
stellar contract invoke \
--id $CONTRACT_ID \
--source_account deployer \
--network mainnet \
-- get_stats
# Expected: { "total": 0, "verified": 0 }
# Confirm the deployed WASM hash matches the pinned build hash
stellar contract get_wasm_hash \
--id $CONTRACT_ID \
--network mainnetChecklist before mainnet:
- Admin address reviewed (prefer multisig)
- WASM built from a tagged release commit
- Build hash pinned and recorded
-
CONFIRM_MAINNET=yesexplicitly set -
cargo testand CI green on that commit - Contract ID recorded in
deployments/mainnet.json - TTL extension plan documented for persistent entries
When rotating the WASM hash, put the contract into pause mode first so the upgrade window behaves as read-only for integrators:
- Call
set_paused(true)as admin. - Publish or apply the new WASM upgrade.
- Verify the new binary with the existing upgrade checks in ABI.md and the deployment script flow in scripts/deploy.sh.
- Call
set_paused(false)once the upgrade is confirmed healthy.
During this window, lookups remain safe, but mutation entry points reject with
the existing pause error. In practice that means dashboards and indexers can
keep using get_address, get_stats, and the export/pagination reads, while
register, remove, verify, pause, unpause, set_role, remove_role,
set_cooldown, attest_upgrade, clear_attestation, and upgrade are
expected to fail fast until the contract is unpaused.
This mode is an operator procedure, not a new ABI surface, so it does not change the public contract interface.
During Wave onboarding spikes, manual one-off verify calls do not scale. Use
scripts/bulk_verify.sh (or the Make targets) to verify a list of usernames from a file.
SOURCE must be the admin or hold Role::Verifier on the contract. Verify off-chain
GitHub identity for each username before running the bulk verify.
The default pace is 500 ms between invocations. For large batches (>50 usernames) or when
hitting HTTP 429 responses, increase --pace-ms to 1000–2000 ms.
# Create a file with one username per line
echo -e "octocat\nsome-contributor" > usernames.txt
# Dry-run (no transactions, confirm the list)
make bulk-verify-dry-run CONTRACT_ID=C... SOURCE=admin-identity NETWORK=testnet
# Execute with audit log
make bulk-verify CONTRACT_ID=C... SOURCE=admin-identity NETWORK=testnet \
BULK_VERIFY_FILE=usernames.txt BULK_VERIFY_LOG=verify-audit.log
# Increase pacing for large batches
make bulk-verify CONTRACT_ID=C... SOURCE=admin-identity NETWORK=testnet \
BULK_VERIFY_PACE=1000Or call the script directly:
bash scripts/bulk_verify.sh \
--file usernames.txt \
--contract $CONTRACT_ID \
--source admin-identity \
--network testnet \
--dry-run
bash scripts/bulk_verify.sh \
--file usernames.txt \
--contract $CONTRACT_ID \
--source admin-identity \
--network testnet \
--continue-on-error \
--pace-ms 500 \
--audit-log verify-audit.logAudit log lines are emitted to stdout and written to --audit-log file:
{"timestamp":"2026-01-01T00:00:00Z","username":"octocat","network":"testnet","result":"ok","detail":"verified"}| Target | Description |
|---|---|
make deploy-testnet |
Build + deploy to testnet |
make deploy-mainnet |
Build + deploy to mainnet (requires CONFIRM_MAINNET=yes) |
make invoke-init |
Initialize an existing contract |
make invoke-register |
Register a username |
make invoke-lookup |
Read-only lookup |
make invoke-stats |
Read statistics |
make invoke-verify |
Verify a contributor (admin or verifier role) |
make invoke-revoke-verification |
Revoke verification (admin or verifier role) |
make bulk-verify-dry-run |
Dry-run bulk verify from BULK_VERIFY_FILE |
make bulk-verify |
Bulk verify with pacing and audit log |
make bulk-revoke-dry-run |
Dry-run bulk revoke from BULK_REVOKE_FILE |
make bulk-revoke |
Bulk revoke with audit log |
make testnet-checklist |
Run the testnet smoke checklist |
make demo-e2e |
Run the cross-repo E2E demo (register → verify → lookup → export) |
Example registration:
export CONTRACT_ID=C...
make invoke-register GITHUB_USER=octocat STELLAR_ADDR=G... SOURCE=deployerEquivalent raw CLI invocation:
stellar contract invoke --id $CONTRACT_ID \
--source-account deployer \
--network testnet \
--send=yes \
-- register \
--github-username octocat \
--stellar-address G...register requires the source account to authenticate as stellar-address. If the
username is already registered to a different address, that previous address must
also sign. See ABI.md for full auth requirements and failure modes.
NETWORK=testnet \
ADMIN=GABC... \
SOURCE=deployer \
ALIAS=trustbridge \
INIT=true \
./scripts/deploy.sh| Flag | Default | Description |
|---|---|---|
NETWORK |
testnet |
Target network |
ADMIN |
— | Required admin G-address |
SOURCE |
default |
Signing identity |
ALIAS |
trustbridge |
CLI alias for contract ID |
INIT |
true |
Call initialize after deploy |
Two operator scripts cover backups, dashboard migrations, and audit snapshots of the registry, without giving up the on-chain data as the source of truth.
scripts/export_registry.sh pages through the admin-only
get_registered_paginated and writes a single JSON file with a stable schema.
| Variable | Required | Description |
|---|---|---|
CONTRACT_ID |
Yes | Deployed contract ID |
SOURCE |
Yes | Stellar CLI identity of the contract admin — get_registered_paginated is admin-gated |
NETWORK |
No | testnet (default), mainnet, or futurenet |
OUTPUT_FILE |
No | Output path (default: registry-export-<network>.json) |
PAGE_LIMIT |
No | Records per page (default: 100, the contract's MAX_PAGE_LIMIT) |
CONTRACT_ID=$CONTRACT_ID SOURCE=admin NETWORK=testnet ./scripts/export_registry.sh
# or:
make export-registry CONTRACT_ID=$CONTRACT_ID SOURCE=adminThe script fails with a clear error and a non-zero exit code if CONTRACT_ID,
SOURCE, the Stellar CLI, or jq are missing.
Output schema (stable field names for dashboard/indexer consumers):
{
"schema_version": 1,
"contract_id": "C...",
"network": "testnet",
"exported_at": "2026-01-01T00:00:00Z",
"count": 2,
"records": [
{
"github_username": "octocat",
"stellar_address": "G...",
"verified": true,
"registered_at": 1732800000
}
]
}Import does not bypass on-chain auth. scripts/validate_registry.sh never
writes to the contract — it validates an export file against live state,
which covers staging restores and migration dry-runs.
| Variable | Required | Description |
|---|---|---|
CONTRACT_ID |
Yes | Deployed contract ID |
NETWORK |
No | testnet (default), mainnet, or futurenet |
SOURCE |
No | Identity for per-record reads (default: default); get_address needs no auth, so any funded identity works |
ADMIN_SOURCE |
No | Admin identity; when set, also detects on-chain registrations missing from the export via admin-gated get_registered_paginated |
PAGE_LIMIT |
No | Records per page for the admin-side check (default: 100) |
CONTRACT_ID=$CONTRACT_ID NETWORK=testnet ./scripts/validate_registry.sh registry-export-testnet.json
# or, for the full two-way diff:
make validate-registry CONTRACT_ID=$CONTRACT_ID ADMIN_SOURCE=admin EXPORT_FILE=registry-export-testnet.jsonThe script reports every mismatch it finds and exits 1 if any are present,
0 if the export matches live state exactly, 2 on a usage or config error
(missing file, malformed JSON, missing CONTRACT_ID, etc.):
| Diff type | Meaning |
|---|---|
MISSING_ONCHAIN |
Export has the username; the contract does not |
ADDRESS_MISMATCH |
stellar_address differs between export and chain |
VERIFIED_MISMATCH |
verified flag differs between export and chain |
MISSING_FROM_EXPORT |
Contract has the username; the export file does not (ADMIN_SOURCE only) |
Safety warning: this tool is validate-only by design. It does not replay
writes. Never use an export file to blindly overwrite mainnet state —
register/verify/revoke_verification all require the appropriate signer
to authorize each call individually, so a "replay" is a reviewed, one-by-one
series of ordinary invocations (e.g. make invoke-register), not a bulk
import. Treat scripts/validate_registry.sh output as the checklist for that
review, run it against testnet first, and never against mainnet without a
human reading every reported diff.
rustup target add wasm32v1-nonesoroban-sdk 26.x requires wasm32v1-none. Use make build (Stellar CLI) instead of legacy cargo target.
The --source-account must match the address that signed the auth payload. For register, source must own stellar_address. For remove, source must match caller.
Ensure the source account is funded on the target network:
stellar keys fund deployer --network testnetRun initialize manually:
make invoke-init CONTRACT_ID=$CONTRACT_ID ADMIN=$ADMINOperators can estimate register resource costs before committing funds, using the
stellar contract invoke simulation path (no --send=yes). This is the recommended way
to set Wave invoke budgets before contributors hit the contract at scale.
Works without spending funds. Simulation runs locally against the current ledger state. No transaction is submitted and no fees are charged.
| Target | Description |
|---|---|
make simulate-register |
Baseline: short username (octocat by default) |
make simulate-register-max |
Max-length: 39-character username |
make simulate-register-compare |
Both runs back-to-back, output to simulate-register-results.txt |
Prerequisites: CONTRACT_ID and STELLAR_ADDR must be set. The SOURCE account just
needs to exist on the network — it is not charged.
export CONTRACT_ID=C...
export STELLAR_ADDR=G... # the address that *would* be registered
# Baseline simulation
make simulate-register CONTRACT_ID=$CONTRACT_ID STELLAR_ADDR=$STELLAR_ADDR
# Max-length username
make simulate-register-max CONTRACT_ID=$CONTRACT_ID STELLAR_ADDR=$STELLAR_ADDR
# Compare both and write to file
make simulate-register-compare CONTRACT_ID=$CONTRACT_ID STELLAR_ADDR=$STELLAR_ADDROr call the CLI directly:
# Simulate register — no --send, no fees spent
stellar contract invoke \
--id $CONTRACT_ID \
--source-account deployer \
--network testnet \
-- register \
--github-username octocat \
--stellar-address $STELLAR_ADDRThe CLI prints a JSON-like block with at least these resource fields:
| Field | Description |
|---|---|
cpu_instructions |
Metered Wasm CPU cost for this invocation |
mem_bytes |
Metered memory footprint in bytes |
min_resource_fee |
Minimum fee in stroops (1 XLM = 10 000 000 stroops) |
read_bytes |
Bytes read from ledger entries |
write_bytes |
Bytes written to ledger entries |
Sample output interpretation (approximate, testnet only):
Simulation result:
cpu_instructions: 1_234_567
mem_bytes: 45_678
min_resource_fee: 9_876 stroops (~0.001 XLM)
A min_resource_fee of ~10 000 stroops means each register call costs roughly 0.001 XLM
at the simulated ledger state. Multiply by the expected number of Wave registrations to budget
the total fee pool.
Issue #111 calls for comparing baseline (short username) against the maximum-length (39-char)
username to measure the username-length delta on cpu_instructions and min_resource_fee.
Run the comparison and diff the results:
make simulate-register-compare CONTRACT_ID=$CONTRACT_ID STELLAR_ADDR=$STELLAR_ADDR
# Output written to simulate-register-results.txt
diff <(git show HEAD:simulate-register-results.txt) simulate-register-results.txt| Limitation | Impact |
|---|---|
| Simulation ≠ live execution | Fee shown is valid at simulation time; live fees can differ under load |
min_resource_fee is a floor |
Actual fee may be higher if ledger load is elevated |
| Auth simulation | The --source-account is simulated but not the registrant; fees are correct but the call would fail on a live network if stellar_address doesn't match source-account |
| No ledger commit | count and index updates are computed but not persisted; a re-simulation of a second register will show the same cost as the first |
| Rent fees change with upgrades | Re-simulate after protocol or fee schedule upgrades |
See STORAGE_RENT.md for how simulation fits into the broader rent estimation workflow.
- Publish the contract ID in the TrustBridge dashboard config
- Configure the GitHub Action with
CONTRACT_IDandNETWORK - Monitor events via a Stellar RPC endpoint or indexer
- Schedule TTL extensions for persistent storage entries on long-lived networks
- Wire production monitors to the probe sequence in CONTRACT_HEALTH.md (initialized?, admin set?, stats sane?, optional Horizon lag)
See SECURITY.md for operational security guidance.
The release WASM artifact is pinned by SHA-256 in wasm-hash.pin. CI fails the build when the
artifact hash does not match the pinned value, preventing silent deploy of a wrong or tampered WASM.
- CI builds the WASM via
stellar contract build. - The Compute and verify WASM hash pin CI step runs
sha256sumon the artifact. - The computed hash is compared to the value in
wasm-hash.pin. - A mismatch fails CI with clear instructions to update the pin.
# Verify the current build matches the pin:
make wasm-hash-pin
# After an intentional WASM change, update the pin:
make wasm-hash-update
git add wasm-hash.pin
git commit -m "chore: bump wasm-hash.pin after <feature>"After deploying, verify the uploaded WASM on Stellar Expert using the explorer for the same network. Stellar Expert displays the hash of the WASM code stored for the contract; compare it with the local SHA-256 hash.
- Set the deployed contract ID and choose the network used for the deployment:
export CONTRACT_ID=$(jq -r .contract_id deployments/testnet.json)
export NETWORK=testnetFor mainnet, use deployments/mainnet.json and set NETWORK=mainnet.
- Compute the hash of the exact release artifact that was deployed. Use the
wasm32v1-nonepath produced by the recommended Stellar CLI build:
WASM=target/wasm32v1-none/release/trustbridge-contract.wasm
sha256sum "$WASM"
grep -v '^#' wasm-hash.pin | grep -v '^$' | tr -d '[:space:]'The two SHA-256 values must match. If the legacy target was used, compute
the hash from target/wasm32-unknown-unknown/release/trustbridge-contract.wasm
instead. Do not hash a debug or rebuilt artifact that was not deployed.
- Open the matching contract page:
Append $CONTRACT_ID to the selected URL, or search for the contract ID in
Stellar Expert. Confirm that the contract address and network are correct.
- Open the contract's Code or WASM details and compare its displayed
SHA-256 hash with the value printed in step 2 and the committed
wasm-hash.pin. Record the contract ID, network, hash, release commit, and explorer URL in the deployment runbook.
Never compare a testnet contract with the mainnet explorer, or vice versa.
Explorer availability is a convenience check; CI's pinned hash check and the
local make wasm-hash-pin result remain the authoritative artifact checks.
- Make your contract changes and build:
make build. - Run
make wasm-hash-updateto rewritewasm-hash.pinwith the new hash. - Commit
wasm-hash.pinalongside the contract change. - Include before/after hashes in the PR description.
- CI will verify the committed pin matches the rebuilt artifact.
-
make wasm-hash-pinpasses locally on the release commit. -
wasm-hash.pincommitted hash matches the CI artifact hash shown in CI logs. - Hash recorded in deploy runbook before running
make deploy-mainnet.
Soroban charges upload fees proportional to WASM size, and the protocol imposes an upper limit. Keeping the binary small reduces deploy cost and makes upgrades cheaper.
| Metric | Value |
|---|---|
| Hard limit (CI gate) | 200 KB (204 800 bytes) |
| Typical release size | ~85 KB |
| Headroom | ~115 KB |
The hard limit is enforced by:
- CI: the WASM size regression gate step in
.github/workflows/ci.ymlfails the build whentrustbridge-contract.wasmexceedsWASM_SIZE_LIMIT. - Local:
make wasm-sizeruns the same check aftermake build.
make wasm-sizeOutput:
──────────────────────────────────────────
WASM size report
──────────────────────────────────────────
File : target/wasm32v1-none/release/trustbridge-contract.wasm
Size : 87040 bytes (~85 KB)
Limit : 204800 bytes (200 KB)
──────────────────────────────────────────
Headroom: 117760 bytes remaining
PASS: WASM size is within budget.
The optimised release WASM currently sits near 85 KB. 200 KB provides ~115 KB of headroom for intentional feature additions while still catching accidental bloat — e.g. a new dependency that pulls in an unintended transitive crate, or a build profile misconfiguration that disables LTO.
If intentional feature growth pushes the binary past 200 KB:
- Run
make wasm-sizelocally to measure the new size. - Round up to the nearest 10 KB and add ~20 KB of headroom to get the new ceiling.
- Update
WASM_SIZE_LIMITin both places (they must stay in sync):Makefile— theWASM_SIZE_LIMIT ?=variable.github/workflows/ci.yml— theWASM_SIZE_LIMIT:env variable
- Document the new limit and the feature that required the bump in this table.
- Include before/after sizes in the PR description.
WasmProvenance carries two digests beyond wasm_hash:
| Field | Meaning |
|---|---|
sbom_hash |
SHA-256 of the SBOM generated for this build |
source_hash |
SHA-256 of the source tree the WASM was built from |
Both are Option<BytesN<32>>. upgrade writes them as None — the chain cannot
know them — and the operator backfills them right after the upgrade:
stellar contract invoke \
--id $CONTRACT_ID --source-account admin-identity --network testnet --send=yes \
-- set_provenance_digests --sbom_hash $SBOM_SHA256 --source_hash $SOURCE_SHA256Passing None for either argument leaves the stored value untouched, so the two
can be filled in independently.
Migration. Provenance records written before these fields existed decode with
both digests absent, which reads as "not recorded" rather than "verified empty".
The migration is one set_provenance_digests call per deployed instance; there is
no automatic backfill, because only the operator holds the build outputs.
Suggested way to produce the values:
sha256sum target/wasm32v1-none/release/trustbridge_contract.wasm # wasm_hash
sha256sum sbom.spdx.json # sbom_hash
git archive --format=tar HEAD | sha256sum # source_hashassert_build(hash) compares a locally built hash against the hash in stored
provenance and fails with a typed error instead of a mismatched deploy:
| Situation | Result |
|---|---|
| Hash equals stored provenance | Ok(()) |
| No provenance recorded yet (never upgraded) | ProvenanceMissing (36) |
| Hash differs | ProvenanceMismatch (37) |
It is a read-only check on provenance only. It does not read, require, or
consume an upgrade attestation — attest_upgrade remains a separate, optional
control over the next upgrade, while assert_build describes what is deployed
now. On a contract that has never been upgraded there is nothing to compare
against, and the check fails closed rather than passing.
stellar contract build
HASH=$(sha256sum target/wasm32v1-none/release/trustbridge_contract.wasm | cut -d' ' -f1)
make assert-build CONTRACT_ID=$CONTRACT_ID WASM_HASH=$HASH