diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..0cadc0113
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,59 @@
+
+
+## Summary
+
+
+
+## Linked issues
+
+Closes #
+
+## Checks
+
+- [ ] Lint passes for every area touched
+- [ ] Build passes
+- [ ] Tests pass
+- [ ] No secrets, keys, or `.env` values are committed
+
+
+Contract changes only — expand if this PR touches contracts/
+
+Run from `contracts/`:
+
+```sh
+cargo fmt --all -- --check
+cargo clippy --all-targets --all-features -- -D warnings
+cargo test --all
+```
+
+- [ ] All three pass
+- [ ] Reviewed against the relevant sections of
+ [`contracts/SECURITY.md`](../blob/main/contracts/SECURITY.md#pre-deployment-checklist)
+
+Confirm the sections that apply to this change:
+
+- [ ] **Authorization** — every new or modified state-changing entrypoint calls
+ `require_auth()` on the correct principal, and no entrypoint treats a
+ caller-supplied address argument as proof of identity. Negative tests
+ exist **without** `mock_all_auths`.
+- [ ] **Arithmetic** — no unchecked arithmetic on any path handling amounts,
+ shares, or percentages; overflow returns a typed error.
+- [ ] **Storage and TTL** — correct durability chosen, and persistent entries
+ that must outlive the default are extended.
+- [ ] **Pause** — new mutating entrypoints respect the pause guard.
+- [ ] **Admin and upgrades** — privileged entrypoints are admin-gated and emit
+ an event.
+- [ ] **Events** — every new state change emits an observable event, and the
+ event catalogue is updated.
+- [ ] **Size** — WASM size impact considered for new dependencies or large code
+ additions.
+
+If this PR knowingly leaves one of these open, say which and why:
+
+
diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml
index c4e53b555..3b7854c78 100644
--- a/.github/workflows/CI.yaml
+++ b/.github/workflows/CI.yaml
@@ -19,10 +19,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - name: Read pinned toolchain
+ id: toolchain
+ run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
with:
- toolchain: stable
+ toolchain: ${{ steps.toolchain.outputs.channel }}
components: rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
@@ -32,10 +35,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - name: Read pinned toolchain
+ id: toolchain
+ run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
with:
- toolchain: stable
+ toolchain: ${{ steps.toolchain.outputs.channel }}
components: clippy
- name: Cache cargo registry
uses: actions/cache@v4
@@ -55,10 +61,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - name: Read pinned toolchain
+ id: toolchain
+ run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
with:
- toolchain: stable
+ toolchain: ${{ steps.toolchain.outputs.channel }}
- name: Cache cargo registry
uses: actions/cache@v4
with:
@@ -78,10 +87,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - name: Read pinned toolchain
+ id: toolchain
+ run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
with:
- toolchain: stable
+ toolchain: ${{ steps.toolchain.outputs.channel }}
- name: Cache cargo registry
uses: actions/cache@v4
with:
@@ -101,10 +113,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - name: Read pinned toolchain
+ id: toolchain
+ run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
with:
- toolchain: stable
+ toolchain: ${{ steps.toolchain.outputs.channel }}
targets: wasm32-unknown-unknown
- name: Cache cargo registry
uses: actions/cache@v4
diff --git a/contracts/.gitignore b/contracts/.gitignore
index a7eaa152b..89a6be20b 100644
--- a/contracts/.gitignore
+++ b/contracts/.gitignore
@@ -1,3 +1,9 @@
target
.env
-test_snapshots/
\ No newline at end of file
+test_snapshots/
+
+# Deployment records hold live contract ids per network and are produced by
+# scripts/deploy.sh. They are environment state, not source. The committed
+# deployments/testnet.example.json documents the shape.
+deployments/*
+!deployments/*.example.json
diff --git a/contracts/README.md b/contracts/README.md
new file mode 100644
index 000000000..5682d4315
--- /dev/null
+++ b/contracts/README.md
@@ -0,0 +1,127 @@
+# AssetsUp Soroban Contracts
+
+Stellar/Soroban smart contracts backing the AssetsUp asset-management platform.
+The workspace holds five crates covering the asset registry, multisig approval
+of high-value transfers, escrow and KYC, and on-chain maintenance history.
+
+## Crate map
+
+| Crate | Directory | Deployable | Responsibility |
+|---|---|---|---|
+| `assetsup` | [`assetsup/`](assetsup/) | yes | Primary asset registry: registration, ownership transfer, tokenization, dividends, voting, leasing, insurance, detokenization. |
+| `contrib` | [`contrib/`](contrib/) | yes | Secondary registry: audit log, emergency pause, insurance, leasing. Most files in this crate are **not compiled** — see below. |
+| `multisig-wallet` | [`multisig-wallet/`](multisig-wallet/) | yes | General-purpose *m-of-n* wallet: transaction submission, confirmation, execution, owner/threshold governance, emergency freeze. |
+| `multisig-transfer` | [`multisig_transfer/`](multisig_transfer/) | yes | Approval workflow for asset transfers, gated on per-category approval rules. Calls into a registry contract to move ownership. |
+| `asset-maintenance` | [`asset-maintenance/`](asset-maintenance/) | yes | On-chain maintenance history, schedules, warranties, provider registry, and alerts. |
+
+All five build to WASM and are deployable. `assetsup`, `contrib`,
+`multisig-wallet`, and `multisig-transfer` additionally declare `crate-type =
+["lib", "cdylib"]` so they can be imported by integration tests; only
+`asset-maintenance` is `cdylib`-only.
+
+Every crate has its own README with an entrypoint table, storage layout,
+emitted events, and error list. Start there.
+
+## How `assetsup` and `contrib` relate
+
+This is the most common source of confusion in the workspace, because the two
+crates share several module names (`audit`, `detokenization`, `insurance`,
+`lease`, `tokenization`, and transfer restrictions).
+
+They are **two independent contracts, deployed separately, with separate
+storage**. Neither reads the other's state. The overlap is duplicated code, not
+a shared library:
+
+- **`assetsup` is the authoritative asset registry.** It is the larger crate
+ (~8,900 lines), it is the one the backend is expected to treat as the source
+ of truth for asset identity and ownership, and it is the only one with
+ dividends, voting, and detokenization proposals wired to a token supply.
+- **`contrib` is a second, smaller registry** with an audit log, an emergency
+ pause, insurance, and leasing.
+
+Where a module name appears in both, the implementations have diverged and are
+not interchangeable. `assetsup::insurance` models policies and claims with a
+full claim state machine; `contrib::insurance` is a smaller policy/claim store.
+The same holds for `lease` and `audit`.
+
+> **`contrib` is mostly dead code.** `contrib/src/lib.rs` declares only
+> `audit`, `pause`, `types`, `insurance`, and `lease`. The other files in
+> `contrib/src/` — escrow, KYC, staking, oracle, tokenization, detokenization,
+> transfer restrictions, and its `error.rs` — have no `mod` declaration and are
+> **not compiled into the crate**, about 1,670 lines in total. So the deployed
+> `ContribContract` has no escrow, no KYC, no staking, no oracle, and no typed
+> errors, despite the source files being present. See
+> [`contrib/README.md`](contrib/README.md) before relying on any of them.
+
+Resolving this duplication — deciding which crate owns each concern — is
+tracked in [SC-46]. Until that lands, treat the two as separate contracts and
+consult the per-crate README for the behaviour of the specific one you are
+calling.
+
+## Toolchain
+
+The Rust toolchain is **pinned** in [`rust-toolchain.toml`](rust-toolchain.toml).
+`rustup` picks it up automatically for any command run inside `contracts/`, and
+every contracts job in [`.github/workflows/CI.yaml`](../.github/workflows/CI.yaml)
+reads the same `channel` value, so local and CI builds always use the same
+compiler.
+
+Current pin: **Rust 1.96.0**, with the `rustfmt` and `clippy` components and the
+`wasm32-unknown-unknown` target.
+
+Floating `stable` is deliberately avoided. A new stable release can introduce
+clippy lints that fail `-D warnings` on a pull request that changed nothing,
+turning an unrelated Rust release into a broken build for every open PR.
+
+### Upgrading the toolchain
+
+Bump the toolchain **in its own dedicated pull request** so that any lint churn
+is isolated from feature work and reviewable on its own:
+
+1. Edit `channel` in `contracts/rust-toolchain.toml`.
+2. Run `cargo fmt --all`, then `cargo clippy --all-targets --all-features -- -D warnings`
+ and fix any lints the new release introduced.
+3. Confirm `cargo test --all` still passes and that the pinned `soroban-sdk`
+ version still compiles — the SDK sets its own MSRV and can lag new releases.
+4. Open the PR with only the toolchain bump and its lint fixes.
+
+CI needs no change: it reads the channel from the file.
+
+## Local development
+
+```sh
+cd contracts
+
+cargo build --all
+cargo test --all
+cargo fmt --all -- --check
+cargo clippy --all-targets --all-features -- -D warnings
+```
+
+CI enforces all four. Run them before opening a pull request.
+
+Build a contract for deployment:
+
+```sh
+cargo build --package assetsup --target wasm32-unknown-unknown --release
+```
+
+## Deployment
+
+Scripted, reproducible testnet deployment lives in
+[`scripts/deploy.sh`](scripts/deploy.sh). See
+[`scripts/README.md`](scripts/README.md) for prerequisites and usage.
+
+## Security
+
+The trust model, per-contract attack surface, and the pre-deployment checklist
+are documented in [`SECURITY.md`](SECURITY.md). Contract changes are reviewed
+against that checklist.
+
+## Naming conventions
+
+- Crate (package) names use **hyphens**: `multisig-wallet`, `asset-maintenance`.
+- Directory names should match the package name. One exception remains today —
+ the directory `multisig_transfer/` holds the package `multisig-transfer`,
+ which is why `cargo test -p multisig_transfer` fails while
+ `-p multisig-transfer` works. Tracked in [SC-33].
diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md
new file mode 100644
index 000000000..1b5f20756
--- /dev/null
+++ b/contracts/SECURITY.md
@@ -0,0 +1,171 @@
+# Contract security
+
+Threat model, trust assumptions, and the pre-deployment checklist for the five
+crates in `contracts/`. Contract changes are reviewed against the
+[checklist](#pre-deployment-checklist) below.
+
+**These contracts have not been externally audited.** They govern asset
+ownership, multisig approvals, escrow, KYC, and dividends. Treat everything here
+as pre-production until an audit is complete and the open items in
+[Known accepted risks](#known-accepted-risks) are closed.
+
+## Trust model
+
+### Who is trusted
+
+| Principal | Held by | Can do | Blast radius if compromised |
+|---|---|---|---|
+| **Contract admin** | One address per contract, set at `initialize` | Add/remove registrars, pause and unpause, change admin, approve KYC, manage oracle sources, register providers | **Total.** Can authorize itself as a registrar, register or retire arbitrary assets, and hand the admin role to an attacker. Admin transfer is single-step, so a compromised admin can lock out the legitimate operator irreversibly. |
+| **Authorized registrar** | Addresses on the `assetsup`/`contrib` allowlist | Register assets, update asset metadata | Can mint fraudulent asset records and pollute the registry. Cannot directly steal an existing asset in `contrib`. |
+| **Asset owner** | The `owner` field of an asset | Transfer, retire, tokenize, lease, insure their own asset | Limited to that owner's assets. |
+| **Multisig signer** | Members of `owners` in `multisig-wallet` | Submit, confirm, and propose; *m* of them can execute anything | *m* compromised signers is equivalent to full wallet control. Fewer than *m* can grief by consuming ids but cannot execute. |
+| **Approver** | Addresses satisfying a `multisig-transfer` `ApprovalRule` | Approve or reject transfer requests | Enough colluding approvers can move any asset in the category they govern. |
+| **Backend signer** | The service account the API signs with | Whatever role it has been granted on-chain | It is a hot key in a server process. Grant it the narrowest role that works — registrar, never admin. |
+
+### Who is not trusted
+
+Everyone else. Any address can call any entrypoint; Soroban does **not**
+authenticate callers implicitly. An entrypoint is protected only if it calls
+`require_auth()` on the correct address.
+
+### Trust boundaries
+
+- **Backend → contracts.** The backend is an ordinary client. Contracts must not
+ assume the backend validated anything.
+- **`multisig-transfer` → registry.** `multisig-transfer` calls into the
+ registry contract stored at `AssetRegistry` to move ownership. That registry
+ address is set at `initialize` with no authorization and is never re-verified.
+- **`assetsup` ↔ `contrib`.** No trust relationship — they are independent
+ deployments with separate storage that never call each other.
+
+## Assets at risk, by contract
+
+| Contract | What an attacker gains | Highest-risk entrypoints |
+|---|---|---|
+| `assetsup` | Ownership of any registered asset; fractional share balances; undistributed dividends | `transfer_asset_ownership`, `register_asset`, `retire_asset`, `mint_tokens`, `distribute_dividends` |
+| `contrib` | Ownership of any asset in its own registry; insurance policy and claim state | `register_asset`, `transfer_asset`, `retire_asset`, `update_claim_status` |
+| `multisig-wallet` | Anything the wallet controls | `execute_transaction`, `execute_proposal`, `emergency_unfreeze` |
+| `multisig-transfer` | Ownership of assets whose category rule it governs | `execute_transfer`, `configure_approval_rule`, `initialize` |
+| `asset-maintenance` | Falsified audit evidence; fraudulent warranty claims | `add_maintenance_record`, `file_warranty_claim`, `add_warranty_information` |
+
+## Known accepted risks
+
+Each is tracked; none is closed. Do not deploy to a network holding real value
+until the ones marked **blocking** are fixed.
+
+| # | Risk | Status |
+|---|---|---|
+| 1 | **`assetsup` does not authenticate `caller`.** `register_asset`, `update_asset_metadata`, `transfer_asset_ownership`, and `retire_asset` compare a caller-supplied `caller` argument against an allowlist/owner/admin but never call `caller.require_auth()`. Any account can name a privileged address and pass the check. `transfer_asset_ownership` is a direct asset-theft path. | **Blocking** — [SC-42] |
+| 2 | **Unguarded entrypoints in `asset-maintenance`.** `init`, `add_warranty_information`, `update_warranty_information`, `file_warranty_claim`, and `create_maintenance_alert` perform no authorization at all. Warranty terms and claims are forgeable by anyone. | **Blocking** — [SC-42] |
+| 3 | **`initialize` is front-runnable** in `contrib` and `multisig-transfer` — neither authorizes the caller, so whoever calls first becomes admin. Deploy and initialize in the same transaction, or accept the race. | **Blocking** — [SC-42] |
+| 4 | **Admin transfer is single-step.** One typo permanently bricks administration, with no on-chain undo. | Open — [SC-48] |
+| 5 | **Arithmetic can trap rather than error.** `overflow-checks = true` turns overflow into a panic. Value paths should return typed errors. | Open — [SC-43] |
+| 6 | **No deliberate TTL policy.** A persistent entry whose TTL lapses is archived; an archived registry entry or pending approval is a correctness bug. | Open — [SC-44] |
+| 7 | **Pause coverage is unverified.** `contrib` has a pause module; whether every mutating entrypoint honours it — and whether the other crates have one at all — is unconfirmed. | Open — [SC-47] |
+| 8 | **No upgrade story.** No contract exposes an upgrade entrypoint and no storage-version key exists, so a storage layout change means redeploying and losing data. | Open — [SC-49] |
+| 9 | **No dependency scanning.** Nothing checks for advisories in transitive dependencies. | Open — [SC-38] |
+| 10 | **Error codes collide across contracts.** The same integer means different things per contract, so a backend cannot map a code without knowing which contract produced it. | Open — [SC-45] |
+| 11 | **`multisig_transfer` has no tests**, and `multisig-wallet` and `asset-maintenance` have four each. | Open — [SC-32], [SC-40], [SC-41] |
+| 12 | **Roughly 1,670 lines of `contrib` are not compiled.** Escrow, KYC, staking, oracle, tokenization, detokenization, transfer restrictions, and its `error.rs` have no `mod` declaration, so the deployed contract does not have those capabilities and has no typed errors at all. Anyone reading the source would reasonably assume otherwise. | Open — [SC-46] |
+| 13 | **Contracts are unaudited.** No external review has been performed. | Open |
+
+## Pre-deployment checklist
+
+Work through this before deploying to any network that holds real value. Each
+item names the issue that established the requirement.
+
+### Authorization — [SC-42]
+- [ ] Every state-changing entrypoint calls `require_auth()` on the correct principal.
+- [ ] No entrypoint trusts a caller-supplied address argument as proof of identity.
+- [ ] `require_auth_for_args` is used where authorization must bind to amounts or recipients.
+- [ ] Each protected entrypoint has a negative test **without** `mock_all_auths`.
+- [ ] `initialize` cannot be front-run, or deployment and initialization are atomic.
+
+### Arithmetic — [SC-43]
+- [ ] No unchecked `+`/`-`/`*` on any path handling amounts, shares, or percentages.
+- [ ] Overflow returns a typed error rather than trapping.
+- [ ] Rounding direction is documented, and who absorbs the remainder is explicit.
+- [ ] Boundary tests exist at `0`, `1`, and type max.
+
+### Storage and TTL — [SC-44]
+- [ ] Every storage write uses the right durability (instance / persistent / temporary).
+- [ ] Long-lived data is never in `temporary`.
+- [ ] Persistent entries that must outlive the default are extended on read and write.
+- [ ] Instance TTL is bumped in every entrypoint touching instance storage.
+- [ ] TTL constants are defined in one place.
+- [ ] A test advances the ledger and proves critical entries survive.
+
+### Emergency controls — [SC-47]
+- [ ] Every mutating entrypoint respects the pause guard.
+- [ ] Read-only entrypoints still work while paused.
+- [ ] Whether withdrawal and escrow-release are exempt is a documented decision.
+- [ ] A test fails if a new mutating entrypoint is added without a pause check.
+
+### Admin and upgrades — [SC-48], [SC-49]
+- [ ] Admin transfer is two-step; an address that never accepts leaves the original admin in place.
+- [ ] Each contract's upgrade posture is documented (upgradeable or immutable).
+- [ ] Upgrade entrypoints are admin-gated and emit an event.
+- [ ] A storage-version key exists and migration is idempotent.
+- [ ] An upgrade test proves state survives a version bump.
+
+### Dependencies — [SC-38]
+- [ ] `cargo audit` passes with no unignored advisories.
+- [ ] `cargo deny` passes for licenses, advisories, and duplicate versions.
+- [ ] Every ignored advisory has a written rationale and an expiry date.
+
+### Build and size — [SC-34], [SC-37], [SC-52]
+- [ ] Every deployable contract builds for `wasm32-unknown-unknown`.
+- [ ] Release profile has size optimizations enabled and `overflow-checks = true` retained.
+- [ ] Each contract's WASM size is within budget and the delta was reviewed.
+
+### Tests and observability — [SC-32], [SC-36], [SC-39]
+- [ ] `cargo test --all` passes.
+- [ ] `cargo clippy --all-targets --all-features -- -D warnings` is clean.
+- [ ] `cargo fmt --all -- --check` is clean.
+- [ ] No crate is at zero coverage; per-crate floors pass.
+- [ ] Every state-changing entrypoint emits an event.
+- [ ] The event catalogue matches what the backend consumes.
+
+### Deployment — [SC-55]
+- [ ] Deployment is scripted and reproducible from a clean checkout.
+- [ ] Contract ids are recorded in `deployments/.json`.
+- [ ] No key material is committed or logged.
+- [ ] Each deployed contract answered a read call.
+- [ ] `multisig-wallet` was initialized with the real signer set and a threshold > 1.
+
+## Reporting a vulnerability
+
+**Do not open a public issue for a security vulnerability.**
+
+Report privately through
+[GitHub Security Advisories](https://github.com/DistinctCodes/AssetsUp/security/advisories/new),
+which creates a channel visible only to maintainers. If that is unavailable,
+contact a maintainer listed on the organization profile directly.
+
+Please include:
+
+- The affected contract and entrypoint.
+- What an attacker gains, and what they need to start.
+- A reproduction — ideally a failing test against this workspace.
+- Any suggested fix.
+
+What to expect:
+
+| Stage | Target |
+|---|---|
+| Acknowledgement | 3 working days |
+| Initial assessment and severity | 10 working days |
+| Fix or documented mitigation | Depends on severity; critical issues are prioritized above all other work |
+
+Please give maintainers a reasonable window to ship a fix before disclosing
+publicly. Reporters are credited in the advisory unless they ask not to be.
+
+### Scope
+
+**In scope:** everything under `contracts/` — all five crates, the release
+profile, CI workflows that produce deployable artifacts, and the deployment
+scripts.
+
+**Out of scope:** the unaudited status itself and the items already listed in
+[Known accepted risks](#known-accepted-risks) — those are tracked, not news.
+Backend and frontend issues belong in their own reports.
diff --git a/contracts/asset-maintenance/README.md b/contracts/asset-maintenance/README.md
new file mode 100644
index 000000000..68500916d
--- /dev/null
+++ b/contracts/asset-maintenance/README.md
@@ -0,0 +1,163 @@
+# `asset-maintenance`
+
+Records asset maintenance history, schedules, warranties, service providers,
+and alerts on-chain. The maintenance history is intended as **audit evidence**:
+records are appended, never rewritten.
+
+Contract type: `AssetMaintenanceContract`. Deployable (`crate-type = ["cdylib"]`).
+
+Assets are referenced by `u64` id. This contract stores an `AssetRegistry`
+address at init but does not currently call into it to validate that an asset
+exists.
+
+## Invariants
+
+- Maintenance history is append-only: `add_maintenance_record` pushes onto
+ `MaintenanceHistory(asset_id)` and no entrypoint removes or rewrites an
+ existing record.
+- `AssetStats(asset_id)` is a derived rollup (total cost, downtime, service
+ count, health score) maintained alongside the history.
+- A health score is expressed on a 1–100 scale.
+
+## Storage layout
+
+All entries use **instance** storage.
+
+| Key | Type | Meaning |
+|---|---|---|
+| `Admin` | `Address` | Contract administrator. |
+| `AssetRegistry` | `Address` | Address of the asset registry contract. |
+| `Provider(Address)` | `ProviderProfile` | Registered maintenance provider. |
+| `MaintenanceHistory(u64)` | `Vec` | Append-only service history for an asset. |
+| `MaintenanceSchedule(u64)` | `ScheduledMaintenance` | Upcoming scheduled service. |
+| `Warranty(u64)` | `WarrantyInfo` | Warranty terms and status. |
+| `Alerts(u64)` | `Vec` | Alerts raised against an asset. |
+| `AssetStats(u64)` | `AssetStats` | Derived cost/downtime/health rollup. |
+
+## Entrypoints
+
+`Auth` names the address whose `require_auth()` is called. **"— (none)" marks an
+entrypoint that performs no authorization at all**; these are unguarded and are
+tracked in [SC-42].
+
+### Setup and providers
+
+| Entrypoint | Args | Returns | Auth |
+|---|---|---|---|
+| `init` | `admin, registry` | `()` | — (none) |
+| `register_provider` | `provider: ProviderProfile` | `()` | stored `admin` |
+| `deactivate_provider` | `provider_address` | `()` | stored `admin` |
+| `get_provider_details` | `provider_address` | `Option` | read-only |
+
+### Maintenance records
+
+| Entrypoint | Args | Returns | Auth |
+|---|---|---|---|
+| `add_maintenance_record` | `record: MaintenanceRecord` | `()` | `record.provider` |
+| `get_maintenance_history` | `asset_id` | `Vec` | read-only |
+| `schedule_maintenance` | `owner, schedule` | `()` | `owner` |
+| `update_maintenance_schedule` | `owner, schedule` | `()` | `owner` (via `schedule_maintenance`) |
+| `complete_scheduled_maintenance` | `asset_id, record` | `()` | `record.provider` (via `add_maintenance_record`) |
+| `get_upcoming_maintenance` | `asset_id` | `Option` | read-only |
+| `get_overdue_maintenance` | `asset_id` | `bool` | read-only |
+
+`update_maintenance_schedule` and `complete_scheduled_maintenance` authenticate
+indirectly: they delegate to `schedule_maintenance` and
+`add_maintenance_record` respectively, which call `require_auth()`. The
+protection is real but easy to miss, and easy to break by refactoring the
+delegation away.
+
+### Warranties
+
+| Entrypoint | Args | Returns | Auth |
+|---|---|---|---|
+| `add_warranty_information` | `warranty: WarrantyInfo` | `()` | — (none) |
+| `update_warranty_information` | `warranty: WarrantyInfo` | `()` | — (none) |
+| `get_warranty` | `asset_id` | `Option` | read-only |
+| `file_warranty_claim` | `asset_id, claim_amount` | `()` | — (none) |
+
+### Alerts
+
+| Entrypoint | Args | Returns | Auth |
+|---|---|---|---|
+| `create_maintenance_alert` | `alert: MaintenanceAlert` | `()` | — (none) |
+| `acknowledge_maintenance_alert` | `asset_id, alert_index, by` | `()` | `by` |
+| `get_alerts` | `asset_id` | `Vec` | read-only |
+
+### Analytics (all read-only)
+
+`calculate_total_maintenance_cost`, `calculate_asset_downtime`,
+`get_asset_health_score`, `get_asset_stats`, `is_maintenance_cost_excessive`.
+
+## Events
+
+| Topics | Emitted by |
+|---|---|
+| `("MaintRec", asset_id)` | `add_maintenance_record` |
+| `("MaintSch", asset_id)` | `schedule_maintenance` |
+| `("MaintCmp", asset_id)` | `complete_scheduled_maintenance` |
+| `("WarrAdd", asset_id)` | `add_warranty_information` |
+| `("WarrClm", asset_id)` | `file_warranty_claim` |
+| `("AlertCr", asset_id)` | `create_maintenance_alert` |
+
+These topics use `CamelCase`, unlike the `snake_case` used elsewhere in the
+workspace — see [SC-36]. `init`, `register_provider`, `deactivate_provider`,
+`update_maintenance_schedule`, `update_warranty_information`, and
+`acknowledge_maintenance_alert` emit no event.
+
+## Errors
+
+This crate has **no error enum**. Failures are raised with `panic!` on a
+`&str` message rather than a typed `contracterror`, so callers cannot
+distinguish failure modes by code. Unifying this with the rest of the workspace
+is tracked in [SC-45].
+
+## Types
+
+`MaintenanceType` (Preventive, Corrective, Emergency, Inspection, Upgrade,
+Calibration), `AlertType`, `AlertSeverity`, `WarrantyStatus`, `PriorityLevel`,
+`MaintenanceRecord`, `ScheduledMaintenance`, `WarrantyInfo`, `ProviderProfile`,
+`MaintenanceAlert`, `AssetStats`.
+
+## Worked example
+
+```rust
+use soroban_sdk::{testutils::Address as _, Address, Env, String};
+
+let env = Env::default();
+env.mock_all_auths();
+
+let contract_id = env.register(AssetMaintenanceContract, ());
+let client = AssetMaintenanceContractClient::new(&env, &contract_id);
+
+let admin = Address::generate(&env);
+let registry = Address::generate(&env);
+let provider = Address::generate(&env);
+
+client.init(&admin, ®istry);
+
+// The provider records a completed service against asset 1.
+let record = MaintenanceRecord {
+ record_id: 1,
+ asset_id: 1,
+ maintenance_type: MaintenanceType::Preventive,
+ provider: provider.clone(),
+ technician_id: String::from_str(&env, "tech-01"),
+ service_date: 1_000,
+ duration_hours: 3,
+ // ... remaining fields
+};
+client.add_maintenance_record(&record);
+
+let history = client.get_maintenance_history(&1);
+assert_eq!(history.len(), 1);
+```
+
+## Tests
+
+```sh
+cargo test -p asset-maintenance
+```
+
+Coverage is currently thin (4 tests for ~730 lines); expanding it is tracked in
+[SC-41].
diff --git a/contracts/asset-maintenance/src/lib.rs b/contracts/asset-maintenance/src/lib.rs
index f8b11a27f..314cdb28b 100644
--- a/contracts/asset-maintenance/src/lib.rs
+++ b/contracts/asset-maintenance/src/lib.rs
@@ -1,4 +1,29 @@
#![no_std]
+//! # asset-maintenance
+//!
+//! Records asset maintenance history, schedules, warranties, service
+//! providers, and alerts on-chain.
+//!
+//! The maintenance history is intended as **audit evidence**: records are
+//! appended, never rewritten. Assets are referenced by `u64` id.
+//!
+//! ## Invariants
+//!
+//! - Maintenance history is append-only — no entrypoint removes or rewrites an
+//! existing [`MaintenanceRecord`].
+//! - [`AssetStats`] is a derived rollup (total cost, downtime, service count,
+//! health score) maintained alongside the history.
+//! - Health scores are expressed on a 1–100 scale.
+//!
+//! ## Error handling
+//!
+//! Unlike the other crates in this workspace, failures here are raised with
+//! `panic!` on a `&str` rather than a typed `contracterror`, so callers cannot
+//! distinguish failure modes by code.
+//!
+//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/asset-maintenance/README.md)
+//! for the full entrypoint, storage, and event tables.
+
use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec};
mod test;
diff --git a/contracts/assetsup/README.md b/contracts/assetsup/README.md
new file mode 100644
index 000000000..87216ded8
--- /dev/null
+++ b/contracts/assetsup/README.md
@@ -0,0 +1,219 @@
+# `assetsup`
+
+The primary asset registry. Assets are registered by authorized registrars,
+owned by an address, and can be transferred, retired, tokenized into fractional
+shares, leased, insured, voted on, and finally detokenized.
+
+Contract type: `AssetUpContract`. Deployable (`crate-type = ["lib", "cdylib"]`).
+
+This is the largest crate in the workspace (~8,900 lines across 33 files). See
+[`../README.md`](../README.md#how-assetsup-and-contrib-relate) for how it
+relates to `contrib`, which duplicates several module names. Splitting this
+crate is under discussion in [SC-46].
+
+## Invariants
+
+- An asset has exactly one owner at any time.
+- An asset id is unique; re-registering an existing id fails with
+ `AssetAlreadyExists`.
+- A retired asset cannot be transferred or updated.
+- For a tokenized asset, the sum of all holder balances equals the total token
+ supply.
+- Locked tokens cannot be transferred until unlocked.
+- Every state change is appended to the audit log for that asset.
+
+## Two asset id spaces
+
+The crate uses **two different asset identifier types**, which is a common
+source of confusion:
+
+- The **registry** (`register_asset`, `get_asset`, `transfer_asset_ownership`,
+ leases, insurance) keys assets by **`BytesN<32>`**.
+- **Tokenization** (`tokenize_asset`, balances, dividends, voting,
+ detokenization, transfer restrictions) keys assets by **`u64`**.
+
+These namespaces are not linked by the contract. Callers are responsible for
+maintaining the mapping between them.
+
+## Module layout
+
+| Module | Responsibility |
+|---|---|
+| `lib.rs` | Contract entrypoints; delegates to the modules below. |
+| `asset.rs` | `Asset`, `AssetInfo`, registry `DataKey`. |
+| `types.rs` | Shared types re-exported from the crate root. |
+| `error.rs` | `Error` enum (codes 1–46) and `handle_error`. |
+| `audit.rs` | Append-only audit entries per asset. |
+| `tokenization.rs` | Fractional share issuance, balances, locks, valuation. |
+| `dividends.rs` | Dividend distribution and claims. |
+| `voting.rs` | Weighted voting by token balance. |
+| `detokenization.rs` | Detokenization proposals and execution. |
+| `transfer_restrictions.rs` | Whitelists and transfer rules. |
+| `lease.rs` | Asset leasing lifecycle. |
+| `insurance.rs` | Insurance policies and the claim state machine. |
+| `branch.rs` | Branch/organization records. |
+
+## Storage layout
+
+Registry state uses **persistent** storage; contract-level flags are also
+persistent (not `instance`, which is unusual — see [SC-44]).
+
+| Key | Type | Meaning |
+|---|---|---|
+| `Admin` | `Address` | Contract administrator. |
+| `Paused` | `bool` | Global pause flag. |
+| `TotalAssetCount` | `u64` | Number of registered assets. |
+| `ContractMetadata` | `ContractMetadata` | Name/version metadata. |
+| `AuthorizedRegistrar(Address)` | `bool` | Registrar allowlist. |
+| `ScheduledTransfer(BytesN<32>)` | — | Scheduled transfer record. |
+| `PendingApproval(BytesN<32>)` | — | Pending approval record. |
+
+Module-specific keys (assets, token balances, leases, policies) live in each
+module's own `DataKey`.
+
+## Entrypoints
+
+`Auth` names the address whose `require_auth()` is called.
+
+### Lifecycle and administration
+
+| Entrypoint | Args | Returns | Auth |
+|---|---|---|---|
+| `initialize` | `admin` | `Result<()>` | `admin` |
+| `update_admin` | `new_admin` | `Result<()>` | current admin |
+| `add_authorized_registrar` | `registrar` | `Result<()>` | current admin |
+| `remove_authorized_registrar` | `registrar` | `Result<()>` | current admin |
+| `pause_contract` | — | `Result<()>` | current admin |
+| `unpause_contract` | — | `Result<()>` | current admin |
+
+Admin transfer is single-step: `update_admin` hands over immediately, so a typo
+permanently bricks administration. A two-step transfer is tracked in [SC-48].
+
+### Asset registry
+
+| Entrypoint | Args | Returns | Auth |
+|---|---|---|---|
+| `register_asset` | `asset, caller` | `Result<()>` | ⚠️ registrar allowlist check only — **no `require_auth`** |
+| `update_asset_metadata` | `asset_id, ..., caller` | `Result<()>` | ⚠️ owner check only — **no `require_auth`** |
+| `transfer_asset_ownership` | `asset_id, new_owner, caller` | `Result<()>` | ⚠️ owner check only — **no `require_auth`** |
+| `retire_asset` | `asset_id, caller` | `Result<()>` | ⚠️ owner/admin check only — **no `require_auth`** |
+
+> **⚠️ Known gap.** These four entrypoints compare the supplied `caller`
+> argument against an allowlist, the asset owner, or the admin, but never call
+> `caller.require_auth()`. Because `caller` is attacker-supplied, the check can
+> be satisfied by simply naming a privileged address. Fixing this is tracked in
+> [SC-42]; it is the highest-severity item in the workspace.
+
+Reads: `get_asset`, `get_asset_info`, `batch_get_asset_info`,
+`get_assets_by_owner`, `check_asset_exists`, `get_total_asset_count`,
+`get_admin`, `is_paused`, `is_authorized_registrar`, `get_contract_metadata`,
+`get_asset_audit_logs`.
+
+### Tokenization
+
+| Entrypoint | Auth |
+|---|---|
+| `tokenize_asset` | owner |
+| `mint_tokens`, `burn_tokens` | issuer |
+| `transfer_tokens` | `from` |
+| `lock_tokens` | owner |
+| `unlock_tokens` | — |
+| `update_valuation` | — |
+
+Reads: `get_token_balance`, `get_token_holders`, `is_tokens_locked`,
+`get_ownership_percentage`, `get_tokenized_asset`.
+
+### Dividends, voting, detokenization
+
+| Entrypoint | Auth |
+|---|---|
+| `distribute_dividends` | — |
+| `claim_dividends` | `holder` |
+| `enable_revenue_sharing`, `disable_revenue_sharing` | — |
+| `cast_vote` | `voter` |
+| `propose_detokenization` | `proposer` |
+| `execute_detokenization` | — |
+
+Reads: `get_unclaimed_dividends`, `get_vote_tally`, `has_voted`,
+`proposal_passed`, `get_detokenization_proposal`, `is_detokenization_active`.
+
+### Transfer restrictions
+
+`set_transfer_restriction`, `add_to_whitelist`, `remove_from_whitelist`,
+`is_whitelisted`, `get_whitelist`. None currently call `require_auth`.
+
+### Leasing and insurance
+
+| Entrypoint | Auth |
+|---|---|
+| `create_lease` | lessor |
+| `return_leased_asset`, `cancel_lease` | `caller` |
+| `create_insurance_policy` | insurer |
+| `cancel_insurance_policy`, `suspend_insurance_policy`, `renew_insurance_policy` | `caller`/insurer |
+| `expire_insurance_policy` | — |
+
+## Events
+
+| Topic | Emitted by |
+|---|---|
+| `("asset_reg",)` | `register_asset` |
+| `("asset_upd",)` | `update_asset_metadata` |
+| `("asset_tx",)` | `transfer_asset_ownership` |
+| `("asset_ret",)` | `retire_asset` |
+| `("admin_chg",)` | `update_admin` |
+| `("c_pause",)` | `pause_contract` |
+| `("c_unpause",)` | `unpause_contract` |
+| `("lease_new",)`, `("lease_ret",)`, `("lease_can",)`, `("lease_exp",)` | lease lifecycle |
+
+Tokenization, dividends, voting, and detokenization emit events too; see the
+respective modules. Registrar allowlist changes emit **no** event. See [SC-36]
+for the workspace-wide event catalogue.
+
+## Errors
+
+`Error`, defined in [`src/error.rs`](src/error.rs), codes 1–46, grouped by
+concern (registry 1–9, tokenization 10–20, voting 21–25, dividends 26–27,
+detokenization 28–29, valuation 30, holders 31, math 32–33, contract state
+34–35, validation 36–39, leasing 40–46).
+
+Note that most entrypoints return `Result<_, Error>`, but `handle_error` panics
+with the error instead of returning it in some paths, so callers see a trap
+rather than a typed error. Cross-contract code allocation is tracked in [SC-45].
+
+## Worked example
+
+```rust
+use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String};
+
+let env = Env::default();
+env.mock_all_auths();
+
+let contract_id = env.register(AssetUpContract, ());
+let client = AssetUpContractClient::new(&env, &contract_id);
+
+let admin = Address::generate(&env);
+let registrar = Address::generate(&env);
+let owner = Address::generate(&env);
+
+client.initialize(&admin);
+client.add_authorized_registrar(®istrar);
+
+let asset = Asset {
+ id: BytesN::from_array(&env, &[1u8; 32]),
+ name: String::from_str(&env, "Forklift #3"),
+ owner: owner.clone(),
+ // ... remaining fields
+};
+client.register_asset(&asset, ®istrar);
+
+assert_eq!(client.get_asset(&asset.id).owner, owner);
+assert_eq!(client.get_total_asset_count(), 1);
+```
+
+## Tests
+
+```sh
+cargo test -p assetsup
+```
+
+199 tests live under [`src/tests/`](src/tests/), organized by module.
diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs
index 7c220273f..a5a289e14 100644
--- a/contracts/assetsup/src/lib.rs
+++ b/contracts/assetsup/src/lib.rs
@@ -1,5 +1,34 @@
#![no_std]
#![allow(clippy::too_many_arguments)]
+//! # assetsup
+//!
+//! The primary AssetsUp asset registry.
+//!
+//! Assets are registered by authorized registrars, owned by an `Address`, and
+//! can be transferred, retired, tokenized into fractional shares, leased,
+//! insured, voted on, and detokenized.
+//!
+//! ## Invariants
+//!
+//! - An asset has exactly one owner at any time.
+//! - An asset id is unique; re-registering fails with `Error::AssetAlreadyExists`.
+//! - A retired asset cannot be transferred or updated.
+//! - For a tokenized asset, holder balances sum to the total token supply.
+//!
+//! ## Two asset id spaces
+//!
+//! The registry keys assets by `BytesN<32>`, while tokenization, dividends,
+//! voting, and detokenization key them by `u64`. The contract does not link the
+//! two namespaces — callers maintain the mapping.
+//!
+//! ## Relationship to `contrib`
+//!
+//! `assetsup` and `contrib` are independent contracts with separate storage
+//! that share several module names. See `contracts/README.md` for which crate
+//! owns which concern.
+//!
+//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/assetsup/README.md)
+//! for the full entrypoint, storage, event, and error tables.
use crate::error::{handle_error, Error};
use soroban_sdk::{
diff --git a/contracts/assetsup/src/types.rs b/contracts/assetsup/src/types.rs
index a1c633b85..b1136649c 100644
--- a/contracts/assetsup/src/types.rs
+++ b/contracts/assetsup/src/types.rs
@@ -85,7 +85,7 @@ pub enum TokenDataKey {
TokenizedAsset(u64),
/// Stores OwnershipRecord for (asset_id, holder_address)
TokenHolder(u64, Address),
- /// Stores Vec of all token holders for an asset
+ /// Stores `Vec` of all token holders for an asset
TokenHoldersList(u64),
/// Stores lock timestamp for (asset_id, holder_address)
TokenLockedUntil(u64, Address),
@@ -95,7 +95,7 @@ pub enum TokenDataKey {
VoteTally(u64, u64),
/// Stores TransferRestriction for asset_id
TransferRestriction(u64),
- /// Stores Vec whitelist for asset_id
+ /// Stores `Vec` whitelist for asset_id
Whitelist(u64),
/// Stores unclaimed dividend for (asset_id, holder_address)
UnclaimedDividend(u64, Address),
diff --git a/contracts/contrib/README.md b/contracts/contrib/README.md
new file mode 100644
index 000000000..b86c34665
--- /dev/null
+++ b/contracts/contrib/README.md
@@ -0,0 +1,185 @@
+# `contrib`
+
+A second asset registry with an audit log, an emergency pause, insurance
+policies and claims, and leasing.
+
+Contract type: `ContribContract`. Deployable (`crate-type = ["lib", "cdylib"]`).
+
+## ⚠️ Most of this directory is not compiled
+
+`contrib/src/lib.rs` declares only five modules:
+
+```rust
+mod audit;
+mod pause;
+mod types;
+mod insurance;
+mod lease;
+```
+
+Every other `.rs` file in `contrib/src/` has **no `mod` declaration and is
+therefore not part of the crate** — roughly 1,670 lines that never compile and
+never ship:
+
+| File | Lines | File | Lines |
+|---|---:|---|---:|
+| `tokenization.rs` | 392 | `kyc.rs` | 107 |
+| `restrictions.rs` | 177 | `test.rs` | 102 |
+| `detokenization.rs` | 162 | `escrow.rs` | 101 |
+| `staking.rs` | 138 | `oracle.rs` | 95 |
+| `oracle_test.rs` | 133 | `error.rs` | 42 |
+| `kyc_test.rs` | 115 | | |
+| `staking_test.rs` | 108 | | |
+
+Consequences worth being explicit about:
+
+- **`ContribContract` does not expose escrow, KYC, staking, oracle,
+ tokenization, detokenization, or transfer restrictions.** Reading those files
+ will tell you nothing about the deployed contract.
+- **`contrib` has no typed errors.** `error.rs` defines an `Error` enum that
+ nothing references, so failures surface as `panic!` on a string.
+- `tokenization.rs` does not even parse as valid contract code; it has never
+ compiled.
+- The orphaned `*_test.rs` files never run. All 35 passing tests come from
+ `src/tests/`.
+
+Whether to wire these modules in or delete them is part of the `assetsup` /
+`contrib` consolidation tracked in [SC-46].
+
+## Relationship to `assetsup`
+
+`contrib` and `assetsup` are **two independent contracts with separate storage**.
+Neither reads the other's state, and neither calls the other. Several module
+names appear in both (`audit`, `insurance`, `lease`) but the implementations
+have diverged and are **not** interchangeable.
+
+What each crate actually ships today:
+
+| Concern | `assetsup` | `contrib` |
+|---|---|---|
+| Asset registry | ✅ authoritative | ✅ separate copy |
+| Emergency pause | partial | ✅ dedicated `pause` module |
+| Audit log | ✅ | ✅ |
+| Insurance | ✅ full claim state machine | ✅ smaller policy/claim store |
+| Leasing | ✅ richer lifecycle | ✅ check-in/cancel only |
+| Tokenization, dividends, voting, detokenization | ✅ only here | ❌ present as dead files only |
+| Escrow, KYC, staking, price oracle | — | ❌ present as dead files only |
+
+The often-repeated idea that `contrib` is where escrow and KYC live is not true
+of the compiled contract.
+
+## Invariants
+
+- An asset has exactly one owner at any time.
+- A retired asset cannot be transferred.
+- While paused, every mutating registry entrypoint rejects; reads still work.
+
+## Module layout
+
+Compiled modules only — see the section above for the files that are not part
+of the crate.
+
+| Module | Responsibility |
+|---|---|
+| `lib.rs` | Registry entrypoints and re-exported module facades. |
+| `types.rs` | `AssetStatus` and shared types. |
+| `pause.rs` | `pause`, `unpause`, `is_paused`, `require_not_paused`. |
+| `audit.rs` | Append-only audit log per asset. |
+| `insurance.rs` | Policies and claims. |
+| `lease.rs` | Lease creation, check-in, cancellation. |
+
+## Storage layout
+
+| Key | Type | Meaning |
+|---|---|---|
+| `Asset(BytesN<32>)` | `Asset` | Registered asset. |
+| `OwnerAssets(Address)` | `Vec>` | Assets held by an owner. |
+| `TotalCount` | `u64` | Registered asset count. |
+| `Admin` | `Address` | Contract administrator. |
+| `Paused` | `bool` | Emergency pause flag. |
+| `AuthorizedRegistrar(Address)` | `bool` | Registrar allowlist. |
+| `AuditLogCount` | `u64` | Audit entry counter. |
+| `AuditLogs(BytesN<32>)` | `Vec` | Audit trail per asset. |
+
+## Entrypoints
+
+### Registry
+
+| Entrypoint | Args | Returns | Auth |
+|---|---|---|---|
+| `initialize` | `admin` | `()` | — (none, front-runnable) |
+| `register_asset` | `registrar, asset` | `()` | `registrar` |
+| `transfer_asset` | `asset_id, new_owner, caller` | `()` | `caller` |
+| `retire_asset` | `asset_id, caller` | `()` | `caller` |
+| `add_authorized_registrar` | `caller, registrar` | `()` | `caller` |
+| `remove_authorized_registrar` | `caller, registrar` | `()` | `caller` |
+| `add_registrar` / `remove_registrar` | `caller, registrar` | `()` | aliases of the above |
+
+Unlike `assetsup`, these entrypoints **do** call `require_auth()` on the acting
+address. `contrib` is the correct reference implementation for authorization in
+this workspace.
+
+Reads: `get_admin`, `get_asset`, `get_asset_info`, `get_assets_by_owner`,
+`get_total_count`, `get_total_asset_count`, `is_authorized_registrar`,
+`get_audit_logs`, `is_paused`.
+
+### Pause
+
+| Entrypoint | Args | Auth |
+|---|---|---|
+| `pause_contract` | `caller` | `caller` (must be admin) |
+| `unpause_contract` | `caller` | `caller` (must be admin) |
+| `is_paused` | — | read-only |
+
+`pause::require_not_paused` is the guard mutating entrypoints call. Verifying
+that **every** mutating entrypoint calls it is tracked in [SC-47].
+
+### Insurance and leasing
+
+`create_policy`, `get_policy`, `cancel_policy`, `is_policy_active`,
+`submit_claim`, `update_claim_status`, `get_claim`, `get_claims_for_policy`,
+`create_lease`, `check_in_lease`, `cancel_lease`, `get_active_leases`.
+
+### Not present
+
+There are no escrow, KYC, staking, oracle, tokenization, detokenization, or
+transfer-restriction entrypoints. Source files for them exist in `contrib/src/`
+but are not compiled into the crate — see the warning at the top of this file.
+
+## Events
+
+`contrib` mixes two emission styles — `symbol_short!` with abbreviated
+`snake_case`, and `Symbol::new` with full words:
+
+| Topics | Emitted by |
+|---|---|
+| `("asset_reg", asset_id)` | `register_asset` |
+| `("asset_tra", asset_id)` | `transfer_asset` |
+| `("asset_ret", asset_id)` | `retire_asset` |
+| `("pol_cre", policy_id)`, `("pol_can", policy_id)` | insurance policies |
+| `("clm_sub", claim_id)`, `("clm_upd", claim_id)` | insurance claims |
+| `("lease_cr", lease_id)`, `("lease_in", lease_id)`, `("lease_can", lease_id)` | leasing |
+| `("pause",)`, `("unpause",)` | `pause_contract`, `unpause_contract` |
+
+`initialize` and the registrar allowlist changes emit **no** event. Unifying
+the convention and closing those gaps is tracked in [SC-36].
+
+## Errors
+
+**`contrib` has no typed errors in compiled code.** `src/error.rs` defines an
+`Error` enum with codes 1–21 and 28–32, but the file is not declared as a
+module and nothing references it, so every failure surfaces as a `panic!` on a
+string rather than a `contracterror` a caller can match on.
+
+Were it wired in, its numbering would **overlap `assetsup` with different
+meanings** — code 5 is `Unauthorized` there but `BranchAlreadyExists` in
+`assetsup`. Tracked in [SC-45].
+
+## Tests
+
+```sh
+cargo test -p contrib
+```
+
+All 35 tests live in [`src/tests/`](src/tests/). The `*_test.rs` files at the
+top of `src/` are not compiled and never run.
diff --git a/contracts/contrib/src/lib.rs b/contracts/contrib/src/lib.rs
index 539797f3d..61b45592d 100644
--- a/contracts/contrib/src/lib.rs
+++ b/contracts/contrib/src/lib.rs
@@ -1,5 +1,33 @@
#![no_std]
#![allow(clippy::too_many_arguments)]
+//! # contrib
+//!
+//! A second AssetsUp asset registry carrying the capabilities `assetsup` does
+//! not have: escrow, KYC, staking, a price oracle, and a first-class emergency
+//! pause.
+//!
+//! ## Relationship to `assetsup`
+//!
+//! `contrib` and `assetsup` are **independent contracts with separate
+//! storage**. Neither reads or calls the other. Several module names appear in
+//! both (`audit`, `detokenization`, `insurance`, `lease`, `tokenization`) but
+//! the implementations have diverged and are not interchangeable. See
+//! `contracts/README.md` for the ownership split.
+//!
+//! ## Invariants
+//!
+//! - An asset has exactly one owner at any time.
+//! - A retired asset cannot be transferred.
+//! - While paused, mutating registry entrypoints reject; reads still work.
+//! - Escrowed funds are either released to the beneficiary or returned to the
+//! depositor — never both.
+//!
+//! Unlike `assetsup`, every acting address here is authenticated with
+//! `require_auth()`; this crate is the reference for authorization in the
+//! workspace.
+//!
+//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/contrib/README.md)
+//! for the full entrypoint, storage, event, and error tables.
mod audit;
mod pause;
diff --git a/contracts/deployments/testnet.example.json b/contracts/deployments/testnet.example.json
new file mode 100644
index 000000000..856c2236c
--- /dev/null
+++ b/contracts/deployments/testnet.example.json
@@ -0,0 +1,12 @@
+{
+ "network": "testnet",
+ "admin": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
+ "deployedAt": "2026-01-01T00:00:00Z",
+ "contracts": {
+ "assetsup": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "contrib": "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
+ "multisig-wallet": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
+ "asset-maintenance": "CDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
+ "multisig-transfer": "CEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE"
+ }
+}
diff --git a/contracts/multisig-wallet/README.md b/contracts/multisig-wallet/README.md
new file mode 100644
index 000000000..3f6e2b1be
--- /dev/null
+++ b/contracts/multisig-wallet/README.md
@@ -0,0 +1,151 @@
+# `multisig-wallet`
+
+A general-purpose *m-of-n* multisignature wallet. Owners submit transactions,
+confirm them, and once the confirmation threshold is met anyone may execute
+them. Owner membership and the threshold itself are changed through the same
+confirmation flow, so no single owner can unilaterally alter the wallet.
+
+Contract type: `MultisigWallet`. Deployable (`crate-type = ["lib", "cdylib"]`).
+
+## Invariants
+
+- The threshold is always `>= 1` and `<= owners.len()`.
+- A wallet always has at least 2 owners.
+- A transaction executes only once — `executed` is checked before execution.
+- A frozen wallet rejects every mutating operation except `emergency_unfreeze`.
+- Confirmations are recorded per `(tx_id, address)`, so one owner cannot confirm
+ the same transaction twice.
+
+## Storage layout
+
+All state lives in **instance** storage except transactions, proposals, and
+confirmations, which are keyed individually.
+
+| Key | Type | Meaning |
+|---|---|---|
+| `Owners` | `Vec` | Current owner set. |
+| `OwnerProfile(Address)` | `OwnerProfile` | Per-owner metadata: type, voting weight, activity counters. |
+| `Threshold` | `u32` | Confirmations required to execute. |
+| `NextTxId` | `u64` | Monotonic transaction id counter. |
+| `Transaction(u64)` | `Transaction` | A submitted transaction. |
+| `Confirmation(u64, Address)` | `bool` | Whether an owner confirmed a transaction. |
+| `DailyLimit` | `u128` | Per-day spend cap; `0` means unlimited. |
+| `DailySpent(u64)` | `u128` | Amount spent on a given day bucket. |
+| `Frozen` | `bool` | Emergency freeze flag. |
+| `NextProposalId` | `u64` | Monotonic proposal id counter. |
+| `Proposal(u64)` | `OwnershipProposal` | An owner/threshold change proposal. |
+| `ProposalConfirmation(u64, Address)` | `bool` | Whether an owner confirmed a proposal. |
+| `Admin` | `Address` | Address that initialized the wallet. |
+
+## Entrypoints
+
+`Auth` names the address whose `require_auth()` is called. "—" means the
+entrypoint performs no authorization of its own.
+
+### Lifecycle
+
+| Entrypoint | Args | Returns | Auth | Errors |
+|---|---|---|---|---|
+| `initialize` | `admin, owners, threshold` | `Result<()>` | `admin` | `AlreadyInitialized`, `InsufficientOwners`, `InvalidThreshold` |
+
+### Transactions
+
+| Entrypoint | Args | Returns | Auth | Errors |
+|---|---|---|---|---|
+| `submit_transaction` | `initiator, to, amount, token, fn_name, args, expires_at` | `Result` | `initiator` | `NotInitialized`, `NotAnOwner`, `WalletFrozen` |
+| `confirm_transaction` | `confirmer, tx_id` | `Result<()>` | `confirmer` | `NotAnOwner`, `TransactionNotFound`, `TransactionAlreadyExecuted`, `TransactionExpired`, `AlreadyConfirmed`, `WalletFrozen` |
+| `revoke_confirmation` | `revoker, tx_id` | `Result<()>` | `revoker` | `NotAnOwner`, `TransactionNotFound`, `TransactionAlreadyExecuted` |
+| `execute_transaction` | `tx_id` | `Result<()>` | — (permissionless once the threshold is met) | `TransactionNotFound`, `TransactionAlreadyExecuted`, `TransactionExpired`, `DailyLimitExceeded`, `WalletFrozen` |
+| `cancel_transaction` | `caller, tx_id` | `Result<()>` | `caller` | `NotAnOwner`, `TransactionNotFound`, `TransactionAlreadyExecuted` |
+
+`execute_transaction` is intentionally callable by anyone: the authorization
+decision was already made by the confirming owners, and requiring one of them to
+also submit the execution transaction adds no security while adding liveness
+risk.
+
+### Ownership governance
+
+| Entrypoint | Args | Returns | Auth | Errors |
+|---|---|---|---|---|
+| `propose_add_owner` | `proposer, new_owner` | `Result` | `proposer` | `NotAnOwner`, `OwnerAlreadyExists` |
+| `propose_remove_owner` | `proposer, owner` | `Result` | `proposer` | `NotAnOwner`, `OwnerNotFound`, `InsufficientOwners` |
+| `propose_change_threshold` | `proposer, new_threshold` | `Result` | `proposer` | `NotAnOwner`, `InvalidThreshold`, `ThresholdTooHigh` |
+| `confirm_proposal` | `confirmer, proposal_id` | `Result<()>` | `confirmer` | `NotAnOwner`, `ProposalNotFound`, `AlreadyConfirmed` |
+| `execute_proposal` | `proposal_id` | `Result<()>` | — (permissionless once the threshold is met) | `ProposalNotFound`, `InvalidProposal`, `InvalidThreshold` |
+
+### Emergency and limits
+
+| Entrypoint | Args | Returns | Auth | Errors |
+|---|---|---|---|---|
+| `emergency_freeze` | `caller` | `Result<()>` | `caller` | `NotAnOwner` |
+| `emergency_unfreeze` | `caller` | `Result<()>` | `caller` (must be an owner) | `NotAnOwner` |
+| `set_daily_limit` | `caller, limit` | `Result<()>` | `caller` (must be an owner) | `NotAnOwner` |
+
+### Reads
+
+`get_owners`, `get_threshold`, `get_transaction`, `is_frozen`,
+`get_required_confirmations`, `get_owner_profile`, `get_proposal`. None require
+auth and none mutate state.
+
+## Events
+
+| Topics | Payload | Emitted by |
+|---|---|---|
+| `("tx_sub", tx_id)` | `(initiator, tx_type, timestamp)` | `submit_transaction` |
+| `("tx_conf", tx_id)` | `(confirmer, confirmations_count, timestamp)` | `confirm_transaction` |
+| `("tx_rev", tx_id)` | `(revoker, timestamp)` | `revoke_confirmation` |
+| `("tx_exec", tx_id)` | `(initiator, result, timestamp)` | `execute_transaction` |
+| `("tx_can", tx_id)` | `(caller, timestamp)` | `cancel_transaction` |
+| `("own_add",)` | `(new_owner, proposer, timestamp)` | `execute_proposal` (add-owner) |
+| `("own_rem",)` | `(removed_owner, proposer, timestamp)` | `execute_proposal` (remove-owner) |
+| `("thr_chg",)` | `(old_threshold, new_threshold, timestamp)` | `execute_proposal` (threshold change) |
+| `("frozen",)` | `(caller, timestamp)` | `emergency_freeze` |
+| `("unfrozen",)` | `(caller, timestamp)` | `emergency_unfreeze` |
+| `("lim_rch",)` | `(limit, attempted_total, timestamp)` | daily-limit check, before returning `DailyLimitExceeded` |
+
+Topics are `symbol_short!` values. Note the gaps: `initialize`, the three
+`propose_*` entrypoints, `confirm_proposal`, and `set_daily_limit` currently
+emit **no** event, so those state changes are not observable off-chain. See
+[SC-36] for the workspace-wide event convention and the plan to close these.
+
+## Errors
+
+Defined in [`src/errors.rs`](src/errors.rs), codes 1–19. See
+`contracts/SECURITY.md` and [SC-45] for the cross-contract code allocation.
+
+## Worked example
+
+```rust
+use soroban_sdk::{testutils::Address as _, vec, Address, Env};
+
+let env = Env::default();
+env.mock_all_auths();
+
+let contract_id = env.register(MultisigWallet, ());
+let client = MultisigWalletClient::new(&env, &contract_id);
+
+let admin = Address::generate(&env);
+let alice = Address::generate(&env);
+let bob = Address::generate(&env);
+let carol = Address::generate(&env);
+
+// 2-of-3 wallet.
+client.initialize(&admin, &vec![&env, alice.clone(), bob.clone(), carol.clone()], &2);
+
+// Alice proposes raising the threshold to 3.
+let proposal_id = client.propose_change_threshold(&alice, &3);
+
+// Alice and Bob confirm; the proposal now has 2 of 2 required confirmations.
+client.confirm_proposal(&alice, &proposal_id);
+client.confirm_proposal(&bob, &proposal_id);
+
+// Anyone may execute once the threshold is reached.
+client.execute_proposal(&proposal_id);
+assert_eq!(client.get_threshold(), 3);
+```
+
+## Tests
+
+```sh
+cargo test -p multisig-wallet
+```
diff --git a/contracts/multisig-wallet/src/lib.rs b/contracts/multisig-wallet/src/lib.rs
index 3533ed84c..8aaf78f0b 100644
--- a/contracts/multisig-wallet/src/lib.rs
+++ b/contracts/multisig-wallet/src/lib.rs
@@ -1,4 +1,31 @@
#![no_std]
+//! # multisig-wallet
+//!
+//! A general-purpose *m-of-n* multisignature wallet.
+//!
+//! Owners submit transactions, confirm them, and once the confirmation
+//! threshold is met anyone may execute them. Owner membership and the threshold
+//! itself are changed through the same confirmation flow, so no single owner can
+//! unilaterally alter the wallet.
+//!
+//! ## Invariants
+//!
+//! - The threshold is always `>= 1` and `<= owners.len()`.
+//! - A wallet always has at least two owners.
+//! - A transaction executes at most once.
+//! - A frozen wallet rejects every mutating operation except
+//! [`MultisigWallet::emergency_unfreeze`].
+//! - One owner cannot confirm the same transaction twice.
+//!
+//! ## Not to be confused with `multisig-transfer`
+//!
+//! This crate is generic — its subject is an arbitrary transaction.
+//! `multisig-transfer` is domain-specific: its subject is always an asset
+//! transfer, its thresholds are per asset category, and it delegates execution
+//! to a registry contract. Neither calls the other.
+//!
+//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/multisig-wallet/README.md)
+//! for the full entrypoint, storage, event, and error tables.
use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Val, Vec};
diff --git a/contracts/multisig_transfer/README.md b/contracts/multisig_transfer/README.md
new file mode 100644
index 000000000..a5a450217
--- /dev/null
+++ b/contracts/multisig_transfer/README.md
@@ -0,0 +1,126 @@
+# `multisig-transfer`
+
+An approval workflow for asset ownership transfers. A transfer is requested,
+collects approvals from authorized approvers until a per-category threshold is
+met, and is then executed — at which point this contract calls into a separate
+**asset registry contract** to actually move ownership.
+
+Contract type: `MultiSigTransferContract`. Deployable
+(`crate-type = ["lib", "cdylib"]`).
+
+> **Naming.** The directory is `multisig_transfer/` (underscore) but the package
+> is `multisig-transfer` (hyphen). Use `cargo test -p multisig-transfer`;
+> `-p multisig_transfer` will not resolve. Tracked in [SC-33].
+
+## Relationship to `multisig-wallet`
+
+These are different contracts solving different problems and neither calls the
+other:
+
+- **`multisig-wallet`** is a generic *m-of-n* wallet. Its subject is an
+ arbitrary transaction.
+- **`multisig-transfer`** is domain-specific. Its subject is always an asset
+ transfer, thresholds are configured **per asset category**, and execution
+ delegates to a registry contract.
+
+## Invariants
+
+- An asset has at most one pending transfer request at a time
+ (`AssetPendingRequest`); a second request fails with `PendingRequestExists`.
+- An approver cannot approve the same request twice (`AlreadyApproved`).
+- A requester cannot approve their own request (`CannotApproveOwnRequest`).
+- Execution requires `approvals >= rule.required_approvals` for the asset's
+ category (`NotEnoughApprovals`).
+- Retired assets cannot be transferred (`AssetRetired`).
+
+## Storage layout
+
+All entries use **persistent** storage.
+
+| Key | Type | Meaning |
+|---|---|---|
+| `Admin` | `Address` | Contract administrator. |
+| `AssetRegistry` | `Address` | Registry contract ownership changes are delegated to. |
+| `NextRequestId` | `u64` | Monotonic request id counter, starts at 1. |
+| `Requests` | `Map` | All transfer requests by id. |
+| `Rules` | `Map, ApprovalRule>` | Approval rule per asset category. |
+| `PendingApprovals` | `Map>` | Approvers who have signed off per request. |
+| `ApprovalFlags` | `Map<(u64, Address), bool>` | Double-approval guard. |
+| `ApprovalSignatures` | `Map<(u64, Address), BytesN<64>>` | Optional signature material. |
+| `AssetPendingRequest` | `Map, u64>` | The open request for an asset, if any. |
+| `AssetHistory` | `Map, Vec>` | All request ids ever raised for an asset. |
+
+Storage is centralized in [`src/storage.rs`](src/storage.rs) rather than
+scattered across modules — this is the pattern the rest of the workspace should
+follow.
+
+## Entrypoints
+
+| Entrypoint | Args | Returns | Auth | Errors |
+|---|---|---|---|---|
+| `initialize` | `admin, asset_registry` | `()` | — (none) | — |
+| `configure_approval_rule` | `caller, rule` | `Result<()>` | admin check via `require_admin` | `NotInitialized`, `Unauthorized` |
+| `create_transfer_request` | `caller, asset_id, asset_category, to_owner, ...` | `Result` | caller ownership check | `AssetNotFound`, `AssetRetired`, `InvalidOwner`, `InvalidNewOwner`, `PendingRequestExists`, `RuleNotFound` |
+| `approve_transfer_request` | `caller, request_id, ...` | `Result<()>` | approver membership check | `RequestNotFound`, `RequestNotPending`, `ApproverNotAuthorized`, `CannotApproveOwnRequest`, `AlreadyApproved`, `ApprovalDeadlinePassed` |
+| `reject_transfer_request` | `caller, request_id, reason_hash` | `Result<()>` | approver membership check | `RequestNotFound`, `RequestNotPending`, `ApproverNotAuthorized` |
+| `execute_transfer` | `caller, request_id` | `Result<()>` | `caller` | `RequestNotFound`, `RequestNotPending`, `NotEnoughApprovals`, `ExecuteTooEarly`, `RequestExpired`, `RegistryCallFailed` |
+| `cancel_transfer_request` | `caller, request_id` | `Result<()>` | requester or admin | `RequestNotFound`, `RequestNotPending`, `Unauthorized` |
+
+`initialize` performs **no** authorization and can be front-run on a freshly
+deployed contract — see [SC-42].
+
+### Reads
+
+`get_request`, `get_asset_history`, `get_pending_transfers_approver`,
+`get_required_approvers_category`. None require auth and none mutate state.
+
+## Events
+
+Event emission is centralized in [`src/events.rs`](src/events.rs) — the only
+crate in the workspace that does this.
+
+| Topic | Payload |
+|---|---|
+| `("TransferRequested",)` | `(request_id, asset_id, from_owner, to_owner, timestamp)` |
+| `("TransferApproved",)` | `(request_id, approver, approval_count, timestamp)` |
+| `("TransferRejected",)` | `(request_id, rejector, reason_hash, timestamp)` |
+| `("TransferExecuted",)` | `(request_id, asset_id, new_owner, timestamp)` |
+| `("TransferCancelled",)` | `(request_id, cancelled_by, timestamp)` |
+| `("ApprovalRuleUpdated",)` | `(category, required_approvals, timestamp)` |
+| `("ApproverAdded",)` | `(approver, added_by, timestamp)` |
+| `("ApproverRemoved",)` | `(approver, removed_by, timestamp)` |
+
+Topics here are string literals in `PascalCase`, while the rest of the workspace
+uses `symbol_short!` in `snake_case`. `ApproverAdded` and `ApproverRemoved` are
+`#[allow(dead_code)]` — declared but never emitted. See [SC-36].
+
+## Errors
+
+`MultiSigError`, defined in [`src/errors.rs`](src/errors.rs), codes 1–18.
+
+Note the collision risk with sibling contracts: code `1` is `NotInitialized`
+here but `AlreadyInitialized` in `assetsup`, `contrib`, and `multisig-wallet`.
+Resolving this is tracked in [SC-45].
+
+## Module layout
+
+| Module | Responsibility |
+|---|---|
+| `lib.rs` | Contract entrypoints. |
+| `storage.rs` | `DataKey` and typed storage accessors. |
+| `types.rs` | `TransferRequest`, `ApprovalRule`, `RequestStatus`. |
+| `approvals.rs` | Approval bookkeeping and double-approval guards. |
+| `registry.rs` | Cross-contract calls into the asset registry. |
+| `rules.rs` | Per-category approval rule lookup. |
+| `events.rs` | All event emission. |
+| `errors.rs` | `MultiSigError`. |
+| `utils.rs` | `require_admin`, `now`. |
+
+## Tests
+
+```sh
+cargo test -p multisig-transfer
+```
+
+This crate currently has **no tests at all**. Writing the suite is tracked in
+[SC-32].
diff --git a/contracts/multisig_transfer/src/lib.rs b/contracts/multisig_transfer/src/lib.rs
index 5cb3fc176..e7b5c3dd3 100644
--- a/contracts/multisig_transfer/src/lib.rs
+++ b/contracts/multisig_transfer/src/lib.rs
@@ -1,4 +1,34 @@
#![no_std]
+//! # multisig-transfer
+//!
+//! An approval workflow for asset ownership transfers.
+//!
+//! A transfer is requested, collects approvals from authorized approvers until
+//! a per-category threshold is met, and is then executed — at which point this
+//! contract calls into a separate asset **registry contract** to actually move
+//! ownership.
+//!
+//! ## Naming
+//!
+//! The directory is `multisig_transfer/` (underscore) but the package is
+//! `multisig-transfer` (hyphen), so use `cargo test -p multisig-transfer`.
+//!
+//! ## Invariants
+//!
+//! - An asset has at most one pending transfer request at a time.
+//! - An approver cannot approve the same request twice.
+//! - A requester cannot approve their own request.
+//! - Execution requires `approvals >= rule.required_approvals` for the asset's
+//! category.
+//! - Retired assets cannot be transferred.
+//!
+//! ## Not to be confused with `multisig-wallet`
+//!
+//! `multisig-wallet` is a generic *m-of-n* wallet over arbitrary transactions.
+//! This crate is domain-specific to asset transfers. Neither calls the other.
+//!
+//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/multisig_transfer/README.md)
+//! for the full entrypoint, storage, event, and error tables.
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec};
diff --git a/contracts/rust-toolchain.toml b/contracts/rust-toolchain.toml
new file mode 100644
index 000000000..f37d7cd81
--- /dev/null
+++ b/contracts/rust-toolchain.toml
@@ -0,0 +1,14 @@
+# Pinned toolchain for the contracts workspace.
+#
+# Every contracts job in .github/workflows/CI.yaml reads the `channel` value
+# below, so local builds and CI always use the same compiler. Floating
+# `stable` is deliberately avoided: a new stable release can introduce clippy
+# lints that fail `-D warnings` on a pull request that changed nothing.
+#
+# Upgrading: bump `channel` in its own dedicated pull request so lint churn is
+# isolated from feature work. See contracts/README.md#toolchain.
+[toolchain]
+channel = "1.96.0"
+components = ["rustfmt", "clippy"]
+targets = ["wasm32-unknown-unknown"]
+profile = "minimal"
diff --git a/contracts/scripts/README.md b/contracts/scripts/README.md
new file mode 100644
index 000000000..4fa251a21
--- /dev/null
+++ b/contracts/scripts/README.md
@@ -0,0 +1,91 @@
+# Deployment scripts
+
+## `deploy.sh`
+
+Builds, optimizes, deploys, initializes, and verifies the full AssetsUp contract
+suite in one command, then records the resulting contract ids for the backend to
+consume.
+
+```sh
+cd contracts
+./scripts/deploy.sh --network testnet --source alice
+```
+
+| Flag | Meaning |
+|---|---|
+| `--network` | Stellar network: `testnet`, `futurenet`, `local`, `mainnet`. |
+| `--source` | **Name** of a Stellar CLI identity to sign and pay with. |
+| `--skip-build` | Reuse existing `.wasm` artifacts instead of rebuilding. |
+
+### Prerequisites
+
+1. **Rust toolchain** — pinned by [`../rust-toolchain.toml`](../rust-toolchain.toml).
+ `rustup` installs it automatically on first use inside `contracts/`, including
+ the `wasm32-unknown-unknown` target.
+
+2. **Stellar CLI** ≥ 22 on `PATH`:
+
+ ```sh
+ cargo install --locked stellar-cli
+ stellar --version
+ ```
+
+3. **A funded identity.** Generate and fund one on testnet:
+
+ ```sh
+ stellar keys generate --network testnet alice
+ stellar keys address alice
+ ```
+
+4. **bash 4+.** macOS ships bash 3.2; install a newer one with `brew install bash`
+ and invoke the script with it. The script checks this and fails early.
+
+### What it does
+
+1. **Build** — each deployable contract for `wasm32-unknown-unknown --release`.
+2. **Optimize** — `stellar contract optimize`, reporting before/after sizes.
+3. **Deploy** — in dependency order, so `assetsup` exists before the contracts
+ that take its address.
+4. **Initialize** — wires cross-contract addresses:
+ - `assetsup.initialize(admin)`
+ - `contrib.initialize(admin)`
+ - `asset-maintenance.init(admin, registry = assetsup)`
+ - `multisig-transfer.initialize(admin, asset_registry = assetsup)`
+5. **Verify** — calls a read entrypoint on each deployed contract and fails the
+ run if any does not respond.
+6. **Record** — writes `deployments/.json`.
+
+### `multisig-wallet` is deliberately not initialized
+
+A wallet needs at least two owners and a real threshold; initializing it with
+the deploying identity as sole owner would be both invalid
+(`InsufficientOwners`) and a security mistake. The script deploys it and prints
+the exact `initialize` command to run with your real signer set.
+
+### Output
+
+`deployments/.json` is **git-ignored** — it is environment state, not
+source. [`../deployments/testnet.example.json`](../deployments/testnet.example.json)
+is committed and documents the shape:
+
+```json
+{
+ "network": "testnet",
+ "admin": "G...",
+ "deployedAt": "2026-01-01T00:00:00Z",
+ "contracts": {
+ "assetsup": "C...",
+ "contrib": "C...",
+ "multisig-wallet": "C...",
+ "asset-maintenance": "C...",
+ "multisig-transfer": "C..."
+ }
+}
+```
+
+### Key material
+
+The script never reads, writes, or logs a secret key. `--source` takes an
+identity **name** that the Stellar CLI resolves from its own keystore; passing
+something that looks like a secret key is rejected outright, because it would
+otherwise land in shell history and CI logs.
diff --git a/contracts/scripts/deploy.sh b/contracts/scripts/deploy.sh
new file mode 100755
index 000000000..795137c13
--- /dev/null
+++ b/contracts/scripts/deploy.sh
@@ -0,0 +1,227 @@
+#!/usr/bin/env bash
+#
+# Reproducible deployment of the AssetsUp contract suite.
+#
+# Handles build -> optimize -> deploy -> initialize -> verify for every
+# deployable contract, and writes the resulting contract ids to
+# deployments/.json for the backend to consume.
+#
+# Usage:
+# ./scripts/deploy.sh --network testnet --source alice
+# ./scripts/deploy.sh --network testnet --source alice --skip-build
+#
+# No key material is read or written by this script. Identities are managed by
+# the Stellar CLI keystore and referenced by name only.
+
+set -euo pipefail
+
+# Associative arrays require bash 4+. macOS ships bash 3.2 as /bin/bash, so
+# reach for a newer one (brew install bash) rather than failing obscurely later.
+if (( BASH_VERSINFO[0] < 4 )); then
+ echo "error: bash 4+ required, found ${BASH_VERSION}." >&2
+ echo " On macOS: brew install bash, then re-run with that bash." >&2
+ exit 1
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CONTRACTS_DIR="$(dirname "$SCRIPT_DIR")"
+cd "$CONTRACTS_DIR"
+
+NETWORK=""
+SOURCE=""
+SKIP_BUILD=0
+WASM_DIR="target/wasm32-unknown-unknown/release"
+
+# Deployable contracts, in dependency order. multisig-transfer is initialized
+# with the assetsup registry address, so assetsup must be deployed first.
+CONTRACTS=(
+ "assetsup"
+ "contrib"
+ "multisig-wallet"
+ "asset-maintenance"
+ "multisig-transfer"
+)
+
+usage() {
+ cat <<'EOF'
+Usage: ./scripts/deploy.sh --network --source [--skip-build]
+
+ --network Stellar network to deploy to (testnet, futurenet, local, mainnet).
+ --source Name of a Stellar CLI identity to sign and pay with. Must be
+ funded. Never pass a secret key here.
+ --skip-build Reuse existing .wasm artifacts instead of rebuilding.
+
+Prerequisites:
+ - Rust toolchain pinned by contracts/rust-toolchain.toml (rustup installs it
+ automatically) with the wasm32-unknown-unknown target.
+ - stellar CLI >= 22 on PATH.
+ - A funded identity: stellar keys generate --network testnet
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --network) NETWORK="${2:-}"; shift 2 ;;
+ --source) SOURCE="${2:-}"; shift 2 ;;
+ --skip-build) SKIP_BUILD=1; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "error: unknown argument '$1'" >&2; usage; exit 2 ;;
+ esac
+done
+
+if [[ -z "$NETWORK" || -z "$SOURCE" ]]; then
+ echo "error: --network and --source are both required" >&2
+ usage
+ exit 2
+fi
+
+if [[ "$SOURCE" == S* && ${#SOURCE} -eq 56 ]]; then
+ echo "error: --source looks like a secret key. Pass an identity NAME instead;" >&2
+ echo " secrets must never appear in shell history or CI logs." >&2
+ exit 2
+fi
+
+command -v stellar >/dev/null 2>&1 || {
+ echo "error: 'stellar' CLI not found on PATH. See https://developers.stellar.org/docs/tools/cli" >&2
+ exit 1
+}
+
+log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
+
+# Package name -> crate directory. These disagree for multisig-transfer.
+crate_dir() {
+ case "$1" in
+ multisig-transfer) echo "multisig_transfer" ;;
+ *) echo "$1" ;;
+ esac
+}
+
+# ---------------------------------------------------------------- build
+
+if [[ "$SKIP_BUILD" -eq 0 ]]; then
+ log "Building ${#CONTRACTS[@]} contracts for wasm32-unknown-unknown (release)"
+ for c in "${CONTRACTS[@]}"; do
+ log " building $c"
+ cargo build --package "$c" --target wasm32-unknown-unknown --release
+ done
+else
+ log "Skipping build (--skip-build)"
+fi
+
+log "Optimizing WASM artifacts"
+for c in "${CONTRACTS[@]}"; do
+ wasm="$WASM_DIR/${c//-/_}.wasm"
+ if [[ ! -f "$wasm" ]]; then
+ echo "error: expected artifact not found: $wasm" >&2
+ exit 1
+ fi
+ before=$(wc -c < "$wasm" | tr -d ' ')
+ stellar contract optimize --wasm "$wasm" >/dev/null
+ optimized="${wasm%.wasm}.optimized.wasm"
+ after=$(wc -c < "$optimized" | tr -d ' ')
+ printf ' %-20s %8s -> %8s bytes\n' "$c" "$before" "$after"
+done
+
+# ---------------------------------------------------------------- deploy
+
+OUT_DIR="deployments"
+OUT_FILE="$OUT_DIR/$NETWORK.json"
+mkdir -p "$OUT_DIR"
+
+declare -A DEPLOYED
+
+log "Deploying to '$NETWORK' as identity '$SOURCE'"
+for c in "${CONTRACTS[@]}"; do
+ wasm="$WASM_DIR/${c//-/_}.optimized.wasm"
+ id=$(stellar contract deploy \
+ --wasm "$wasm" \
+ --source-account "$SOURCE" \
+ --network "$NETWORK")
+ DEPLOYED["$c"]="$id"
+ printf ' %-20s %s\n' "$c" "$id"
+done
+
+# ---------------------------------------------------------------- initialize
+
+ADMIN=$(stellar keys address "$SOURCE")
+log "Initializing contracts (admin: $ADMIN)"
+
+invoke() {
+ local contract_id="$1"; shift
+ stellar contract invoke \
+ --id "$contract_id" \
+ --source-account "$SOURCE" \
+ --network "$NETWORK" \
+ -- "$@"
+}
+
+log " assetsup.initialize"
+invoke "${DEPLOYED[assetsup]}" initialize --admin "$ADMIN" >/dev/null
+
+log " contrib.initialize"
+invoke "${DEPLOYED[contrib]}" initialize --admin "$ADMIN" >/dev/null
+
+log " asset-maintenance.init (registry: assetsup)"
+invoke "${DEPLOYED[asset-maintenance]}" init \
+ --admin "$ADMIN" --registry "${DEPLOYED[assetsup]}" >/dev/null
+
+log " multisig-transfer.initialize (registry: assetsup)"
+invoke "${DEPLOYED[multisig-transfer]}" initialize \
+ --admin "$ADMIN" --asset_registry "${DEPLOYED[assetsup]}" >/dev/null
+
+# multisig-wallet needs an owner set and a threshold. A single-owner wallet is
+# invalid (InsufficientOwners), so it is left uninitialized here and must be
+# initialized deliberately with the real signer set.
+warn "multisig-wallet deployed but NOT initialized: it needs >= 2 owners and a"
+warn "threshold. Initialize it explicitly with your real signer set:"
+warn " stellar contract invoke --id ${DEPLOYED[multisig-wallet]} \\"
+warn " --source-account $SOURCE --network $NETWORK \\"
+warn " -- initialize --admin $ADMIN --owners '[...]' --threshold 2"
+
+# ---------------------------------------------------------------- verify
+
+log "Verifying deployments with a read call against each contract"
+
+verify() {
+ local name="$1" id="$2"; shift 2
+ if invoke "$id" "$@" >/dev/null 2>&1; then
+ printf ' %-20s \033[1;32mOK\033[0m\n' "$name"
+ else
+ printf ' %-20s \033[1;31mFAILED\033[0m\n' "$name"
+ return 1
+ fi
+}
+
+failed=0
+verify "assetsup" "${DEPLOYED[assetsup]}" get_admin || failed=1
+verify "contrib" "${DEPLOYED[contrib]}" get_admin || failed=1
+verify "asset-maintenance" "${DEPLOYED[asset-maintenance]}" get_asset_stats --asset_id 0 || failed=1
+verify "multisig-transfer" "${DEPLOYED[multisig-transfer]}" get_asset_history --asset_id 0000000000000000000000000000000000000000000000000000000000000000 || failed=1
+
+if [[ "$failed" -ne 0 ]]; then
+ echo "error: at least one contract failed verification" >&2
+ exit 1
+fi
+
+# ---------------------------------------------------------------- record
+
+log "Writing $OUT_FILE"
+{
+ printf '{\n'
+ printf ' "network": "%s",\n' "$NETWORK"
+ printf ' "admin": "%s",\n' "$ADMIN"
+ printf ' "deployedAt": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ printf ' "contracts": {\n'
+ last_index=$(( ${#CONTRACTS[@]} - 1 ))
+ for i in "${!CONTRACTS[@]}"; do
+ c="${CONTRACTS[$i]}"
+ sep=","
+ [[ "$i" -eq "$last_index" ]] && sep=""
+ printf ' "%s": "%s"%s\n' "$c" "${DEPLOYED[$c]}" "$sep"
+ done
+ printf ' }\n'
+ printf '}\n'
+} > "$OUT_FILE"
+
+log "Done. Contract ids are in $OUT_FILE (git-ignored)."