See also: SECURITY.md for vulnerability reporting.
This guide covers safe integration patterns, key management, and known attack vectors for the Token and Escrow contracts in this repository.
| Actor | Trusted for |
|---|---|
| Admin | Minting new tokens, burning tokens, transferring admin role |
| Token holder | Approving allowances, transferring their own balance |
| Spender | Spending up to the approved allowance on behalf of a holder |
The admin key is the single most sensitive credential. Compromise of the admin key allows unlimited minting and therefore complete devaluation of the token.
| Actor | Trusted for |
|---|---|
| Buyer | Funding the escrow, approving delivery, requesting a refund after deadline, cancelling before funding |
| Seller | Marking goods/services as delivered |
| Arbiter | Resolving disputes by releasing funds to either party |
No single actor can unilaterally move funds without the contract enforcing the correct state machine transition.
- Use a hardware wallet (Ledger, Trezor) or a multi-sig account for the token admin key. A single hot-wallet key is not acceptable for production.
- Key rotation — use the two-step
propose_admin/accept_adminflow to rotate the admin key. The pending admin must explicitly callaccept_adminbefore the transfer takes effect, preventing accidental loss of admin access from a typo in the new address. - Never reuse keys across environments. Use separate deployer accounts for local, testnet, and mainnet.
- Revoke access immediately when a team member leaves. Rotate the admin key
via
propose_adminbefore the old key is considered compromised. - Escrow arbiter — the arbiter address is set at initialization and cannot be changed. Choose an arbiter that is independent of both buyer and seller. A multi-sig arbiter is strongly recommended for high-value escrows.
- Do not store secret keys in
.envfiles committed to version control. Use.env.examplewith placeholder values and add.envto.gitignore. - Do not log secret keys in CI/CD pipelines. Store them as encrypted secrets (e.g., GitHub Actions secrets) and reference them by name only.
- Do not transmit secret keys over HTTP, WebSockets, or any unencrypted channel.
- Use Freighter, Albedo, or another browser wallet for frontend signing so the secret key never leaves the user's device.
- Audit your dependencies with
cargo auditandnpm auditregularly to catch packages that may exfiltrate secrets.
Soroban transactions include the ledger sequence number of the source account, which the network increments after every successful transaction. This makes replaying a previously signed transaction impossible once the sequence number has advanced.
Integrator checklist:
- Always fetch a fresh account sequence number with
server.getAccount()before building a transaction. Cached sequence numbers causetx_bad_seqerrors and can, in edge cases, allow a stale transaction to be resubmitted. - Set a tight
setTimeout(30–60 seconds) on every transaction so that a delayed broadcast cannot be replayed after the window closes. - For allowance-based flows (
approve+transfer_from), setexpiration_ledgerto the minimum ledger at which the allowance is no longer needed. An open-ended allowance is a standing replay vector.
Escrow deadlines are expressed as ledger sequence numbers. Because ledger close times vary (~5 s average), a deadline expressed in ledgers is approximate.
Risks:
- A seller can observe a pending
request_refundtransaction in the mempool and attempt to front-run it withmark_deliveredbefore the refund is confirmed. - A buyer can observe a pending
approve_deliveryand attempt to cancel or request a refund in the same ledger.
Mitigations:
- Add a generous buffer (at least 100–200 ledgers ≈ 8–16 minutes) beyond the
expected delivery time when setting
deadline_ledger. - Use the arbiter flow for high-value escrows so that neither party can unilaterally resolve a disputed state.
- Monitor the mempool and use fee bumping to prioritize time-sensitive transactions.
The upgradeable feature enables WASM replacement via propose_upgrade /
execute_upgrade. A timelock (UPGRADE_DELAY_LEDGERS) is enforced between
proposal and execution so that users have time to review and exit before a
potentially malicious upgrade takes effect.
Best practices:
- Announce upgrades publicly (Discord, Twitter, governance forum) before calling
propose_upgrade. Give users at least as much notice as the timelock duration. - Verify the new WASM hash on-chain after deployment:
stellar contract info --id <CONTRACT_ID>
- Never upgrade directly on mainnet without a full testnet rehearsal.
- Consider a governance vote (DAO or multi-sig) as the gating mechanism for
propose_upgraderather than a single admin key. - After a successful upgrade, emit an event and update your monitoring infrastructure with the new WASM hash.
All state-changing operations emit Soroban events. Subscribe to these events and alert on unexpected patterns:
| Event topic | Contract | Anomaly signal |
|---|---|---|
mint |
Token | Large or unexpected mint |
burn |
Token | Burn exceeding expected volume |
admin_changed |
Token | Any admin change not initiated by your team |
upgrade_proposed |
Token / Escrow | Any upgrade proposal |
upgrade_executed |
Token / Escrow | Upgrade executed (verify WASM hash) |
dispute_raised |
Escrow | Spike in disputes |
funds_released |
Escrow | Release to unexpected address |
Tooling:
// Poll for events using the Stellar SDK
const events = await server.getEvents({
startLedger: lastProcessedLedger,
filters: [{ contractIds: [TOKEN_CONTRACT_ID], topics: [['mint']] }],
});Set up alerts (PagerDuty, Slack webhook) for any admin_changed or
upgrade_proposed event that was not initiated by your own infrastructure.
Soroban contracts execute in a single-threaded, deterministic VM. Cross-contract calls are synchronous and the host does not allow re-entrant invocations of the same contract instance within a single transaction. However, the Checks → Effects → Interactions pattern is still followed as a best practice: state is updated before any outbound token transfer.
Soroban uses a rent model: storage entries expire if their TTL reaches zero.
- Both contracts bump instance TTL on every write
(
BUMP_THRESHOLD ≈ 7 days,BUMP_AMOUNT ≈ 30 days). - If an escrow is ignored for more than ~30 days, instance storage may expire.
The public
bump()function can be called by anyone to extend the TTL. - Integrators should monitor active escrows and call
bump()proactively.
| Limitation | Impact | Suggested Mitigation |
|---|---|---|
| No arbiter replacement | Compromised arbiter cannot be removed | Deploy a new escrow; add governance layer |
| Single-token escrow | Only one token type per escrow | Deploy multiple escrows for multi-token deals |
| Deadline is ledger-sequence based | Ledger close times vary | Add a generous buffer when setting deadlines |
| Threat | Likelihood | Impact | Mitigated by |
|---|---|---|---|
| Admin key compromise (token) | Low | Critical | Hardware wallet, multi-sig, two-step transfer |
| Arbiter collusion | Medium | High | Reputable arbiter, multi-sig |
| Storage expiry of live escrow | Low | High | bump() monitoring |
| Front-running escrow deadline | Low | Medium | Generous deadline buffer, arbiter flow |
| Integer overflow | Very Low | High | checked_add / checked_sub throughout |
| Reentrancy | Very Low | High | CEI pattern enforced |
| Malicious WASM upgrade | Low | Critical | Upgrade timelock, governance gating |
Please do not open a public GitHub issue for security vulnerabilities. See SECURITY.md for the responsible disclosure process.