> For the complete documentation index, see [llms.txt](https://docs.trilobyte.finance/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.trilobyte.finance/technical-documentation/deployment.md).

# Deployment

Trilobyte contracts are deployed to Stellar with a single TypeScript script, `scripts/deploy-protocol.ts`, that drives the `stellar` CLI (`@stellar/stellar-sdk` handles keypair/address work). For the live testnet addresses, see [Testnet Deployment](/technical-documentation/testnet-deployment.md).

## Full Protocol Deployment

`deploy-protocol.ts` deploys **all seven contracts** and runs post-deploy wiring in one pass:

### Step 1 — Deploy Globals

```
Globals(governor, treasury, security_admin, operations_admin)
```

Globals is deployed first. `governor` becomes the OZ admin; `security_admin` and `operations_admin` are granted their roles in the constructor.

### Step 2 — Deploy Timelock

```
Timelock(min_delay, proposers, executors, admin: None)
```

Self-administered. `min_delay` is in **ledgers**, not seconds. Proposers are also granted the canceller role; an empty executor list means anyone can execute.

### Step 3 — Deploy CollateralEscrow, ComplianceAllowlist, WebAuth

```
CollateralEscrow(globals)
ComplianceAllowlist(admin)
WebAuth()   // no constructor args
```

All three are deployed as part of the same run. `ComplianceAllowlist` is deployed but **not** wired into Globals by the script — pointing `Globals.set_compliance_gate` at it is left as a deliberate, separate governance action once the allowlist has real entries.

### Step 4 — Upload Vault WASM

The compiled vault WASM is uploaded (`stellar contract upload`), returning a WASM hash used by the Factory to deploy vault instances.

### Step 5 — Deploy Factory

```
Factory(governor, globals_address, vault_wasm_hash)
```

### Step 6 — Register the Factory in Globals (MANDATORY)

```
globals.set_factory(factory_address)
```

{% hint style="danger" %}
**This step is required.** Globals gates `lock_collateral`, `increment_outstanding`, and `register_vault` on the call coming from the registered Factory. Until `set_factory` has been called, `create_vault` fails with `Globals::UnauthorizedCaller` — no vault can be created. `set_factory` is `#[only_admin]`, so it must run **before** admin is transferred to the Timelock.
{% endhint %}

### Step 7 — Whitelist Assets

```
globals.whitelist_asset(asset)          // assets that can be lent
globals.whitelist_collateral_asset(asset) // assets that can be posted as collateral
```

These are two **separate** whitelists (§14.2) — an asset must be on the collateral list to be used as manager or borrower collateral, independent of whether it's also lendable. Both must be done **before** transferring Globals admin to the Timelock, since they require direct admin access.

### Step 8 — Approve Initial Managers

```
globals.approve_pool_manager(manager_address, caller, max_credit)
```

A single call both registers and approves a manager (there is no separate `add_pool_manager`). `caller` must hold the `ops_adm` role.

### Step 9 — Begin the Globals Admin Transfer

Globals uses the OZ **two-step** admin transfer:

```
globals.transfer_admin_role(timelock_address, live_until_ledger)   // current admin initiates
globals.accept_admin_transfer()                                     // the Timelock accepts, as a separate governance action
```

`live_until_ledger` bounds when the pending transfer expires (`0` cancels it). Completing the transfer is left as a deliberate follow-up step, not part of the automated script — see the Mainnet Checklist below for when `require_timelock_admin` also needs to be set.

{% hint style="warning" %}
**This is effectively irreversible once accepted.** Once admin is the Timelock, direct admin access is gone. Ensure `set_factory`, both asset whitelists, and manager approval are all complete first.
{% endhint %}

### Step 10 — Save Contract IDs

```json
{
  "network": "testnet",
  "deployedAt": "2026-09-28T00:00:00Z",
  "contracts": {
    "globals": "CABC...",
    "timelock": "CDEF...",
    "factory": "CGHI...",
    "collateral_escrow": "CJKL...",
    "compliance_allowlist": "CMNO...",
    "web_auth": "CPQR..."
  },
  "vaultWasmHash": "abc123..."
}
```

## Running the Deployment

```bash
# Build one contract → target/wasm32v1-none/release/<name>.wasm (note: '-' → '_')
stellar contract build --package <globals|timelock|factory|vault|collateral-escrow|compliance-allowlist|web-auth>

# Full automated deploy of all 7 contracts + post-deploy wiring
npx ts-node scripts/deploy-protocol.ts
```

## Configuration

All deployment parameters are configured via environment variables. See `.env.example`:

| Variable                      | Description                                                        |
| ----------------------------- | ------------------------------------------------------------------ |
| `SOROBAN_NETWORK`             | Network to deploy to (`testnet` / `mainnet`)                       |
| `NETWORK_RPC_URL`             | Soroban RPC endpoint                                               |
| `NETWORK_PASSPHRASE`          | Network passphrase                                                 |
| `NETWORK_HORIZON_URL`         | Horizon endpoint                                                   |
| `WASM_TARGET_DIR`             | Build output dir (default `target/wasm32v1-none/release`)          |
| `SOURCE_SECRET_KEY`           | Secret key for the deploying account                               |
| `GOVERNOR_ADDRESS`            | Initial governor (root admin of Globals)                           |
| `TREASURY_ADDRESS`            | Protocol treasury address                                          |
| `SECURITY_ADMIN_ADDRESS`      | Security admin address                                             |
| `OPERATIONS_ADMIN_ADDRESS`    | Operations admin address                                           |
| `TIMELOCK_MIN_DELAY`          | Minimum delay for timelocked operations (**ledgers**, not seconds) |
| `TIMELOCK_PROPOSERS`          | Comma-separated proposer addresses (≥ 1 required)                  |
| `TIMELOCK_EXECUTORS`          | Comma-separated executor addresses (empty ⇒ anyone)                |
| `WHITELIST_ASSETS`            | Comma-separated lending-asset addresses to whitelist               |
| `WHITELIST_COLLATERAL_ASSETS` | Comma-separated collateral-asset addresses to whitelist            |
| `INITIAL_MANAGERS`            | Comma-separated manager addresses to approve                       |
| `MANAGER_CREDIT_LIMIT`        | Credit limit applied to initial managers (7-decimal)               |
| `STELLAR_CLI_PATH`            | (optional) Absolute path override for the `stellar` CLI            |

{% hint style="info" %}
The deployer secret is passed to the CLI via the `STELLAR_ACCOUNT` / `STELLAR_SIGN_WITH_KEY` environment variables (never on argv), and the `stellar` CLI path is resolved on a sanitised PATH to prevent PATH-hijack.
{% endhint %}

## The upgrade-path rule

**Incident (testnet).** A previously deployed Factory could not be upgraded: `Factory::upgrade` decoded the full `GlobalSettings` composite struct just to read one field, and once the deployed Factory's copy of that struct drifted from Globals' real (grown) one, the decode trapped on **every** call — including `upgrade` itself. The Factory was bricked for both `create_vault` and `upgrade`, with no way to fix it, because the fix needed an upgrade and the upgrade path was the thing that was broken.

**Rule.** An upgrade or admin-recovery path must never decode another contract's composite type — only primitives (`bool`, numbers, addresses, `Option<Address>`). `Globals.is_timelock_admin_required() -> bool` and `Globals.get_timelock() -> Option<Address>` exist specifically so `Factory::upgrade`, `Vault::upgrade`, and `CollateralEscrow::upgrade` never need to call `get_global_settings()`. A CI check guards against a hand-mirrored composite type reappearing, but reviewers must still check new upgrade/admin paths by hand — a *correctly shared* composite type decoded from an upgrade path is still unsafe, since it can grow a field between deployment and the next decode.

**Recovery, if a contract is bricked anyway.** It cannot be recovered in place. Redeploy a fresh instance from source and re-point every address that referenced the old one — for a bricked Factory: deploy a new Factory (same `globals` + `vault_wasm_hash`), then `Globals.set_factory(<new_factory>)`. The bricked Factory's own vault registry is not migrated; existing vaults registered by the old Factory keep working against Globals, but new `create_vault` calls must go through the new Factory address.

## TTL (state-archival) policy

Soroban archives storage entries whose time-to-live lapses. Trilobyte extends TTLs on a schedule rather than to the network maximum on every write:

* **Persistent entries** (managers, registered vaults, pledges, allowlist entries, investor positions) use a shared threshold/extend-to policy: roughly a **30-day threshold, 120-day extend-to**, clamped to the network's `max_ttl()`. A persistent entry that isn't touched again lives roughly **120 days** past its last touch, then archives.
* **Instance entries** (each contract's own instance storage) use a shorter window — roughly a **14-day threshold, 45-day extend-to**. Extending an instance also extends that instance's Wasm *code* entry to the same `live_until`; because the vault contract's code entry is large (\~76 KB), the old 120-day instance window let the first vault creation after the shared code entry's TTL drifted low drag that whole code entry's rent into a single user transaction — enough, at one point, to exceed Stellar's transaction fee cap and make the transaction unsubmittable. The shorter 45-day instance window bounds that drag.
* **Operational requirement.** Keep the shared vault Wasm code entry pre-extended past the instance window, independent of any instance's own bump (`stellar contract extend --wasm-hash <hash> --ledgers-to-extend <ledgers>`), and run the TTL keep-alive cron **at least every two weeks** — comfortably inside the 14-day instance threshold. A cron that stalls for 45+ days risks letting an untouched instance (and, if the code entry's own extension also lapses, the shared code entry) archive.
* Vault investor positions use the paginated, permissionless `bump_investor_ttls(start, count)` so a keeper can refresh a large investor base without a single call's cost scaling with the count.

## Mainnet Checklist

* [ ] Own funded deployer (not a shared testnet key)
* [ ] Dedicated `SIGNING_KEY` for the SEP-45 web-auth domain account — not the deployer/governor key
* [ ] Publish `stellar.toml` at the web-auth domain
* [ ] Both asset whitelists populated before the admin transfer
* [ ] `Globals.accept_admin_transfer()` completed, moving admin to the Timelock
* [ ] `Globals.set_timelock(<timelock address>)` called, then `update_settings` (or a dedicated setter) sets `require_timelock_admin = true` — **mandatory before mainnet**, per the deploy script's own guard
* [ ] TTL keep-alive cron scheduled at least every two weeks
* [ ] `ComplianceAllowlist` wired in via `set_compliance_gate`, if the launch requires it — otherwise leave the gate unset

*As of September 2026.*
