diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f7a7ab7..15ba9be4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,10 +2,15 @@ name: Build Project and Run Tests on: push: - branches: ["main"] + branches: ["main", "feedback"] pull_request: types: [opened, synchronize, reopened, labeled, unlabeled] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # A cancelled push run would leave a failed build check on that commit. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always PKG_CONFIG_PATH: /usr/lib/pkgconfig @@ -15,39 +20,83 @@ permissions: contents: read jobs: - build: + changes: runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # The test merge commit and its parents, to diff a pull request against its base. + fetch-depth: 2 + - name: Check whether anything besides docs changed + id: filter + run: | + code=true + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + CHANGED=$(git diff --no-renames --name-only HEAD^1 HEAD) + printf '%s\n' "$CHANGED" + code=false + while IFS= read -r path; do + case "$path" in + "") ;; + # The harness hashes the sample valuation. + docs/evidence/*) code=true ;; + *.md) ;; + *) code=true ;; + esac + done <<< "$CHANGED" + fi + echo "code=$code" | tee -a "$GITHUB_OUTPUT" + js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 - run: npm i -g npm@11.7.0 - run: npm --version - - # ---- Fast checks first ---- - # Fail in seconds on lint / format before the multi-minute CLI build + - # Docker network below, so trivial fixes don't pay the full cost. - run: npm ci - run: npm run lint - run: npm run format:check - run: npm run test - # ---- Slow path: CLI toolchain, local network, contract codegen ---- - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + secret-scan: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Every commit of the pull request, so each one is scanned. + fetch-depth: 0 + - name: Install gitleaks + run: scripts/ci/install-tools.sh gitleaks + # Per commit, so a key added and later removed in the same pull request still fails. + - name: Scan the pull request's commits for secrets + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: gitleaks git --redact --no-banner --log-opts="$BASE..$HEAD" . + + rust-tests: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # NB: ~/.cargo/bin is deliberately NOT cached here. Keying the - # stellar-scaffold binary on this repo's Cargo.lock would restore a - # binary built from an unrelated CLI revision. - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + persist-credentials: false + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + cache-bin: "false" + workspaces: | + . -> target # Before the CLI install so a broken contract test fails without # waiting on it. rustup picks the toolchain from rust-toolchain.toml. - name: Run the contract unit tests @@ -55,32 +104,125 @@ jobs: - run: sudo apt-get update && sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config - # binstall fetches a prebuilt stellar-scaffold-cli instead of compiling it - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@e00d2c94cc0067b77737821097a62d91c0301baa # v1.21.1 - - name: Install stellar-scaffold + - name: Install the stellar CLI + run: scripts/ci/install-tools.sh stellar + # The upgrade tests run built Wasm, so they need both contracts and + # their probe builds first (docs/TESTING.md). + - name: Run the Wasm upgrade tests run: | - if ! command -v stellar-scaffold &> /dev/null; then - echo "stellar-scaffold not found, installing..." - # --locked so the source-build fallback uses the CLI's published - # Cargo.lock instead of resolving fresh soroban deps whose MSRV - # may outrun the pinned toolchain. - cargo binstall --locked stellar-scaffold-cli@0.0.27 - else - echo "stellar-scaffold already installed. Clear cache to force reinstall." - fi - - name: Install stellar CLI + # Setting pipefail so a failing test run is not hidden by `tee` + set -o pipefail + stellar contract build --package share-token + stellar contract build --package nav-oracle + CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package share-token --features upgrade_probe + CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package nav-oracle --features upgrade_probe + CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package async-vault --features upgrade_probe + cargo test --locked -p async-vault -p nav-oracle --features upgrade_wasm with_wasm 2>&1 | tee upgrade-tests.log + scripts/ci/check-tests-ran.sh upgrade-tests.log test::upgrade::with_wasm test::governance::with_wasm + + network: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # In the background, so the steps up to "Wait for the local network" run while it boots. + - name: Start Stellar local network + env: + # Pinned by digest: "latest" moves with every protocol build. + IMAGE: docker.io/stellar/quickstart:v687-b1580.1-latest@sha256:7711899e9942c60316ebacf8ab901be15bed7a5a31705d847b55375ff3bc8356 + # The image's own default can be a protocol its RPC cannot simulate. + PROTOCOL_VERSION: "30" + # The exit file lets the wait step fail at once when the pull or start fails. run: | - if ! command -v stellar &> /dev/null; then - cargo binstall --locked stellar-cli@27.0.0 + ( + trap '' HUP + status=0 + docker run -d --name stellar -p 8000:8000 \ + -e ENABLE_LOGS=true -e ENABLE=core,rpc -e NETWORK=local \ + -e PROTOCOL_VERSION="$PROTOCOL_VERSION" "$IMAGE" || status=$? + echo "$status" > "$RUNNER_TEMP/quickstart.exit" + ) > "$RUNNER_TEMP/quickstart.log" 2>&1 & + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - run: npm i -g npm@11.7.0 + - run: npm --version + - run: npm ci + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + cache-bin: "false" + - run: + sudo apt-get update && sudo apt-get install -y libudev-dev + libdbus-1-dev pkg-config + - name: Install the stellar CLI and stellar-scaffold + run: scripts/ci/install-tools.sh stellar stellar-scaffold + # The same cargo build Scaffold runs, so its build below reuses the Wasm. + - name: Build the contracts + run: stellar contract build + # A build path in the Wasm would tie its hash to the checkout location. + - name: Print the Wasm hashes and check no build path is embedded + run: | + sha256sum target/wasm32v1-none/release/*.wasm + if grep -l -a -E '/home/runner|/Users/|\.cargo/registry' target/wasm32v1-none/release/*.wasm; then + echo "These Wasm files embed a build path, so their hashes depend on where the repository is checked out." + exit 1 fi - - name: Start Stellar local network - # No tagged releases upstream, so this pins main as read on 2026-08-14 - uses: stellar/quickstart@864f91d804c0061bd11ef8872d3e318581febb55 + - name: Build the vault without the wind-down minimum + run: + CARGO_TARGET_DIR=target/short-wind-down stellar contract build + --package async-vault --features short_wind_down + - name: Read the Playwright version + id: playwright + run: | + VERSION=$(jq -r '.packages["node_modules/playwright-core"].version' package-lock.json) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Cache the Playwright browser + id: playwright-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - enable: "core,rpc" - # Pinned image: "latest" tracks new protocol builds. - tag: v678-b1525.1-latest + path: ~/.cache/ms-playwright + key: + ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }} + - name: Install Playwright browser + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + working-directory: e2e + - name: Install Playwright's system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + working-directory: e2e + + - name: Wait for the local network + timeout-minutes: 12 + run: | + fail() { + echo "$1" + cat "$RUNNER_TEMP/quickstart.log" + docker logs stellar || true + exit 1 + } + while [ "$SECONDS" -lt 600 ]; do + EXIT=$(cat "$RUNNER_TEMP/quickstart.exit" 2>/dev/null || true) + if [ -n "$EXIT" ] && [ "$EXIT" != 0 ]; then + fail "docker run failed with exit code $EXIT." + fi + case "$(docker inspect -f '{{.State.Status}}' stellar 2>/dev/null || true)" in + exited | dead) fail "The local network container stopped." ;; + esac + if curl --no-progress-meter --max-time 5 -X POST -H 'Content-Type: application/json' \ + -d '{"jsonrpc": "2.0", "id": 1, "method": "getHealth"}' \ + http://localhost:8000/rpc 2>/dev/null | grep -q healthy && + curl --no-progress-meter --max-time 5 http://localhost:8000/friendbot 2>/dev/null | + grep -q '"invalid_field": "addr"'; then + exit 0 + fi + sleep 2 + done + fail "The local network did not start in 10 minutes." - name: Add master account as default identity run: | mkdir -p .config/stellar/identity @@ -92,6 +234,8 @@ jobs: set -o pipefail STELLAR_SCAFFOLD_ENV=development stellar-scaffold build \ --build-clients 2>&1 | tee build_clients.log + # Build the clients against the workspace @stellar/stellar-sdk, not the scaffold's copy. + rm -rf app-lib/clients/*/node_modules - name: Check client generation summary run: | # Extract the number after "Failed: " @@ -127,10 +271,23 @@ jobs: exit 1 fi echo "Generated $ACTUAL client(s)." - - run: npm run build --workspaces --if-present - - name: Install Playwright browser - run: npx playwright install --with-deps chromium - working-directory: e2e + - name: + Deploy the native asset contract and register it as the alias asset + # The console is written against a Stellar Asset Contract, so the + # development build needs one. Its id is deterministic, so a rerun only + # re-registers the alias. + run: | + RPC=http://localhost:8000/rpc + PASSPHRASE="Standalone Network ; February 2017" + ID=$(stellar contract id asset --asset native --rpc-url "$RPC" --network-passphrase "$PASSPHRASE") + if ! stellar contract invoke --id "$ID" --source me --rpc-url "$RPC" --network-passphrase "$PASSPHRASE" -- name > /dev/null 2>&1; then + stellar contract asset deploy --asset native --source me --rpc-url "$RPC" --network-passphrase "$PASSPHRASE" + fi + stellar contract alias add asset --id "$ID" --rpc-url "$RPC" --network-passphrase "$PASSPHRASE" + - name: Build the workspaces against the contracts deployed above + run: npm run build --workspaces --if-present + env: + ADDRESSES_ENV: development - name: Run smoke tests run: npx playwright test working-directory: e2e @@ -148,10 +305,62 @@ jobs: run: npm run test:guards -w scripts/harness env: HARNESS_NETWORK: local + - name: Run a wind-down on a throwaway vault on the local network + run: npm run test:wind-down -w scripts/harness + env: + HARNESS_NETWORK: local - name: Keep the harness report when it fails if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: harness-runs path: scripts/harness/runs/ if-no-files-found: ignore + + rust-checks: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + cache-bin: "false" + # rustup reads rust-toolchain.toml, which includes clippy and rustfmt. + - name: Check formatting + run: cargo fmt --all -- --check + - name: Lint with warnings as errors + run: cargo clippy --locked --workspace --all-targets -- -D warnings + - name: Check licences, advisories, bans and sources + uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + # Replaces the action's default --all-features, so CI runs the + # same command as docs/TESTING.md. + arguments: --locked + command: check + + # The check branch protection requires. Rust and network skip only for docs; the secret scan runs on pull requests. + build: + if: always() + needs: [changes, js, secret-scan, rust-tests, rust-checks, network] + runs-on: ubuntu-latest + steps: + - name: Check the results + env: + CODE: ${{ needs.changes.outputs.code }} + CHANGES: ${{ needs.changes.result }} + JS: ${{ needs.js.result }} + EVENT: ${{ github.event_name }} + SECRET_SCAN: ${{ needs.secret-scan.result }} + RUST_TESTS: ${{ needs.rust-tests.result }} + RUST_CHECKS: ${{ needs.rust-checks.result }} + NETWORK: ${{ needs.network.result }} + run: | + echo "changes=$CHANGES js=$JS event=$EVENT secret-scan=$SECRET_SCAN rust-tests=$RUST_TESTS rust-checks=$RUST_CHECKS network=$NETWORK code=$CODE" + [ "$CHANGES" = success ] && [ "$JS" = success ] || exit 1 + if [ "$EVENT" = pull_request ]; then [ "$SECRET_SCAN" = success ] || exit 1; fi + if [ "$CODE" != false ]; then + [ "$RUST_TESTS" = success ] && [ "$RUST_CHECKS" = success ] && [ "$NETWORK" = success ] || exit 1 + fi diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..1100f376 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,2 @@ +# A captured RPC fetch response used as a test fixture, not a secret +scripts/harness/lib/ttl.test.ts:generic-api-key:22 diff --git a/AGENTS.md b/AGENTS.md index 7ad91313..7ad69795 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,18 +20,23 @@ result. Three states per side, none skipped: **pending, priced, claimed**. What the contracts enforce: -1. **Entry is gated** by a post-KYC allowlist, checked on the receiver of a - subscription (enforced on share claim in contracts today; request-time - check is targeted for a future milestone) and on every share transfer. +1. **Only allowlisted addresses enter.** The post-KYC allowlist is checked + when a subscription is requested, again when its shares are minted, and on + every share transfer. 2. **A covered claim always pays.** Once priced and covered, a cash claim cannot be blocked by a pause, a stale valuation, or the investor losing their - allowlist place. Priced claims are never re-priced and never identity-gated; - a delisted, non-frozen investor leaves through the exit-only cash path. -3. **Cancellation is atomic and single-step**, open only until the attestation - that prices the request is accepted. There is no instant exit. - -Five authorities, each a native Stellar multisig: governance, compliance, -attestation, treasury, guardian. Testnet only. Not audited. + allowlist place. Priced claims are never re-priced, and a cash payout never + needs the identity check; a delisted, non-frozen investor leaves through + the exit-only cash path, including a deposit that was priced before the + delisting. A compliance freeze holds the claim until it is lifted. +3. **Cancellation is atomic and single-step**, open only while the vault cannot + price the request: the attestation that prices it closes cancellation, and + cancellation reopens if that report expires, the NAV is paused or the vault + is paused. There is no instant exit. + +Five authorities, each meant to be a native Stellar multisig: governance, +compliance, attestation, treasury, guardian. The deploy script gives each one +key for now. Testnet only. Not audited. ## Reference base @@ -48,7 +53,9 @@ does not carry over. Read it for Soroban and OZ mechanics only. - **Tests:** `cargo test` from the repo root runs every workspace member against the in-memory `Env`. There is no unit-test runner for `app/` or `app-lib/`; `e2e/` runs Playwright separately. CI runs `cargo test --locked` on every pull - request. + request except one that changes only Markdown files outside `docs/evidence/`: + that one skips the Rust and local-network jobs. The secret scan runs on + every pull request. - **Toolchain:** `nix develop` provides it, or rustup honours `rust-toolchain.toml`. @@ -80,7 +87,13 @@ apply as the corresponding code lands here. `scripts/generate-clients.mjs`, which reads the contract ids and RPC URL of `ADDRESSES_ENV` (default `staging`) from `environments.toml` and writes `app-lib/clients//` from each contract's on-chain spec, with Node - alone. Those generated types are the only contract types; `app-lib/contracts.ts` + alone. `development` declares no ids: there the script resolves each + `[development.contracts.]` block plus an alias named `asset` through + `stellar contract alias show`, so CI builds the app against the contracts + the branch just deployed. The scaffold registers the block aliases when it + deploys; the asset is a Stellar Asset Contract a developer registers once with + `stellar contract asset deploy --asset native --alias asset --source me` + against the local network. Those generated types are the only contract types; `app-lib/contracts.ts` re-exports them and builds each runtime client with `new Client(...)` from its generated `src/index`, whose embedded spec means no page fetches a contract's instance or Wasm. Vite dedupes `@stellar/stellar-sdk` so a client's nested copy @@ -89,10 +102,13 @@ apply as the corresponding code lands here. A fresh clone builds `app` with no cargo, no stellar CLI and no local network, but needs RPC access to the selected network at build time. A local scaffold build writes the same directory from the WASM. -- When a tool must drive `cargo` directly rather than the CLI, set - `SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1`. The build script fails - without it and its error names neither the variable nor this way out, so - coverage runs and size checks look impossible until you read the SDK source. +- When `cargo` itself builds a contract for the Wasm target, rather than + `stellar contract build` (a size check with + `cargo build --release --target wasm32v1-none`, for example), set + `SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1`. soroban-sdk's build + script stops that build without it, and its error names neither the variable + nor this way out. Host builds such as `cargo test` and `cargo clippy` never + need it. - Change a contract's `__constructor` and you must change its `constructor_args` in `environments.toml` in the same commit. Otherwise the scaffold build still exits 0 and CI fails minutes later on a client count, naming neither the @@ -111,6 +127,45 @@ Not recorded here on purpose. The reference base kept them in this file and they drifted: its `AGENTS.md` and its generated client pointed at two different vault contracts. The addresses emitted by the deploy script are authoritative. +## Code conventions + +- **Crate names.** Shared crates in `crates/` start with `strata-` + (`strata-timelock`, `strata-storage`, `strata-bindings`), so a path such as + `strata_timelock::DAY_DURATION` reads as the kit's own code. +- **Addresses in entrypoints.** In an entrypoint the kit defines, `caller` is + the account that must sign the call, and `holder` is the account whose + position, request or balance the call is about. When one account is both, the + entrypoint takes `holder` and that account signs. The two ends of a share + transfer are `from` and `to`, and other addresses are named for what they + are (`custodian`, `vault`, the allowlisted `account`). OpenZeppelin trait + methods keep their own names (`operator`, `user_address`). +- **Named errors.** Contract code fails with a named error variant, never with + `unwrap`, `expect` or a bare `panic!`. Tests may unwrap. Each contract + declares its error enum through `strata_bindings::error_enum!`, which fails + the build if a code leaves the contract's block (`docs/kit-spec.md`, section + 7). +- **No boolean switches.** A new entrypoint does one thing. Two behaviours get + two functions, like `freeze_partial_tokens` and `unfreeze_partial_tokens`, + not one function that takes a `bool`. +- **Guards.** Each precondition is a `pub(crate) fn` in the contract's + `guards.rs`. Its name is the condition it checks (`positive_amount`, + `not_winding_down`), and it panics with a named error when the condition + fails. An entrypoint calls its guards first and then does its work, so its + preconditions are the first lines a reader sees. +- **Doc comments.** Every public entrypoint has one, in OpenZeppelin's style: a + one-line summary, then `# Arguments` with units (asset base units, share base + units, WAD prices, seconds or ledgers), `# Errors`, `# Events` and + `# Authorization`. Every error variant's doc says when it happens. Keep them + short: they are stored in the Wasm as part of the contract spec, which keeps + 1,024 bytes of each doc and drops the rest without a warning. + `every_contract_doc_fits_the_spec` (`crates/strata-bindings/tests/spec_docs.rs`) + fails when one is longer. +- **Design notes.** Each contract's `lib.rs` opens with a `//! # Design` + section: five to ten bullets, each a choice and its trade-off, in plain words + and with no issue numbers or decision ids. +- **Exact pins.** Every crates.io dependency, dev-dependencies included, is + pinned with `=` to the version in `Cargo.lock`. + ## Conventions - Conventional Commits, imperative, subject ≤ 50 chars. **No AI attribution.** @@ -118,8 +173,8 @@ contracts. The addresses emitted by the deploy script are authoritative. `feat/-`, `fix/...`, `chore/...`. - Issue bodies follow the templates in `.github/ISSUE_TEMPLATE/`, 200 words of prose hard cap. Titles are natural language with no prefix. -- Exact dependency pins. Dependencies are never upgraded mid-milestone; each - migration is its own issue in the following one. +- Dependencies are never upgraded mid-milestone; each migration is its own + issue in the following one. - Estimates live in the `Estimate` field of the [project board](https://github.com/orgs/BootNodeDev/projects/29), not in issue bodies. diff --git a/Cargo.lock b/Cargo.lock index 2eda79d5..983faeda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,7 +184,6 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" name = "async-vault" version = "0.1.0" dependencies = [ - "bindings", "compliance", "identity-verifier", "nav-oracle", @@ -194,7 +193,10 @@ dependencies = [ "stellar-access", "stellar-contract-utils", "stellar-macros", - "storage", + "stellar-tokens", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -221,13 +223,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bindings" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -353,6 +348,9 @@ dependencies = [ "stellar-access", "stellar-macros", "stellar-tokens", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -1031,7 +1029,7 @@ dependencies = [ "stellar-access", "stellar-macros", "stellar-tokens", - "storage", + "strata-storage", ] [[package]] @@ -1208,11 +1206,13 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "nav-oracle" version = "0.1.0" dependencies = [ - "bindings", "soroban-sdk", "stellar-access", + "stellar-contract-utils", "stellar-macros", - "storage", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -1711,6 +1711,9 @@ dependencies = [ "stellar-contract-utils", "stellar-macros", "stellar-tokens", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -2052,12 +2055,28 @@ dependencies = [ ] [[package]] -name = "storage" +name = "strata-bindings" version = "0.1.0" dependencies = [ "soroban-sdk", ] +[[package]] +name = "strata-storage" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "strata-timelock" +version = "0.1.0" +dependencies = [ + "proptest", + "soroban-sdk", + "strata-storage", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 72abd44b..600a7b0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = ["crates/*", "contracts/*"] -exclude = ["fixtures/upgrade-target"] [workspace.package] edition = "2021" diff --git a/README.md b/README.md index b3770010..6944d796 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,16 @@ Two rules the contracts enforce: 1. **Only approved addresses can enter** — an allowlist maintained post-KYC. 2. **A priced claim always pays** — neither a pause, a delisting nor a stale - valuation can block an already-priced, funded cash claim. + valuation can block an already-priced, funded cash claim. A compliance + freeze holds it until the freeze is lifted. + +A request can be cancelled until the vault can read its price. Once an epoch is +sealed, a valid report accepted after the seal closes cancellation, even while +the notice still runs; the epoch is priced once the notice ends. If that report +expires, or the NAV or the vault is paused, cancellation opens again. New +deposits are refused while the vault is paused, while the valuation is stale +or the NAV is paused, while an issuer clawback leaves the vault holding less +than its pending deposits, and once a wind-down is proposed. ## Documentation @@ -22,6 +31,14 @@ Two rules the contracts enforce: parameters, and complete role matrix. - [Testing](./docs/TESTING.md) — which tests cover storage and TTL, auth, ledger time, events and calls between contracts. +- [Threat model](./docs/THREAT_MODEL.md) — what can go wrong with the oracle, + the roles and the redemption paths, what stops it, what is left, and the + test for each control. +- [Monitoring plan](./docs/MONITORING_PLAN.md) — what an operator watches on + chain, where each signal comes from, and what to do when it fires. +- [Grant differences](./docs/GRANT_DIFFERENCES.md) — where the built kit + differs from the SCF grant proposal, what is not built yet, and the limits an + adopter should know. ## Status @@ -35,11 +52,11 @@ deploy from `environments.toml` → `[staging.contracts]`, which is the record. | Contract | Address | | --- | --- | -| `async_vault` | `CD5LYYXGOJ7R23P6D7YUN6DUJQYMI23KEUEOIFKXOSWOX3KHKTXRITED` | -| `nav_oracle` | `CBKVMUMIX7MXYHX5K34N5HYZOP6S2VGWF7MN27RIWBV3A3WD3X3L63CO` | -| `share_token` | `CDOISQTQRCYOTH6S3DFQ6UKJ2TEXLNO7NFIGOZS4RZL63IRCVXXCGP5N` | -| `identity_verifier` | `CCTCCY7AVFAIRGEWWJZT2UDMBHS3NGNKVUWLY43CHDNN75AQEV6P3EOY` | -| `compliance` | `CDEH6FJBK7NDFLALO3X3GDXZUREYHUSNC7ODPM64RBG3BBXW2USMW7OO` | +| `async_vault` | `CDJHQ2R5XUJGP6WUKCW4VBEVGWFAZ5TZLEONBITKITNXFDLWNNK7WVYO` | +| `nav_oracle` | `CCZ4X25GLMYRQ33PM4ZYDMGKAJJKPUG2ABUZ4NIGUSA7W4RUTBOEEDYI` | +| `share_token` | `CDOZT5VNIKSLQSFELF4PHVZRZA3GCJYJEHVDZTUB7O2QMBHBKKOYORQI` | +| `identity_verifier` | `CCGHNGPI66WGVSLLALQVCIRIOZKX73QW23UDZLZ3LMXLGEG7SLVUOULD` | +| `compliance` | `CCQI5NFRWBOO77LYSFY54D6PSU4TVWD5A3CFX2A5AWLEURVR35XISMKG` | | `asset` | `CCOHKZLQ5EAD2A3RLHYHH7W4K2TNLNELXUVLWZIZALH45WXHH5H3QV7E` | @@ -57,6 +74,36 @@ above. | Redeem (request) | `request_redeem` | [`d6c02ee0…7524`](https://stellar.expert/explorer/testnet/tx/d6c02ee0868235fd12c280ba8c4e17fd6d63f4a117e9c48a897185c72dc97524) | | Withdraw (claim cash) | `claim_redeem` | [`27a81778…db64`](https://stellar.expert/explorer/testnet/tx/27a8177877ecac5a4c87c1b4bed12e5f8664ca387f13fd9c05f8062ec68bdb64) | +## Vault figures + +The [live dApp](https://strata-vault-kit-app.vercel.app) shows Withdrawable and +Committed in the strip at the top of the vault page, and Economic supply under +"About this vault". Each one reads a view on `async_vault`. + +| Figure | What it is | View | +| --- | --- | --- | +| Withdrawable | The cash the vault holds now, less deposits that can still be cancelled. Priced redemptions are paid from it. | `liquid_reserve()` | +| Committed | The cash owed to priced redemptions that are not claimed yet. When it is more than Withdrawable, the gap shows as Uncovered. | `committed()` | +| Economic supply | The shares held outside the vault, plus the shares priced for deposits and not claimed yet. | `total_economic_supply()` | + +## Oracle tests + +These tests show the oracle's price controls and how it keeps its storage +alive (TTL). Run them with `cargo test -p nav-oracle`. + +| What the tests show | File | Tests | +| --- | --- | --- | +| Band: a price below the minimum or above the maximum answer is refused | [`contracts/nav-oracle/src/test/oracle.rs`](./contracts/nav-oracle/src/test/oracle.rs), [`contracts/nav-oracle/src/test/guards.rs`](./contracts/nav-oracle/src/test/guards.rs) | `attest_rejects_out_of_band`, `nav_in_band_refuses_a_value_outside_the_band` | +| Deviation cap: one report cannot move the price past the upward or downward cap | [`contracts/nav-oracle/src/test/oracle.rs`](./contracts/nav-oracle/src/test/oracle.rs), [`contracts/nav-oracle/src/test/guards.rs`](./contracts/nav-oracle/src/test/guards.rs) | `attest_enforces_cooldown_and_deviation`, `a_configured_downside_still_bounds_a_fall`, `a_full_downside_cap_still_bounds_a_rise`, `within_deviation_refuses_a_move_past_either_cap` | +| Cooldown: a report inside the cooldown after the last one is refused | [`contracts/nav-oracle/src/test/oracle.rs`](./contracts/nav-oracle/src/test/oracle.rs), [`contracts/nav-oracle/src/test/guards.rs`](./contracts/nav-oracle/src/test/guards.rs) | `attest_enforces_cooldown_and_deviation`, `two_reports_in_one_ledger_are_refused_at_the_shortest_cooldown`, `cooldown_elapsed_refuses_a_report_inside_the_cooldown` | +| Freshness: a report never outlives the freshness window, a report that is already expired is refused, and once a price expires the oracle reports it stale and the vault refuses to price an epoch with it | [`contracts/nav-oracle/src/test/oracle.rs`](./contracts/nav-oracle/src/test/oracle.rs), [`contracts/nav-oracle/src/test/guards.rs`](./contracts/nav-oracle/src/test/guards.rs), [`contracts/async-vault/src/test/oracle_pricing.rs`](./contracts/async-vault/src/test/oracle_pricing.rs) | `heartbeat_expiry_makes_it_stale`, `a_reporter_expiry_past_the_window_is_cut_to_the_window`, `expires_in_future_refuses_a_report_born_stale`, `fulfill_is_rejected_once_the_feed_goes_stale` | +| TTL: the latest valuation keeps a full TTL while it is read | [`contracts/nav-oracle/src/test/oracle.rs`](./contracts/nav-oracle/src/test/oracle.rs) | `the_latest_valuation_keeps_a_full_ttl_while_it_is_read` | +| TTL: setup, grant, hand-over and change calls extend the instance, and every role grant writes the role with its full TTL | [`contracts/nav-oracle/src/test/governance.rs`](./contracts/nav-oracle/src/test/governance.rs) | `setup_grants_hand_overs_and_changes_restore_the_instance_ttl`, `the_constructor_writes_each_role_with_the_full_ttl`, `a_grant_during_setup_writes_the_role_with_the_full_ttl`, `a_grant_through_the_timelock_writes_the_role_with_the_full_ttl` | + +The vault's test runs with `cargo test -p async-vault +fulfill_is_rejected_once_the_feed_goes_stale`. [Testing](./docs/TESTING.md) +lists the TTL tests for every contract. + ## Demo deployment The Tranche 1 demo video was recorded on testnet against the team's E2E @@ -97,12 +144,13 @@ the shell — and your editor's language server — automatically. ```sh rustup show # installs the toolchain pinned in rust-toolchain.toml rustup target add wasm32v1-none # Soroban's wasm target -cargo install --locked stellar-cli@27.0.0 # or: brew install stellar-cli +cargo install --locked stellar-cli@27.0.0 stellar contract build && cargo test ``` Formatting and secret-scanning run on commit via a `prek` hook — see -`.pre-commit-config.yaml` and run `prek install` once. +`.pre-commit-config.yaml` and run `prek install` once. CI also scans every +pull request's commits with the same gitleaks version. ### Integration & On-Chain Tests @@ -112,7 +160,8 @@ From a fresh clone to a vault set deployed on a local network. Run these inside ```sh # A local Stellar network, RPC at http://localhost:8000/rpc docker run --rm -d -p 8000:8000 --name stellar-local \ - stellar/quickstart --local --enable core,rpc + docker.io/stellar/quickstart:v687-b1580.1-latest@sha256:7711899e9942c60316ebacf8ab901be15bed7a5a31705d847b55375ff3bc8356 \ + --local --enable core,rpc --protocol-version 30 npm ci # JS dependencies, including the harness stellar contract build # contract wasms @@ -121,8 +170,11 @@ HARNESS_NETWORK=local npm run deploy -w scripts/harness ``` `HARNESS_NETWORK` defaults to `local`; `testnet` deploys to testnet and rewrites -the addresses table above. The local deploy funds its accounts from the -quickstart root key, so it needs no `stellar keys` identity. +the addresses table above and `[staging.contracts]` in `environments.toml`. +Commit both files after a testnet deploy: the app's build reads those IDs, so +until the change is committed it points at the previous contracts. The local +deploy funds its accounts from the quickstart root key, so it needs no +`stellar keys` identity. The first deploy writes a secret key for each harness account to `.env.keys.` at the repository root, and later deploys reuse them. Back @@ -139,16 +191,24 @@ Output of a real run from a fresh clone. Contract IDs differ on every run. deploying to local (http://localhost:8000/rpc) account keys in .env.keys.local (13 new) funded 13 accounts; 0 already existed -asset contract CDKWXRAHPQVFOAEBOOWWR62ZXAE2IV4V47PATFN7IX4XDW5QIUUL7D2B -compliance CDGKQCYSMTDRBO7SYV3V72H6AMCQ3UNQFKUU4AD5GSU2I32XDYFS6F3U -identity_verifier CCZE76VWEWJ47NSFMIXCKU3L55CZZTI5JJY4CSYAWPO3WXPDFXUCA5G6 -share_token CD6IA3BOPFX7VNADJ3GFK7IZGPVPNZA6HBCF425CNPSQ2GN7QZ3UUCP2 -nav_oracle CAHD5ZKMIDD67ZEJUB5ACR62FMAYMQJ2KCRV2MBDNXWT7WEZ2VV3MRDD -async_vault CDQNMAZP7D5IA7AXZGK3XWYPFD2LDR6V5HQD2OLXZJYNQN4BMNN6SXWJ +asset contract CCW7OKVOXBBBX3YK6O7F6TZPNIFG32OG4UY7R2K6NSWZUI4R75QCEPVT +returned the deposit asset of 0 accounts to its issuer +compliance CDPCD2BQQKVQBQFAFI4YBLN3TRX34J5NZUQQMUBGHPYNY64LCEM63DTL +identity_verifier CC4SRUIMKOBZSEO2GKGVQMDWPWCBJBBIMYYE7BBCUEZE6VMD3SFQDAMW +share_token CDC7SRTDZIMJSLC5AONUD3FIFYZGMFOHRAS7LLYV2ATJRT7RMTMQO2DG +nav_oracle CCUUBP3RGOIZZL4DUGRL4FBOSQJZKBPNQ7VUWIN6UOOYBJYYKGNT5E52 +async_vault CB2KVM2WHLXDZRHGCYPTGE7PZM6OORXCNVBUMC3L7GY5KX4BA6NMADXE +commit clients generated vault holds share_token manager role +share_token bound to the vault, setup finished vault is allowlisted share_token bound to compliance +compliance bound to the vault, setup finished +nav_oracle bound to the vault, setup finished +vault notice set to 10 seconds +vault setup finished +health {"addresses_distinct":true,"allowlisted":true,"compliance_bound":true,"custodian_set":true,"decimals_match":true,"feed_answers":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true,"vault_unfrozen":true} wrote deployed.local.json ``` @@ -159,8 +219,49 @@ integration suites against that deployment: ```sh npm run test:happy-path -w scripts/harness npm run test:guards -w scripts/harness +CARGO_TARGET_DIR=target/short-wind-down stellar contract build --package async-vault --features short_wind_down +npm run test:wind-down -w scripts/harness +``` + +Deploy afresh before each run of the suites. The deploy sets the vault's +notice to twice the oracle cooldown (10 seconds on the local network, 120 on +testnet), and the suites wait out the notice before they price an epoch. +`test:wind-down` ignores `deployed.local.json`: it deploys its own throwaway +vault set, built from the `short_wind_down` wasm above, so it can reach an +active wind-down without the real seven-day minimum. + +Each deploy first returns any deposit asset the harness accounts hold to its +issuer, so every holder starts a new set at zero. Deploy again and the suites +run again on the same network. + +### Keeping a deployment alive + +Every contract extends its own storage when it is called, but a deployment +nobody calls archives after 30 days. Keep the contracts callable with: + +```sh +STELLAR_ACCOUNT= npm run extend -w scripts/harness ``` +It reads the testnet contracts from `environments.toml` and covers three kinds +of entry: each contract instance, the Wasm it runs and each pending timelock +change. It restores any of them that has archived, then extends each by 30 +days. Any funded account pays, since extending needs no authority. Use +`--env ` for another environment, or `--config ` for a +deployment record. + +It extends nothing else. Other entries, such as epochs, requests, balances and +roles, still archive when nobody uses them. Nothing in them is lost: the next +call that needs an archived entry restores it, and the account that sends that +call pays the restore fee. For a holder's balance or request, that is usually +the holder, on their next call. The +[TTL policy](./docs/kit-spec.md#5-storage-lifetime-ttl-policy) says what each +contract extends on its own and how a restore works. + +With `--config`, it uses the network the record names (`local` or `testnet`) +unless `--env` names another environment, and it prints the network and RPC it +uses before it sends anything. Put flags after `--`, or npm swallows them: +`npm run extend -w scripts/harness -- --config `. ## Reference base diff --git a/app-lib/accounts.test.ts b/app-lib/accounts.test.ts index 8eb41d66..a9096f1c 100644 --- a/app-lib/accounts.test.ts +++ b/app-lib/accounts.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest" -import { accountSigners, uniformWeights } from "./accounts" +import { accountSigners, requiredWeight, uniformWeights } from "./accounts" const { callMock, accountIdMock } = vi.hoisted(() => ({ callMock: vi.fn(), @@ -101,3 +101,19 @@ describe("uniformWeights", () => { ).toBe(false) }) }) + +describe("requiredWeight", () => { + const withMed = (med: number) => ({ + signers: [{ key: "GKEY", weight: 1 }], + thresholds: { low: med, med, high: med }, + sequence: "1", + }) + + it("needs one signature from a single-key account whose thresholds are zero", () => { + expect(requiredWeight(withMed(0))).toBe(1) + }) + + it("needs the medium threshold of a multisig account", () => { + expect(requiredWeight(withMed(3))).toBe(3) + }) +}) diff --git a/app-lib/accounts.ts b/app-lib/accounts.ts index 42ce5620..837d44a3 100644 --- a/app-lib/accounts.ts +++ b/app-lib/accounts.ts @@ -6,6 +6,10 @@ export interface AccountSigners { sequence: string } +// The network needs a source signature even when every threshold is zero. +export const requiredWeight = (account: AccountSigners): number => + Math.max(account.thresholds.med, 1) + export const uniformWeights = (signers: AccountSigners["signers"]): boolean => signers.every(({ weight }) => weight <= 1) diff --git a/app-lib/contracts.ts b/app-lib/contracts.ts index 2665f2ee..e4a0f006 100644 --- a/app-lib/contracts.ts +++ b/app-lib/contracts.ts @@ -12,14 +12,23 @@ import { type EpochInfo, type EpochStatus, type RedeemRequest, + type VaultChange, + type VaultPendingChange, + type WindDownInfo, } from "./clients/async_vault/src/index" import { Client as IdentityVerifierApi } from "./clients/identity_verifier/src/index" import { Client as NavOracleApi, type NavReport, + type OracleChange, + type OraclePendingChange, type OracleState, } from "./clients/nav_oracle/src/index" -import { Client as ShareTokenApi } from "./clients/share_token/src/index" +import { + Client as ShareTokenApi, + type TokenChange, + type TokenPendingChange, +} from "./clients/share_token/src/index" export type { AssetApi, @@ -30,9 +39,16 @@ export type { IdentityVerifierApi, NavOracleApi, NavReport, + OracleChange, + OraclePendingChange, OracleState, RedeemRequest, ShareTokenApi, + TokenChange, + TokenPendingChange, + VaultChange, + VaultPendingChange, + WindDownInfo, } export interface Signer { diff --git a/app-lib/index.ts b/app-lib/index.ts index 62aedfaf..087ac104 100644 --- a/app-lib/index.ts +++ b/app-lib/index.ts @@ -8,6 +8,6 @@ export * from "./contracts" // AsyncVaultApi, connectAsyncVault export * from "./env" // rpcUrl, networkPassphrase, stellarNetwork, horizonUrl, network, labPrefix export * from "./format" // shortAddress, formatNetworkName, networkStatus, NetworkState export * from "./friendbot" // getFriendbotUrl -export * from "./multisig" // decodePending, signatureWeight, hasSigned, submitSigned, OPERATOR_TIMEOUT_SECONDS +export * from "./multisig" // decodePending, signatureWeight, hasSigned, submitSigned, authorizePending, prepareCoSigned, OPERATOR_TIMEOUT_SECONDS export * from "./readContract" // ContractRead, readContract -export * from "./wallet" // connectWallet, disconnectWallet, profileModal, signTransaction, onWalletChange, fetchBalances, wallet, MappedBalances, WalletState +export * from "./wallet" // connectWallet, disconnectWallet, profileModal, signTransaction, signAuthEntry, onWalletChange, fetchBalances, wallet, MappedBalances, WalletState diff --git a/app-lib/multisig.test.ts b/app-lib/multisig.test.ts index 6c6f4809..ba13adc6 100644 --- a/app-lib/multisig.test.ts +++ b/app-lib/multisig.test.ts @@ -1,42 +1,58 @@ +import { Buffer } from "buffer" import { Account, Address, BASE_FEE, + hash, Keypair, nativeToScVal, Operation, + SorobanDataBuilder, StrKey, TimeoutInfinite, type Transaction, TransactionBuilder, xdr, } from "@stellar/stellar-sdk" +import { + AssembledTransaction, + Spec, + type ClientOptions, +} from "@stellar/stellar-sdk/contract" import { Api } from "@stellar/stellar-sdk/rpc" import { beforeEach, describe, expect, it, vi } from "vitest" import { Client as GeneratedClient } from "../vitest.clients" import { connectAsyncVault } from "./contracts" import { networkPassphrase } from "./env" import { + AUTHORIZATION_LEDGERS, + authorizePending, decodePending, hasSigned, InvalidPendingTransaction, OPERATOR_TIMEOUT_SECONDS, + prepareCoSigned, signatureWeight, submitSigned, transactionStatus, } from "./multisig" -const { sendMock, pollMock, getMock } = vi.hoisted(() => ({ - sendMock: vi.fn(), - pollMock: vi.fn(), - getMock: vi.fn(), -})) +const { sendMock, pollMock, getMock, latestLedgerMock, simulateMock } = + vi.hoisted(() => ({ + sendMock: vi.fn(), + pollMock: vi.fn(), + getMock: vi.fn(), + latestLedgerMock: vi.fn(), + simulateMock: vi.fn(), + })) vi.mock("./rpc", () => ({ server: { sendTransaction: sendMock, pollTransaction: pollMock, getTransaction: getMock, + getLatestLedger: latestLedgerMock, + simulateTransaction: simulateMock, }, })) @@ -393,6 +409,7 @@ describe("submitSigned", () => { await expect(submitSigned(tx)).resolves.toEqual({ status: "success", hash: "abc123", + events: [], }) expect(sendMock).toHaveBeenCalledWith(tx) expect(pollMock).toHaveBeenCalledWith("abc123") @@ -414,6 +431,7 @@ describe("submitSigned", () => { status: "success", hash: "abc123", result: 5n, + events: [], }) }) @@ -502,6 +520,7 @@ describe("submitSigned", () => { await expect(submitSigned(tx)).resolves.toEqual({ status: "success", hash: "abc123", + events: [], }) expect(pollMock).toHaveBeenCalledWith("abc123") }) @@ -553,3 +572,320 @@ describe("operator timeout", () => { expect(timeoutOf(await connectAsyncVault(contractId))).toBeUndefined() }) }) + +const admin = Keypair.random() +const holder = Keypair.random().publicKey() + +const transferArgs = (shares = 25n) => [ + new Address(holder).toScVal(), + new Address(recipient).toScVal(), + nativeToScVal(shares, { type: "i128" }), + nativeToScVal("ORDER-1", { type: "string" }), + new Address(authority.publicKey()).toScVal(), +] + +const invocationArgs = (fn: string, args = transferArgs()) => + new xdr.InvokeContractArgs({ + contractAddress: new Address(contractId).toScAddress(), + functionName: fn, + args, + }) + +const authorized = ( + args: xdr.InvokeContractArgs, + subInvocations: xdr.SorobanAuthorizedInvocation[] = [], +) => + new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + args, + ), + subInvocations, + }) + +const addressEntry = ( + address: string, + invocation = authorized(invocationArgs("legal_transfer")), + signature = xdr.ScVal.scvVoid(), + expiration = 0, +) => + new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( + new xdr.SorobanAddressCredentials({ + address: new Address(address).toScAddress(), + nonce: xdr.Int64.fromString("7"), + signatureExpirationLedger: expiration, + signature, + }), + ), + rootInvocation: invocation, + }) + +const sourceEntry = () => + new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), + rootInvocation: authorized(invocationArgs("legal_transfer")), + }) + +const coSigned = ( + auth: xdr.SorobanAuthorizationEntry[], + fn = "legal_transfer", + args = transferArgs(), +) => + builder() + .addOperation( + Operation.invokeContractFunction({ + contract: contractId, + function: fn, + args, + auth, + }), + ) + .build() + .toEnvelope() + .toXDR("base64") + +const legalTransfer = () => + coSigned([sourceEntry(), addressEntry(admin.publicKey())]) + +describe("decodePending with a co-signer's authorization", () => { + it("reads one unsigned address authorization on a legal transfer", () => { + expect(decodePending(legalTransfer()).authorization).toEqual({ + address: admin.publicKey(), + signed: false, + expirationLedger: 0, + }) + }) + + it("reads a signed authorization and the ledger it expires after", () => { + const signature = xdr.ScVal.scvVec([xdr.ScVal.scvMap([])]) + const encoded = coSigned([ + sourceEntry(), + addressEntry(admin.publicKey(), undefined, signature, 900), + ]) + + expect(decodePending(encoded).authorization).toEqual({ + address: admin.publicKey(), + signed: true, + expirationLedger: 900, + }) + }) + + it("has no authorization when every entry is the source account's", () => { + expect(decodePending(coSigned([sourceEntry()])).authorization).toBeNull() + }) + + it("refuses two address authorizations", () => { + const encoded = coSigned([ + addressEntry(admin.publicKey()), + addressEntry(outsider.publicKey()), + ]) + + expect(() => decodePending(encoded)).toThrow(InvalidPendingTransaction) + }) + + it("refuses an address authorization on any function but a legal transfer", () => { + const args = [nativeToScVal(42n, { type: "i128" })] + const encoded = coSigned( + [ + addressEntry( + admin.publicKey(), + authorized(invocationArgs("forced_transfer", args)), + ), + ], + "forced_transfer", + args, + ) + + expect(() => decodePending(encoded)).toThrow(InvalidPendingTransaction) + }) + + it("refuses an authorization for an invocation the operation does not make", () => { + const encoded = coSigned([ + addressEntry( + admin.publicKey(), + authorized(invocationArgs("legal_transfer", transferArgs(99n))), + ), + ]) + + expect(() => decodePending(encoded)).toThrow(InvalidPendingTransaction) + }) + + it("refuses an authorization that also covers nested calls", () => { + const nested = authorized(invocationArgs("legal_transfer"), [ + authorized(invocationArgs("transfer", [])), + ]) + const encoded = coSigned([addressEntry(admin.publicKey(), nested)]) + + expect(() => decodePending(encoded)).toThrow(InvalidPendingTransaction) + }) + + it("refuses an authorization by a contract address", () => { + const encoded = coSigned([addressEntry(contractId)]) + + expect(() => decodePending(encoded)).toThrow(InvalidPendingTransaction) + }) +}) + +const legalTransferSpec = new Spec([ + xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "", + name: "legal_transfer", + inputs: [], + outputs: [], + }), + ), +]) + +const assembled = (encoded: string, options: Partial = {}) => + AssembledTransaction.fromXDR( + { + contractId, + networkPassphrase, + rpcUrl: "http://localhost:8000/rpc", + allowHttp: true, + server: { simulateTransaction: simulateMock }, + ...options, + } as unknown as ClientOptions, + encoded, + legalTransferSpec, + ) + +const simulated = (): Api.SimulateTransactionSuccessResponse => ({ + id: "1", + latestLedger: 1_000, + events: [], + _parsed: true, + transactionData: new SorobanDataBuilder().setResourceFee(5_000), + minResourceFee: "5000", + result: { auth: [], retval: xdr.ScVal.scvVoid() }, +}) + +const refused = (code: number): Api.SimulateTransactionErrorResponse => ({ + id: "1", + latestLedger: 1_000, + events: [], + _parsed: true, + error: `HostError: Error(Contract, #${code})`, +}) + +const adminWallet = vi.fn(async (preimage: string) => ({ + signedAuthEntry: admin + .sign(hash(Buffer.from(preimage, "base64"))) + .toString("base64"), + signerAddress: admin.publicKey(), +})) + +describe("authorizePending", () => { + beforeEach(() => { + vi.clearAllMocks() + latestLedgerMock.mockResolvedValue({ sequence: 1_000 }) + simulateMock.mockResolvedValue(simulated()) + }) + + it("signs the admin's entry for the operator timeout and re-simulates", async () => { + const encoded = legalTransfer() + const pending = decodePending(encoded) + + const step = await authorizePending( + assembled(encoded), + pending, + adminWallet, + ) + + if (step.kind !== "ready") throw new Error(`unexpected ${step.kind}`) + expect(decodePending(step.xdr)).toEqual(step.pending) + expect(step.pending.authorization).toEqual({ + address: admin.publicKey(), + signed: true, + expirationLedger: 1_000 + AUTHORIZATION_LEDGERS, + }) + expect(AUTHORIZATION_LEDGERS).toBe(OPERATOR_TIMEOUT_SECONDS / 5) + expect(adminWallet).toHaveBeenCalledWith(expect.any(String), { + address: admin.publicKey(), + }) + expect(simulateMock).toHaveBeenCalledTimes(1) + expect(step.pending.args).toEqual(pending.args) + expect(step.pending.expiresAt).toEqual(pending.expiresAt) + }) + + it("reports the contract's refusal from the re-simulation", async () => { + simulateMock.mockResolvedValue(refused(300)) + const encoded = legalTransfer() + + await expect( + authorizePending(assembled(encoded), decodePending(encoded), adminWallet), + ).resolves.toEqual({ kind: "refused", code: 300 }) + }) + + it("refuses a transaction whose authorization is already signed", async () => { + const signature = xdr.ScVal.scvVec([xdr.ScVal.scvMap([])]) + const encoded = coSigned([ + sourceEntry(), + addressEntry(admin.publicKey(), undefined, signature, 900), + ]) + + await expect( + authorizePending(assembled(encoded), decodePending(encoded), adminWallet), + ).rejects.toThrow(InvalidPendingTransaction) + expect(adminWallet).not.toHaveBeenCalled() + }) +}) + +describe("prepareCoSigned", () => { + beforeEach(() => { + vi.clearAllMocks() + latestLedgerMock.mockResolvedValue({ sequence: 1_000 }) + simulateMock.mockResolvedValue(simulated()) + }) + + const authorizedTransfer = async () => { + const encoded = legalTransfer() + const step = await authorizePending( + assembled(encoded), + decodePending(encoded), + adminWallet, + ) + if (step.kind !== "ready") throw new Error(`unexpected ${step.kind}`) + return step.pending + } + + it("re-simulates and starts the operator timeout now, keeping the authorization", async () => { + const pending = await authorizedTransfer() + const encoded = pending.transaction.toEnvelope().toXDR("base64") + const before = Date.now() + + const step = await prepareCoSigned(assembled(encoded), pending) + + if (step.kind !== "ready") throw new Error(`unexpected ${step.kind}`) + const expiresAt = step.pending.expiresAt?.getTime() ?? 0 + expect(expiresAt).toBeGreaterThanOrEqual( + Math.floor(before / 1000) * 1000 + OPERATOR_TIMEOUT_SECONDS * 1000, + ) + expect(step.pending.authorization).toEqual(pending.authorization) + expect(step.pending.transaction.fee).toBe(pending.transaction.fee) + expect(step.pending.sequence).toBe(pending.sequence) + expect(step.pending.transaction.signatures).toEqual([]) + }) + + it("reports the contract's refusal from the simulation", async () => { + const pending = await authorizedTransfer() + simulateMock.mockResolvedValue(refused(304)) + + await expect( + prepareCoSigned( + assembled(pending.transaction.toEnvelope().toXDR("base64")), + pending, + ), + ).resolves.toEqual({ kind: "refused", code: 304 }) + }) + + it("refuses a transaction the admin has not authorized", async () => { + const encoded = legalTransfer() + + await expect( + prepareCoSigned(assembled(encoded), decodePending(encoded)), + ).rejects.toThrow(InvalidPendingTransaction) + }) +}) diff --git a/app-lib/multisig.ts b/app-lib/multisig.ts index f2d1aadd..fa4c93eb 100644 --- a/app-lib/multisig.ts +++ b/app-lib/multisig.ts @@ -2,18 +2,31 @@ import { Address, FeeBumpTransaction, Keypair, + type Operation, scValToNative, + StrKey, type Transaction, TransactionBuilder, type xdr, } from "@stellar/stellar-sdk" +import { + type AssembledTransaction, + type ClientOptions, +} from "@stellar/stellar-sdk/contract" import { Api } from "@stellar/stellar-sdk/rpc" import type { AccountSigners } from "./accounts" import { networkPassphrase } from "./env" +import { parseErrorCode } from "./readContract" import { server } from "./rpc" export const OPERATOR_TIMEOUT_SECONDS = 7 * 24 * 60 * 60 +const LEDGER_SECONDS = 5 + +export const AUTHORIZATION_LEDGERS = OPERATOR_TIMEOUT_SECONDS / LEDGER_SECONDS + +const CO_SIGNED_FUNCTION = "legal_transfer" + export class InvalidPendingTransaction extends Error { constructor(reason: string) { super(reason) @@ -21,6 +34,12 @@ export class InvalidPendingTransaction extends Error { } } +export interface PendingAuthorization { + address: string + signed: boolean + expirationLedger: number +} + export interface PendingTransaction { transaction: Transaction source: string @@ -30,8 +49,15 @@ export interface PendingTransaction { fn: string args: unknown[] hash: string + authorization: PendingAuthorization | null } +export type SignAuthEntry = NonNullable + +export type CoSignStep = + | { kind: "ready"; xdr: string; pending: PendingTransaction } + | { kind: "refused"; code: number } + export type SubmitFailure = | "sequence-consumed" | "expired" @@ -42,7 +68,12 @@ export type SubmitFailure = | "unknown" export type SubmitResult = - | { status: "success"; hash: string; result: unknown } + | { + status: "success" + hash: string + result: unknown + events: xdr.ContractEvent[][] + } | { status: "failed"; reason: SubmitFailure; hash: string } const parseEnvelope = (encoded: string) => { @@ -53,7 +84,10 @@ const parseEnvelope = (encoded: string) => { } } -const invocationOf = (transaction: Transaction) => { +const unexpectedEntry = () => + new InvalidPendingTransaction("unexpected authorization entry") + +const hostFunctionOf = (transaction: Transaction) => { const [operation, ...rest] = transaction.operations if (operation === undefined || rest.length > 0) { throw new InvalidPendingTransaction("expected exactly one operation") @@ -67,17 +101,51 @@ const invocationOf = (transaction: Transaction) => { ) { throw new InvalidPendingTransaction("operation source differs") } - for (const entry of operation.auth ?? []) { - if ( - entry.credentials().switch().name !== "sorobanCredentialsSourceAccount" - ) { - throw new InvalidPendingTransaction("unexpected authorization entry") - } - } if (operation.func.switch().name !== "hostFunctionTypeInvokeContract") { throw new InvalidPendingTransaction("not a contract invocation") } - return operation.func.invokeContract() + return operation +} + +const addressEntriesOf = (operation: Operation.InvokeHostFunction) => + (operation.auth ?? []).filter((entry) => { + const kind = entry.credentials().switch().name + if (kind === "sorobanCredentialsSourceAccount") return false + if (kind === "sorobanCredentialsAddress") return true + throw unexpectedEntry() + }) + +const authorizes = ( + entry: xdr.SorobanAuthorizationEntry, + invocation: xdr.InvokeContractArgs, +): boolean => { + const root = entry.rootInvocation() + const fn = root.function() + return ( + root.subInvocations().length === 0 && + fn.switch().name === "sorobanAuthorizedFunctionTypeContractFn" && + fn.contractFn().toXDR("base64") === invocation.toXDR("base64") + ) +} + +const authorizationOf = ( + operation: Operation.InvokeHostFunction, + invocation: xdr.InvokeContractArgs, +): PendingAuthorization | null => { + const [entry, ...others] = addressEntriesOf(operation) + if (entry === undefined) return null + if (others.length > 0) throw unexpectedEntry() + if (invocation.functionName().toString() !== CO_SIGNED_FUNCTION) + throw unexpectedEntry() + if (!authorizes(entry, invocation)) throw unexpectedEntry() + const credentials = entry.credentials().address() + const address = Address.fromScAddress(credentials.address()).toString() + if (!StrKey.isValidEd25519PublicKey(address)) throw unexpectedEntry() + return { + address, + signed: credentials.signature().switch().name !== "scvVoid", + expirationLedger: credentials.signatureExpirationLedger(), + } } const expiryOf = (transaction: Transaction): Date | null => { @@ -99,7 +167,8 @@ export function decodePending(encoded: string): PendingTransaction { throw new InvalidPendingTransaction("fee bump envelope") } assertStarted(transaction) - const invocation = invocationOf(transaction) + const operation = hostFunctionOf(transaction) + const invocation = operation.func.invokeContract() return { transaction, source: transaction.source, @@ -109,7 +178,120 @@ export function decodePending(encoded: string): PendingTransaction { fn: invocation.functionName().toString(), args: invocation.args().map(scValToNative), hash: transaction.hash().toString("hex"), + authorization: authorizationOf(operation, invocation), + } +} + +const encodeEnvelope = (transaction: Transaction) => + transaction.toEnvelope().toXDR("base64") + +const credentialOf = (entry: xdr.SorobanAuthorizationEntry): string => { + const credentials = entry.credentials() + if (credentials.switch().name !== "sorobanCredentialsAddress") + return credentials.toXDR("base64") + const address = credentials.address() + return `${Address.fromScAddress(address.address()).toString()}:${address.nonce().toString()}` +} + +const intentOf = (transaction: Transaction): string => { + const operation = hostFunctionOf(transaction) + const entries = (operation.auth ?? []).map( + (entry) => + `${credentialOf(entry)}/${entry.rootInvocation().toXDR("base64")}`, + ) + return [ + transaction.source, + transaction.sequence, + operation.func.toXDR("base64"), + ...entries, + ].join("|") +} + +const authEntriesOf = (transaction: Transaction): string => + (hostFunctionOf(transaction).auth ?? []) + .map((entry) => entry.toXDR("base64")) + .join("|") + +const sameTimeBounds = (a: Transaction, b: Transaction): boolean => + a.timeBounds?.minTime === b.timeBounds?.minTime && + a.timeBounds?.maxTime === b.timeBounds?.maxTime + +const simulated = async ( + assembled: AssembledTransaction, +): Promise => { + await assembled.simulate() + const code = parseErrorCode(assembled.simulation) + if (code !== null) return code + if ( + assembled.simulation === undefined || + !Api.isSimulationSuccess(assembled.simulation) || + assembled.built === undefined + ) { + throw new Error("The transaction could not be simulated") } + return assembled.built +} + +export async function latestLedger(): Promise { + return (await server.getLatestLedger()).sequence +} + +/** Re-simulates after signing so the fee covers the signature check. */ +export async function authorizePending( + assembled: AssembledTransaction, + pending: PendingTransaction, + signAuthEntry: SignAuthEntry, +): Promise { + const { authorization } = pending + if (authorization === null || authorization.signed) { + throw new InvalidPendingTransaction("nothing to authorize") + } + const expiration = (await latestLedger()) + AUTHORIZATION_LEDGERS + await assembled.signAuthEntries({ + address: authorization.address, + signAuthEntry, + expiration, + }) + const built = await simulated(assembled) + if (typeof built === "number") return { kind: "refused", code: built } + const xdr = encodeEnvelope(built) + const next = decodePending(xdr) + if ( + next.authorization?.signed !== true || + next.authorization.address !== authorization.address || + intentOf(next.transaction) !== intentOf(pending.transaction) || + !sameTimeBounds(next.transaction, pending.transaction) + ) { + throw new InvalidPendingTransaction("authorization changed the transaction") + } + return { kind: "ready", xdr, pending: next } +} + +/** Restarts the timeout here: nobody could sign the envelope before the admin did. */ +export async function prepareCoSigned( + assembled: AssembledTransaction, + pending: PendingTransaction, +): Promise { + if (pending.authorization?.signed !== true) { + throw new InvalidPendingTransaction("authorization missing") + } + const built = await simulated(assembled) + if (typeof built === "number") return { kind: "refused", code: built } + const retimed = TransactionBuilder.cloneFrom(built, { + timebounds: undefined, + sorobanData: assembled.simulationData.transactionData, + }) + .setTimeout(OPERATOR_TIMEOUT_SECONDS) + .build() + const xdr = encodeEnvelope(retimed) + const next = decodePending(xdr) + if (authEntriesOf(next.transaction) !== authEntriesOf(pending.transaction)) { + throw new InvalidPendingTransaction("simulation changed the authorization") + } + if (intentOf(next.transaction) !== intentOf(pending.transaction)) { + throw new InvalidPendingTransaction("simulation changed the transaction") + } + return { kind: "ready", xdr, pending: next } } const verifiedSigners = (transaction: Transaction, keys: string[]) => { @@ -183,7 +365,12 @@ export async function submitSigned( polled.returnValue === undefined ? undefined : scValToNative(polled.returnValue) - return { status: "success", hash, result } + return { + status: "success", + hash, + result, + events: polled.events.contractEventsXdr, + } } if (polled.status === Api.GetTransactionStatus.FAILED) { return { status: "failed", reason: failureOf(polled.resultXdr), hash } diff --git a/app-lib/package.json b/app-lib/package.json index 16e6629a..7bfd33cc 100644 --- a/app-lib/package.json +++ b/app-lib/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@creit.tech/stellar-wallets-kit": "^2.5.0", - "@stellar/stellar-sdk": "^16.1.0", + "@stellar/stellar-sdk": "^16.4.1", "zod": "^4.3.5" } } diff --git a/app-lib/readContract.test.ts b/app-lib/readContract.test.ts index db8b9529..e6a06ae2 100644 --- a/app-lib/readContract.test.ts +++ b/app-lib/readContract.test.ts @@ -3,6 +3,7 @@ import type { Api } from "@stellar/stellar-sdk/rpc" import { beforeEach, describe, expect, it, vi } from "vitest" import type { DepositRequest, EpochInfo, RedeemRequest } from "./contracts" import { + decodeTransactionEvents, readContract, readEpochRequests, readEpochs, @@ -481,6 +482,28 @@ describe("readEvents", () => { ]) }) + it("matches every topic an event names after its symbol", async () => { + getHealthMock.mockResolvedValue({ + latestLedger: 500_000, + oldestLedger: 1, + }) + getEventsMock.mockResolvedValue({ events: [], cursor: "c" }) + + await readEvents( + [{ contractId: CONTRACT_ID, names: ["legal_transfer"], topics: 2 }], + SEVEN_DAYS_OF_LEDGERS, + ) + + const [request] = getEventsMock.mock.calls[0] as [{ filters: unknown[] }] + expect(request.filters).toEqual([ + { + type: "contract", + contractIds: [CONTRACT_ID], + topics: [[symbolTopic("legal_transfer").toXDR("base64"), "*", "*"]], + }, + ]) + }) + it("never starts before the oldest ledger the RPC still holds", async () => { getHealthMock.mockResolvedValue({ latestLedger: 500_000, @@ -966,3 +989,63 @@ describe("readContractAdmin", () => { }) }) }) + +const HOLDER = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7" + +const contractEvent = (topics: xdr.ScVal[], data: xdr.ScVal) => + new xdr.ContractEvent({ + ext: new xdr.ExtensionPoint(0), + contractId: null, + type: xdr.ContractEventType.contract(), + body: new xdr.ContractEventBody( + 0, + new xdr.ContractEventV0({ topics, data }), + ), + }) + +describe("decodeTransactionEvents", () => { + it("decodes the name, topics and data of every event across operations", () => { + const converted = contractEvent( + [ + xdr.ScVal.scvSymbol("deposit_converted_to_exit"), + nativeToScVal(Address.fromString(HOLDER)), + ], + nativeToScVal( + { epoch: 3n, into_epoch: 5n, shares: 7n }, + { + type: { + epoch: ["symbol", "u64"], + into_epoch: ["symbol", "u64"], + shares: ["symbol", "i128"], + }, + }, + ), + ) + const transfer = contractEvent( + [xdr.ScVal.scvSymbol("transfer")], + nativeToScVal(1n, { type: "i128" }), + ) + + expect(decodeTransactionEvents([[converted], [transfer]])).toEqual([ + { + name: "deposit_converted_to_exit", + topics: [HOLDER], + data: { epoch: 3n, into_epoch: 5n, shares: 7n }, + }, + { name: "transfer", topics: [], data: 1n }, + ]) + }) + + it("returns no events when the response carries none", () => { + expect(decodeTransactionEvents(undefined)).toEqual([]) + }) + + it("skips an event whose first topic is not a name", () => { + const unnamed = contractEvent( + [nativeToScVal(1n, { type: "u64" })], + xdr.ScVal.scvVoid(), + ) + + expect(decodeTransactionEvents([[unnamed]])).toEqual([]) + }) +}) diff --git a/app-lib/readContract.ts b/app-lib/readContract.ts index 68d30947..88a9d188 100644 --- a/app-lib/readContract.ts +++ b/app-lib/readContract.ts @@ -410,7 +410,11 @@ export async function readContractAdmin( } } -export type EventSelection = { contractId: string; names: string[] } +export type EventSelection = { + contractId: string + names: string[] + topics?: number +} export type ChainEvent = { contractId: string @@ -421,13 +425,18 @@ export type ChainEvent = { closedAt: bigint } +// "*" matches exactly one topic, so the filter names every topic after the symbol. const toEventFilter = ({ contractId, names, + topics = 1, }: EventSelection): Api.EventFilter => ({ type: "contract", contractIds: [contractId], - topics: names.map((name) => [xdr.ScVal.scvSymbol(name).toXDR("base64"), "*"]), + topics: names.map((name) => [ + xdr.ScVal.scvSymbol(name).toXDR("base64"), + ...Array(topics).fill("*"), + ]), }) const decodeEvent = (event: Api.EventResponse): ChainEvent | null => { @@ -447,6 +456,36 @@ const decodeEvent = (event: Api.EventResponse): ChainEvent | null => { } } +export type TransactionEvent = { + name: string + topics: unknown[] + data: unknown +} + +const decodeContractEvent = ( + event: xdr.ContractEvent, +): TransactionEvent | null => { + try { + const body = event.body().v0() + const [name, ...topics] = body.topics().map((topic) => scValToNative(topic)) + if (typeof name !== "string") return null + return { name, topics, data: scValToNative(body.data()) } + } catch { + return null + } +} + +export function decodeTransactionEvents( + events: xdr.ContractEvent[][] | undefined, +): TransactionEvent[] { + const decoded: TransactionEvent[] = [] + for (const event of events?.flat() ?? []) { + const next = decodeContractEvent(event) + if (next !== null) decoded.push(next) + } + return decoded +} + export type EventsRead = | { kind: "value"; value: ChainEvent[]; partial: boolean } | { kind: "unreadable" } diff --git a/app-lib/wallet.ts b/app-lib/wallet.ts index a7205cae..df9b0f30 100644 --- a/app-lib/wallet.ts +++ b/app-lib/wallet.ts @@ -33,6 +33,8 @@ export const disconnectWallet = () => StellarWalletsKit.disconnect() /** Sign a transaction with the active wallet. Passed to generated contract clients. */ export const signTransaction = StellarWalletsKit.signTransaction +export const signAuthEntry = StellarWalletsKit.signAuthEntry + /** * Use the wallet's reported network if it supports it, otherwise flag the * wallet as unsupported. Note that even if a wallet is `unsupported`, this diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index a91b881f..7ff922e6 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -27,6 +27,7 @@ const disconnectedWallet: WalletContextType = { isPending: false, updateBalances: async () => {}, signTransaction: vi.fn() as WalletContextType["signTransaction"], + signAuthEntry: vi.fn() as WalletContextType["signAuthEntry"], } const disconnectedAuthority: AdminAuthority = { diff --git a/app/src/components/admin/ComplianceSection.test.tsx b/app/src/components/admin/ComplianceSection.test.tsx index b4a9f7f6..c1ef1da2 100644 --- a/app/src/components/admin/ComplianceSection.test.tsx +++ b/app/src/components/admin/ComplianceSection.test.tsx @@ -1,32 +1,45 @@ -import { fireEvent, render, screen, within } from "@testing-library/react" -import { beforeEach, describe, expect, it, type Mock, vi } from "vitest" +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest" import { type Grant } from "../../hooks/useAdminAuthority" import { type TransactionStatus } from "../../hooks/useContractTransaction" import ComplianceSection from "./ComplianceSection" -const { txs, useComplianceStateMock } = vi.hoisted(() => { - const tx = (): { - status: TransactionStatus - submit: Mock<() => boolean> - reset: Mock - } => ({ - status: { status: "idle" }, - submit: vi.fn(() => true), - reset: vi.fn(), - }) - return { - txs: { - useAllowAddress: tx(), - useDisallowAddress: tx(), - useFreezeAddress: tx(), - useUnfreezeAddress: tx(), - useFreezeShares: tx(), - useUnfreezeShares: tx(), - useForceTransfer: tx(), - }, - useComplianceStateMock: vi.fn(), - } -}) +const { txs, useComplianceStateMock, useLegalTransfersMock } = vi.hoisted( + () => { + const tx = (): { + status: TransactionStatus + submit: Mock<() => boolean> + prepare: Mock + reset: Mock + } => ({ + status: { status: "idle" }, + submit: vi.fn(() => true), + prepare: vi.fn(), + reset: vi.fn(), + }) + return { + txs: { + useAllowAddress: tx(), + useDisallowAddress: tx(), + useFreezeAddress: tx(), + useUnfreezeAddress: tx(), + useFreezeShares: tx(), + useUnfreezeShares: tx(), + useLegalTransfer: tx(), + }, + useComplianceStateMock: vi.fn(), + useLegalTransfersMock: vi.fn(), + } + }, +) vi.mock("../../hooks/useComplianceTransactions", () => Object.fromEntries( @@ -36,6 +49,9 @@ vi.mock("../../hooks/useComplianceTransactions", () => vi.mock("../../hooks/useComplianceState", () => ({ useComplianceState: useComplianceStateMock, })) +vi.mock("../../hooks/useCycleState", () => ({ + useLegalTransfers: useLegalTransfersMock, +})) vi.mock("../../config/contracts", () => ({ vaultContractId: "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3", })) @@ -95,11 +111,18 @@ const readsFor = ({ target }: { target: string | undefined }) => ({ }) describe("ComplianceSection", () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { vi.clearAllMocks() for (const transaction of Object.values(txs)) transaction.status = { status: "idle" } useComplianceStateMock.mockImplementation(readsFor) + useLegalTransfersMock.mockReturnValue({ + legalTransfers: { status: "loaded", events: [], partial: false }, + }) }) it("blocks the actions until a target address is entered", () => { @@ -219,6 +242,56 @@ describe("ComplianceSection", () => { ).toBe(false) }) + it("prepares allowing the target once it settles and still submits it on click", () => { + vi.useFakeTimers() + render( + , + ) + enterTarget(TARGET) + openRow("Allow the address") + + act(() => { + vi.advanceTimersByTime(400) + }) + + const allowArg = { verifier: "CVERIFIERADDRESS", account: TARGET } + expect(txs.useAllowAddress.prepare).toHaveBeenCalledWith( + allowArg, + verifierAdmin, + ) + + fireEvent.click(button("Allow the address")) + + expect(txs.useAllowAddress.submit).toHaveBeenCalledWith( + allowArg, + verifierAdmin, + ) + }) + + it("prepares nothing while the compliance tab is hidden", () => { + vi.useFakeTimers() + render( + , + ) + enterTarget(TARGET) + openRow("Allow the address") + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(txs.useAllowAddress.prepare).not.toHaveBeenCalled() + }) + it("allows the target and follows it in the modal", () => { const { rerender } = render( { ) expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0) }) + + it("lists recent legal transfers with their reference", () => { + useLegalTransfersMock.mockReturnValue({ + legalTransfers: { + status: "loaded", + partial: false, + events: [ + { + contractId: "CTOKEN", + name: "legal_transfer", + topics: [TARGET, WALLET], + data: { amount: 5n * UNIT, reference: "COURT-ORDER-17" }, + ledger: 10, + closedAt: 1_700_000_000n, + }, + ], + }, + }) + render( + , + ) + + const table = screen.getByRole("table", { name: "Legal transfers" }) + expect(within(table).getByText("Legal transfer")).toBeTruthy() + expect(within(table).getByText(/COURT-ORDER-17/)).toBeTruthy() + expect(within(table).getByText("5.00")).toBeTruthy() + }) + + it("says when no legal transfer happened in the last 7 days", () => { + render( + , + ) + + expect( + screen.getByText("No legal transfers in the last 7 days."), + ).toBeTruthy() + }) }) diff --git a/app/src/components/admin/ComplianceSection.tsx b/app/src/components/admin/ComplianceSection.tsx index cee12714..16033a7b 100644 --- a/app/src/components/admin/ComplianceSection.tsx +++ b/app/src/components/admin/ComplianceSection.tsx @@ -5,13 +5,18 @@ import { useComplianceState } from "../../hooks/useComplianceState" import { useAllowAddress, useDisallowAddress, - useForceTransfer, useFreezeAddress, + useLegalTransfer, useFreezeShares, useUnfreezeAddress, useUnfreezeShares, } from "../../hooks/useComplianceTransactions" +import { useLegalTransfers } from "../../hooks/useCycleState" import { + prepareFor, + type Send, + type Sendable, + submitTo, usePendingTransaction, type UsePendingTransaction, } from "../../hooks/usePendingTransaction" @@ -24,6 +29,7 @@ import { toComplianceActions, toComplianceTiles, } from "../../pages/compliance" +import { toActivityList } from "../../pages/cycle" import typeStyles from "../../styles/type.module.css" import MetricsStrip from "../vault/MetricsStrip" import TransactionModal, { @@ -32,6 +38,7 @@ import TransactionModal, { } from "../vault/TransactionModal" import styles from "./ComplianceSection.module.css" import CycleActionRail from "./CycleActionRail" +import { ActivityTable, Section } from "./CycleSurface" import SurfaceLayout from "./SurfaceLayout" import layoutStyles from "./SurfaceLayout.module.css" @@ -59,30 +66,31 @@ function useComplianceFlows() { "unfreeze-address": usePendingTransaction(useUnfreezeAddress()), "freeze-shares": usePendingTransaction(useFreezeShares()), "unfreeze-shares": usePendingTransaction(useUnfreezeShares()), - "force-transfer": usePendingTransaction(useForceTransfer()), + "legal-transfer": usePendingTransaction(useLegalTransfer()), } satisfies Record } type Flows = ReturnType -const submitArg = ( - { submit }: { submit: (arg: Arg, label: string, grant?: Grant) => boolean }, +const sendArg = ( + send: Send, + flow: Sendable, { arg, next, grant }: { arg: Arg | undefined; next: string; grant?: Grant }, -) => arg !== undefined && submit(arg, next, grant) +) => (arg === undefined ? undefined : send(flow, arg, next, grant)) -function submit(flows: Flows, action: ComplianceAction) { +function dispatch(flows: Flows, action: ComplianceAction, send: Send) { switch (action.id) { case "allow-address": case "disallow-address": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "freeze-address": case "unfreeze-address": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "freeze-shares": case "unfreeze-shares": - return submitArg(flows[action.id], action) - case "force-transfer": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) + case "legal-transfer": + return sendArg(send, flows[action.id], action) } } @@ -114,6 +122,8 @@ const ComplianceSection: React.FC<{ destination: destinationOf(inputs), }) const flows = useComplianceFlows() + const { legalTransfers } = useLegalTransfers() + const transfers = toActivityList(legalTransfers) const modalFlows = COMPLIANCE_ACTION_IDS.map((id) => flow(id, flows[id])) const recorded = modalFlows.some( @@ -150,7 +160,12 @@ const ComplianceSection: React.FC<{ actions={actions} pending={false} unreadable={false} - onRun={(action) => submit(flows, action)} + onRun={(action) => dispatch(flows, action, submitTo)} + onReady={ + active + ? (action) => dispatch(flows, action, prepareFor) + : undefined + } onInput={onInput} /> } @@ -196,6 +211,16 @@ const ComplianceSection: React.FC<{ +
+ {transfers.status === "loaded" && ( + + )} +
{contracts} {active && activeModal !== undefined && ( diff --git a/app/src/components/admin/ConfigurationSection.test.tsx b/app/src/components/admin/ConfigurationSection.test.tsx index b001b6fe..b9c98f4c 100644 --- a/app/src/components/admin/ConfigurationSection.test.tsx +++ b/app/src/components/admin/ConfigurationSection.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, within } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { type Grant } from "../../hooks/useAdminAuthority" import { type ConfigurationState } from "../../hooks/useConfigurationState" import { type TransactionStatus } from "../../hooks/useContractTransaction" @@ -11,13 +11,22 @@ const { custodianMock, noticeMock, windDownDelayMock, + proposeCustodianMock, + proposeNoticeMock, + proposeWindDownDelayMock, + minEpochDurationMock, + proposeMinEpochDurationMock, oracleConfigMock, complianceMock, identityVerifierMock, + proposeComplianceMock, + proposeIdentityVerifierMock, + proposeOracleConfigMock, } = vi.hoisted(() => { const tx = () => ({ status: { status: "idle" } as { status: string }, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }) return { @@ -26,9 +35,17 @@ const { custodianMock: tx(), noticeMock: tx(), windDownDelayMock: tx(), + proposeCustodianMock: tx(), + proposeNoticeMock: tx(), + proposeWindDownDelayMock: tx(), + minEpochDurationMock: tx(), + proposeMinEpochDurationMock: tx(), oracleConfigMock: tx(), complianceMock: tx(), identityVerifierMock: tx(), + proposeComplianceMock: tx(), + proposeIdentityVerifierMock: tx(), + proposeOracleConfigMock: tx(), } }) @@ -40,9 +57,17 @@ vi.mock("../../hooks/useConfigurationTransactions", () => ({ useSetCustodian: () => custodianMock, useSetNotice: () => noticeMock, useSetWindDownDelay: () => windDownDelayMock, + useProposeCustodian: () => proposeCustodianMock, + useProposeNotice: () => proposeNoticeMock, + useProposeWindDownDelay: () => proposeWindDownDelayMock, + useSetMinEpochDuration: () => minEpochDurationMock, + useProposeMinEpochDuration: () => proposeMinEpochDurationMock, useSetOracleConfig: () => oracleConfigMock, useSetCompliance: () => complianceMock, useSetIdentityVerifier: () => identityVerifierMock, + useProposeCompliance: () => proposeComplianceMock, + useProposeIdentityVerifier: () => proposeIdentityVerifierMock, + useProposeOracleConfig: () => proposeOracleConfigMock, })) const WALLET = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" @@ -60,6 +85,7 @@ const grantOf = (role: string): Grant => ({ const governance = grantOf("vault governance") const oracleAdmin = grantOf("oracle admin") +const tokenAdmin = grantOf("share-token admin") const READY: ConfigurationState = { status: "ready", @@ -67,12 +93,16 @@ const READY: ConfigurationState = { custodian: { kind: "set", address: CUSTODIAN }, notice: DAY, windDownDelay: 7n * DAY, + minEpochDuration: { kind: "unset" }, upgradeDelay: 3n * DAY, + setupOpen: false, + tokenSetupOpen: true, + oracleSetupOpen: true, oracle: { freshness: DAY, cooldown: 3_600n, maxUpBps: 1_000, - maxDownBps: null, + maxDownBps: 500, min: PRICE_ONE / 2n, max: 2n * PRICE_ONE, }, @@ -93,6 +123,10 @@ const submit = (name: string) => .find((candidate) => !candidate.hasAttribute("aria-expanded"))! describe("ConfigurationSection", () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { vi.clearAllMocks() for (const mock of [ @@ -100,9 +134,17 @@ describe("ConfigurationSection", () => { custodianMock, noticeMock, windDownDelayMock, + proposeCustodianMock, + proposeNoticeMock, + proposeWindDownDelayMock, + minEpochDurationMock, + proposeMinEpochDurationMock, oracleConfigMock, complianceMock, identityVerifierMock, + proposeComplianceMock, + proposeIdentityVerifierMock, + proposeOracleConfigMock, ]) Object.assign(mock, { status: { status: "idle" } as TransactionStatus, @@ -125,6 +167,69 @@ describe("ConfigurationSection", () => { expect(within(token).getByText("Identity verifier")).toBeTruthy() }) + it("prepares a typed notice once it settles and still submits it on click", () => { + vi.useFakeTimers() + render() + expand("Set the notice") + + fireEvent.change(screen.getByRole("textbox", { name: "Notice" }), { + target: { value: "2d" }, + }) + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(noticeMock.prepare).toHaveBeenCalledWith(2n * DAY, governance) + + fireEvent.click(submit("Set the notice")) + + expect(noticeMock.submit).toHaveBeenCalledWith(2n * DAY, governance) + }) + + it("prepares the proposal once a lower notice switches the row to its timelocked form", () => { + vi.useFakeTimers() + render() + expand("Set the notice") + const field = screen.getByRole("textbox", { name: "Notice" }) + + fireEvent.change(field, { target: { value: "2d" } }) + act(() => { + vi.advanceTimersByTime(400) + }) + fireEvent.change(field, { target: { value: "12h" } }) + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(noticeMock.prepare).toHaveBeenCalledWith(2n * DAY, governance) + expect(proposeNoticeMock.prepare).toHaveBeenCalledWith(43_200n, governance) + + fireEvent.click(submit("Set the notice")) + + expect(proposeNoticeMock.submit).toHaveBeenCalledWith(43_200n, governance) + }) + + it("prepares nothing while the configuration tab is hidden", () => { + vi.useFakeTimers() + render( + , + ) + expand("Set the notice") + fireEvent.change(screen.getByRole("textbox", { name: "Notice" }), { + target: { value: "2d" }, + }) + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(noticeMock.prepare).not.toHaveBeenCalled() + }) + it("submits a new notice once typed, and follows it in the modal", () => { const { rerender } = render( , @@ -143,6 +248,113 @@ describe("ConfigurationSection", () => { expect(screen.getByText("New notice: 2d.")).toBeTruthy() }) + it("proposes a lower notice after setup and follows it in the modal", () => { + const { rerender } = render( + , + ) + expand("Set the notice") + fireEvent.change(screen.getByRole("textbox", { name: "Notice" }), { + target: { value: "12h" }, + }) + fireEvent.click(submit("Set the notice")) + expect(proposeNoticeMock.submit).toHaveBeenCalledWith(43_200n, governance) + expect(noticeMock.submit).not.toHaveBeenCalled() + + proposeNoticeMock.status = { status: "awaiting-signature" } + rerender() + expect(screen.getByText("Signing queues notice 12h.")).toBeTruthy() + }) + + it("proposes the custodian after setup and sets it at once while setup is open", () => { + const { rerender } = render( + , + ) + expand("Set the custodian") + fireEvent.change(screen.getByRole("textbox", { name: "Custodian" }), { + target: { value: VERIFIER }, + }) + fireEvent.click(submit("Set the custodian")) + expect(proposeCustodianMock.submit).toHaveBeenCalledWith( + VERIFIER, + governance, + ) + + useConfigurationStateMock.mockReturnValue({ + configuration: { ...READY, setupOpen: true }, + }) + rerender() + fireEvent.click(submit("Set the custodian")) + expect(custodianMock.submit).toHaveBeenCalledWith(VERIFIER, governance) + }) + + it("sets the minimum epoch duration while setup is open and proposes it after", () => { + useConfigurationStateMock.mockReturnValue({ + configuration: { ...READY, setupOpen: true }, + }) + const { rerender } = render( + , + ) + expand("Set the minimum epoch duration") + fireEvent.change( + screen.getByRole("textbox", { name: "Minimum epoch duration" }), + { target: { value: "7d" } }, + ) + fireEvent.click(submit("Set the minimum epoch duration")) + expect(minEpochDurationMock.submit).toHaveBeenCalledWith( + 7n * DAY, + governance, + ) + + useConfigurationStateMock.mockReturnValue({ configuration: READY }) + rerender() + fireEvent.click(submit("Set the minimum epoch duration")) + expect(proposeMinEpochDurationMock.submit).toHaveBeenCalledWith( + 7n * DAY, + governance, + ) + + proposeMinEpochDurationMock.status = { status: "awaiting-signature" } + rerender() + expect( + screen.getByText("Signing queues minimum epoch duration 7d."), + ).toBeTruthy() + }) + + it("proposes the token's contracts once its setup closes and sets them at once while it is open", () => { + useConfigurationStateMock.mockReturnValue({ + configuration: { ...READY, tokenSetupOpen: false }, + }) + const { rerender } = render( + , + ) + expand("Set the compliance contract") + fireEvent.change( + screen.getByRole("textbox", { name: "Compliance contract" }), + { target: { value: CUSTODIAN } }, + ) + fireEvent.click(submit("Set the compliance contract")) + expand("Set the identity verifier") + fireEvent.change( + screen.getByRole("textbox", { name: "Identity verifier" }), + { target: { value: CUSTODIAN } }, + ) + fireEvent.click(submit("Set the identity verifier")) + expect(proposeComplianceMock.submit).toHaveBeenCalledWith( + CUSTODIAN, + tokenAdmin, + ) + expect(proposeIdentityVerifierMock.submit).toHaveBeenCalledWith( + CUSTODIAN, + tokenAdmin, + ) + + useConfigurationStateMock.mockReturnValue({ configuration: READY }) + rerender() + expand("Set the compliance contract") + fireEvent.click(submit("Set the compliance contract")) + expect(complianceMock.submit).toHaveBeenCalledWith(CUSTODIAN, tokenAdmin) + }) + it("sends every oracle limit when one changes", () => { render() expand("Set the oracle limits") @@ -157,7 +369,7 @@ describe("ConfigurationSection", () => { freshness_duration: DAY, cooldown_secs: 3_600n, max_up_bps: 500, - max_down_bps: null, + max_down_bps: 500, min_answer: PRICE_ONE / 2n, max_answer: 2n * PRICE_ONE, }, @@ -165,6 +377,32 @@ describe("ConfigurationSection", () => { ) }) + it("proposes new oracle limits once the oracle's setup closes and follows them in the modal", () => { + useConfigurationStateMock.mockReturnValue({ + configuration: { ...READY, oracleSetupOpen: false }, + }) + const { rerender } = render( + , + ) + expand("Set the oracle limits") + fireEvent.change(screen.getByRole("textbox", { name: "Largest rise" }), { + target: { value: "5" }, + }) + fireEvent.click(submit("Set the oracle limits")) + + expect(proposeOracleConfigMock.submit).toHaveBeenCalledWith( + expect.objectContaining({ max_up_bps: 500 }), + oracleAdmin, + ) + expect(oracleConfigMock.submit).not.toHaveBeenCalled() + + proposeOracleConfigMock.status = { status: "awaiting-signature" } + rerender() + expect( + screen.getByText("Signing queues every new oracle limit."), + ).toBeTruthy() + }) + it("returns the fields to the current values once a change is recorded", () => { const { rerender } = render( , diff --git a/app/src/components/admin/ConfigurationSection.tsx b/app/src/components/admin/ConfigurationSection.tsx index 433f32d2..9b6d25c2 100644 --- a/app/src/components/admin/ConfigurationSection.tsx +++ b/app/src/components/admin/ConfigurationSection.tsx @@ -2,15 +2,26 @@ import React, { useEffect, useState } from "react" import { type Grant } from "../../hooks/useAdminAuthority" import { useConfigurationState } from "../../hooks/useConfigurationState" import { + useProposeCompliance, + useProposeCustodian, + useProposeIdentityVerifier, + useProposeMinEpochDuration, + useProposeNotice, + useProposeOracleConfig, + useProposeWindDownDelay, useSetCompliance, useSetCustodian, useSetDepositCap, useSetIdentityVerifier, + useSetMinEpochDuration, useSetNotice, useSetOracleConfig, useSetWindDownDelay, } from "../../hooks/useConfigurationTransactions" import { + prepareFor, + type Send, + submitTo, usePendingTransaction, type UsePendingTransaction, } from "../../hooks/usePendingTransaction" @@ -58,9 +69,21 @@ const ConfigurationSection: React.FC<{ const custodian = usePendingTransaction(useSetCustodian()) const notice = usePendingTransaction(useSetNotice()) const windDownDelay = usePendingTransaction(useSetWindDownDelay()) + const proposeCustodian = usePendingTransaction(useProposeCustodian()) + const proposeNotice = usePendingTransaction(useProposeNotice()) + const proposeWindDownDelay = usePendingTransaction(useProposeWindDownDelay()) + const minEpochDuration = usePendingTransaction(useSetMinEpochDuration()) + const proposeMinEpochDuration = usePendingTransaction( + useProposeMinEpochDuration(), + ) const oracleConfig = usePendingTransaction(useSetOracleConfig()) + const proposeOracleConfig = usePendingTransaction(useProposeOracleConfig()) const compliance = usePendingTransaction(useSetCompliance()) const identityVerifier = usePendingTransaction(useSetIdentityVerifier()) + const proposeCompliance = usePendingTransaction(useProposeCompliance()) + const proposeIdentityVerifier = usePendingTransaction( + useProposeIdentityVerifier(), + ) const [inputs, setInputs] = useState({}) const modalFlows = ( @@ -69,9 +92,17 @@ const ConfigurationSection: React.FC<{ ["set-custodian", custodian], ["set-notice", notice], ["set-wind-down-delay", windDownDelay], + ["propose-custodian", proposeCustodian], + ["propose-notice", proposeNotice], + ["propose-wind-down-delay", proposeWindDownDelay], + ["set-min-epoch-duration", minEpochDuration], + ["propose-min-epoch-duration", proposeMinEpochDuration], ["set-oracle-config", oracleConfig], + ["propose-oracle-config", proposeOracleConfig], ["set-compliance", compliance], ["set-identity-verifier", identityVerifier], + ["propose-compliance", proposeCompliance], + ["propose-identity-verifier", proposeIdentityVerifier], ] satisfies [ConfigurationModalAction, Flow][] ).map(([id, transaction]) => flow(id, transaction)) const recorded = modalFlows.some( @@ -83,23 +114,49 @@ const ConfigurationSection: React.FC<{ const actions = toConfigurationActions(configuration, grants, wallet, inputs) - const run = (action: ConfigurationAction) => { + const dispatch = (action: ConfigurationAction, send: Send) => { if (action.arg === undefined) return switch (action.id) { case "set-deposit-cap": - return depositCap.submit(action.arg, action.next, action.grant) + return send(depositCap, action.arg, action.next, action.grant) case "set-custodian": - return custodian.submit(action.arg, action.next, action.grant) + return send(custodian, action.arg, action.next, action.grant) case "set-notice": - return notice.submit(action.arg, action.next, action.grant) + return send(notice, action.arg, action.next, action.grant) case "set-wind-down-delay": - return windDownDelay.submit(action.arg, action.next, action.grant) + return send(windDownDelay, action.arg, action.next, action.grant) + case "propose-custodian": + return send(proposeCustodian, action.arg, action.next, action.grant) + case "propose-notice": + return send(proposeNotice, action.arg, action.next, action.grant) + case "propose-wind-down-delay": + return send(proposeWindDownDelay, action.arg, action.next, action.grant) + case "set-min-epoch-duration": + return send(minEpochDuration, action.arg, action.next, action.grant) + case "propose-min-epoch-duration": + return send( + proposeMinEpochDuration, + action.arg, + action.next, + action.grant, + ) case "set-oracle-config": - return oracleConfig.submit(action.arg, action.next, action.grant) + return send(oracleConfig, action.arg, action.next, action.grant) + case "propose-oracle-config": + return send(proposeOracleConfig, action.arg, action.next, action.grant) case "set-compliance": - return compliance.submit(action.arg, action.next, action.grant) + return send(compliance, action.arg, action.next, action.grant) case "set-identity-verifier": - return identityVerifier.submit(action.arg, action.next, action.grant) + return send(identityVerifier, action.arg, action.next, action.grant) + case "propose-compliance": + return send(proposeCompliance, action.arg, action.next, action.grant) + case "propose-identity-verifier": + return send( + proposeIdentityVerifier, + action.arg, + action.next, + action.grant, + ) } } @@ -119,7 +176,10 @@ const ConfigurationSection: React.FC<{ actions={actions} pending={configuration.status === "checking"} unreadable={false} - onRun={run} + onRun={(action) => dispatch(action, submitTo)} + onReady={ + active ? (action) => dispatch(action, prepareFor) : undefined + } onInput={onInput} /> } diff --git a/app/src/components/admin/CycleActionRail.test.tsx b/app/src/components/admin/CycleActionRail.test.tsx index eec99204..00d1c3af 100644 --- a/app/src/components/admin/CycleActionRail.test.tsx +++ b/app/src/components/admin/CycleActionRail.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, within } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { type RailAction, type RailField } from "../../pages/actions" import { type CycleAction } from "../../pages/cycle" import CycleActionRail from "./CycleActionRail" @@ -52,6 +52,8 @@ const attestAction: CycleAction = { }, price: null, freshness: null, + proofHash: null, + proofUri: "", enabled: false, } @@ -422,15 +424,15 @@ describe("CycleActionRail", () => { it("states an action's note outside the conditions", () => { renderRail([ - { ...attestAction, note: "Ripcord raised — attesting does not lift it." }, + { ...attestAction, note: "NAV paused — attesting does not unpause it." }, ]) expand("Attest price") const list = screen.getByRole("list", { name: "Attest price conditions" }) expect( - screen.getByText("Ripcord raised — attesting does not lift it."), + screen.getByText("NAV paused — attesting does not unpause it."), ).toBeTruthy() - expect(within(list).queryByText(/Ripcord/)).toBeNull() + expect(within(list).queryByText(/NAV paused/)).toBeNull() }) }) @@ -491,6 +493,27 @@ describe("CycleActionRail with keyed fields", () => { expect(onInput).toHaveBeenNthCalledWith(2, setLimits, "0.6", "floor") }) + it("keeps an action open and its field focused when its route changes id", () => { + const setNotice: RailAction = { ...setLimits, slot: "set-limits" } + const rail = (action: RailAction) => ( + + ) + const { rerender } = render(rail(setNotice)) + const field = screen.getByRole("textbox", { name: "Freshness window" }) + field.focus() + + rerender(rail({ ...setNotice, id: "propose-oracle-config" })) + + expect(expanded("Set the oracle limits")).toBe(true) + expect(document.activeElement).toBe(field) + }) + it("shows the current value next to the field and the new one once it reads", () => { renderKeyed() @@ -606,3 +629,170 @@ describe("CycleActionRail choice fields", () => { }, ) }) + +describe("CycleActionRail readiness", () => { + afterEach(() => { + vi.useRealTimers() + }) + + const renderWithReady = (actions: CycleAction[]) => { + const onReady = vi.fn() + render( + , + ) + return onReady + } + + it("reports the open action once it settles as ready", () => { + vi.useFakeTimers() + const onReady = renderWithReady([closeAction, fulfillAction]) + + expect(onReady).not.toHaveBeenCalled() + vi.advanceTimersByTime(400) + + expect(onReady).toHaveBeenCalledWith(closeAction) + }) + + it("releases the reported action once it is collapsed", () => { + vi.useFakeTimers() + const release = vi.fn() + render( + release} + />, + ) + vi.advanceTimersByTime(400) + + expect(release).not.toHaveBeenCalled() + + fireEvent.click(rowToggle("Close epoch")) + + expect(release).toHaveBeenCalledTimes(1) + }) + + it("releases the reported action once it is no longer ready", () => { + vi.useFakeTimers() + const release = vi.fn() + const rail = (action: CycleAction) => ( + release} + /> + ) + const { rerender } = render(rail(closeAction)) + vi.advanceTimersByTime(400) + + rerender(rail({ ...closeAction, enabled: false })) + vi.advanceTimersByTime(400) + + expect(release).toHaveBeenCalledTimes(1) + }) + + it("reports an equal action once when the parent rerenders inside the delay", () => { + vi.useFakeTimers() + const onReady = vi.fn() + const rail = () => ( + onReady(action)} + /> + ) + const { rerender } = render(rail()) + vi.advanceTimersByTime(300) + rerender(rail()) + vi.advanceTimersByTime(150) + + expect(onReady).toHaveBeenCalledTimes(1) + + rerender(rail()) + vi.advanceTimersByTime(1000) + + expect(onReady).toHaveBeenCalledTimes(1) + }) + + it("releases and re-arms when the action's argument changes", () => { + vi.useFakeTimers() + const release = vi.fn() + const onReady = vi.fn(() => release) + const rail = (epochId: bigint) => ( + + ) + const { rerender } = render(rail(5n)) + vi.advanceTimersByTime(400) + rerender(rail(6n)) + + expect(release).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(400) + + expect(onReady).toHaveBeenCalledTimes(2) + expect(onReady).toHaveBeenLastCalledWith( + expect.objectContaining({ epochId: 6n }), + ) + }) + + it("reports the new route of an open action whose id changes within its slot", () => { + vi.useFakeTimers() + const release = vi.fn() + const onReady = vi.fn(() => release) + const direct: RailAction = { ...closeAction, slot: "close" } + const proposed: RailAction = { ...direct, id: "propose-close" } + const rail = (action: RailAction) => ( + + ) + const { rerender } = render(rail(direct)) + vi.advanceTimersByTime(400) + + rerender(rail(proposed)) + vi.advanceTimersByTime(400) + + expect(release).toHaveBeenCalledTimes(1) + expect(onReady).toHaveBeenNthCalledWith(1, direct) + expect(onReady).toHaveBeenNthCalledWith(2, proposed) + }) + + it("does not report an open action that is blocked", () => { + vi.useFakeTimers() + const onReady = renderWithReady([closeAction, fulfillAction]) + + fireEvent.click(rowToggle("Close epoch")) + fireEvent.click(rowToggle("Fulfill epoch")) + vi.advanceTimersByTime(400) + + expect(onReady).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/components/admin/CycleActionRail.tsx b/app/src/components/admin/CycleActionRail.tsx index eaaf51aa..18429883 100644 --- a/app/src/components/admin/CycleActionRail.tsx +++ b/app/src/components/admin/CycleActionRail.tsx @@ -1,4 +1,5 @@ -import React, { useState } from "react" +import React, { useEffect, useRef, useState } from "react" +import { type Release } from "../../hooks/useContractTransaction" import { type ActionField as ActionFieldSpec, type Condition, @@ -16,6 +17,13 @@ import styles from "./CycleActionRail.module.css" type OnInput = (action: A, value: string, key?: string) => void +const PREPARE_DELAY_MS = 400 + +const serialise = (value: unknown): string => + JSON.stringify(value, (_name, part: unknown) => + typeof part === "bigint" ? `${part}n` : part, + ) + type Status = "pass" | "fail" | "pending" const statusOf = ({ met }: Condition): Status => { @@ -270,12 +278,16 @@ function ActionBody>({ ) } +const slotOf = (action: RailAction | undefined) => + action === undefined ? undefined : (action.slot ?? action.id) + function CycleActionRail>({ scope, actions, pending, unreadable, onRun, + onReady, onInput, }: { scope: string @@ -283,11 +295,35 @@ function CycleActionRail>({ pending: boolean unreadable: boolean onRun: (action: A) => void + onReady?: (action: A) => Release | undefined onInput?: OnInput }) { const [open, setOpen] = useState() if (open === undefined && actions.length > 0) - setOpen(actions.find((action) => action.enabled)?.id ?? null) + setOpen(slotOf(actions.find((action) => action.enabled)) ?? null) + const ready = actions.find( + (action) => slotOf(action) === open && action.enabled, + ) + + const latest = useRef({ ready, onReady }) + useEffect(() => { + latest.current = { ready, onReady } + }) + const readyKey = ready === undefined ? null : serialise(ready) + const reportable = onReady !== undefined + + useEffect(() => { + if (readyKey === null || !reportable) return + let release: Release | undefined + const timer = setTimeout(() => { + const { ready: action, onReady: report } = latest.current + if (action !== undefined) release = report?.(action) + }, PREPARE_DELAY_MS) + return () => { + clearTimeout(timer) + release?.() + } + }, [readyKey, reportable]) return (
@@ -315,10 +351,11 @@ function CycleActionRail>({
    {actions.map((action) => { const state = readiness(action) - const expanded = open === action.id - const id = `${scope}-${action.id}-action` + const slot = slotOf(action) + const expanded = open === slot + const id = `${scope}-${slot}-action` return ( -
  • +
  • ) -const Section: React.FC<{ +export const Section: React.FC<{ title: string state: ListState unreadable: string @@ -185,8 +195,11 @@ const Detail: React.FC<{ parts: ActivityDetail }> = ({ parts }) => ( ) -const ActivityTable: React.FC<{ rows: ActivityRow[] }> = ({ rows }) => ( - +export const ActivityTable: React.FC<{ + rows: ActivityRow[] + name?: string +}> = ({ rows, name = "Recent activity" }) => ( +
    {rows.map((row) => ( diff --git a/app/src/components/admin/EmergencySection.test.tsx b/app/src/components/admin/EmergencySection.test.tsx index 7af8efe0..71eba382 100644 --- a/app/src/components/admin/EmergencySection.test.tsx +++ b/app/src/components/admin/EmergencySection.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, within } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { type Grant } from "../../hooks/useAdminAuthority" import { type TransactionStatus } from "../../hooks/useContractTransaction" import EmergencySection from "./EmergencySection" @@ -7,17 +7,19 @@ import EmergencySection from "./EmergencySection" const transaction = () => ({ status: { status: "idle" } as TransactionStatus, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }) const { useVaultPausedMock, + useSetupOpenMock, useTokenPausedMock, useOracleStateMock, pauseVaultMock, unpauseVaultMock, - raiseRipcordMock, - setRipcordMock, + pauseNavMock, + setNavPausedMock, clearLatestMock, pauseTokenMock, unpauseTokenMock, @@ -25,16 +27,18 @@ const { const tx = () => ({ status: { status: "idle" } as { status: string }, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }) return { useVaultPausedMock: vi.fn(), + useSetupOpenMock: vi.fn(), useTokenPausedMock: vi.fn(), useOracleStateMock: vi.fn(), pauseVaultMock: tx(), unpauseVaultMock: tx(), - raiseRipcordMock: tx(), - setRipcordMock: tx(), + pauseNavMock: tx(), + setNavPausedMock: tx(), clearLatestMock: tx(), pauseTokenMock: tx(), unpauseTokenMock: tx(), @@ -49,6 +53,9 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ vi.mock("../../hooks/useVaultPaused", () => ({ useVaultPaused: useVaultPausedMock, })) +vi.mock("../../hooks/useSetupOpen", () => ({ + useSetupOpen: useSetupOpenMock, +})) vi.mock("../../hooks/useTokenPaused", () => ({ useTokenPaused: useTokenPausedMock, })) @@ -58,8 +65,8 @@ vi.mock("../../hooks/useOracleState", () => ({ vi.mock("../../hooks/useEmergencyTransactions", () => ({ usePauseVault: () => pauseVaultMock, useUnpauseVault: () => unpauseVaultMock, - useRaiseRipcord: () => raiseRipcordMock, - useSetRipcord: () => setRipcordMock, + usePauseNav: () => pauseNavMock, + useSetNavPaused: () => setNavPausedMock, useClearLatest: () => clearLatestMock, usePauseToken: () => pauseTokenMock, useUnpauseToken: () => unpauseTokenMock, @@ -93,6 +100,7 @@ const button = (name: string) => { const setState = (vault: string, token: string, oracle: string): void => { useVaultPausedMock.mockReturnValue({ pause: vault }) + useSetupOpenMock.mockReturnValue({ setup: "closed" }) useTokenPausedMock.mockReturnValue({ pause: token }) useOracleStateMock.mockReturnValue({ oracle }) } @@ -103,8 +111,8 @@ describe("EmergencySection", () => { for (const mock of [ pauseVaultMock, unpauseVaultMock, - raiseRipcordMock, - setRipcordMock, + pauseNavMock, + setNavPausedMock, clearLatestMock, pauseTokenMock, unpauseTokenMock, @@ -114,6 +122,38 @@ describe("EmergencySection", () => { setState("open", "open", "valid") }) + afterEach(() => { + vi.useRealTimers() + }) + + it("prepares the open ready action and still submits it on click", () => { + vi.useFakeTimers() + render() + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(pauseVaultMock.prepare).toHaveBeenCalledWith(undefined, guardian) + + fireEvent.click(button("Pause the vault")) + + expect(pauseVaultMock.submit).toHaveBeenCalledWith(undefined, guardian) + }) + + it("prepares nothing while the emergency tab is hidden", () => { + vi.useFakeTimers() + render( + , + ) + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(pauseVaultMock.prepare).not.toHaveBeenCalled() + }) + it("shows the vault, share token and oracle state first", () => { setState("paused", "open", "paused") render() @@ -122,7 +162,19 @@ describe("EmergencySection", () => { expect(within(overview).getByText("Vault")).toBeTruthy() expect(within(overview).getAllByText("Paused")).toHaveLength(2) expect(within(overview).getByText("Open")).toBeTruthy() - expect(within(overview).getByText("Ripcord raised")).toBeTruthy() + expect(within(overview).getByText("NAV paused")).toBeTruthy() + }) + + it("shows whether setup is still open beside the vault", () => { + useSetupOpenMock.mockReturnValue({ setup: "open" }) + render() + + const overview = screen.getByRole("region", { name: "Status" }) + const tiles = within(overview).getAllByText(/^(Vault|Setup)$/) + expect(tiles.map((tile) => tile.textContent)).toEqual(["Vault", "Setup"]) + expect( + within(overview).getByText("Changes apply at once; no deposits yet"), + ).toBeTruthy() }) it("offers the vault guardian only the pause, saying what it stops and what stays open", () => { @@ -155,36 +207,36 @@ describe("EmergencySection", () => { ).toBeTruthy() }) - it("raises the ripcord for an oracle admin through set_ripcord, shown as raising", () => { + it("pauses the NAV for an oracle admin through set_nav_paused, shown as pausing", () => { const { rerender } = render( , ) - fireEvent.click(button("Raise the ripcord")) - expect(setRipcordMock.submit).toHaveBeenCalledWith(true, oracleAdmin) - expect(raiseRipcordMock.submit).not.toHaveBeenCalled() + fireEvent.click(button("Pause the NAV")) + expect(setNavPausedMock.submit).toHaveBeenCalledWith(true, oracleAdmin) + expect(pauseNavMock.submit).not.toHaveBeenCalled() - setRipcordMock.status = { status: "preparing" } + setNavPausedMock.status = { status: "preparing" } rerender() expect( - screen.getByRole("heading", { name: "Preparing to raise the ripcord" }), + screen.getByRole("heading", { name: "Preparing to pause the NAV" }), ).toBeTruthy() }) - it("lowers the ripcord, and clears the latest price once raised", () => { + it("unpauses the NAV, and clears the latest price once paused", () => { setState("open", "open", "paused") const { rerender } = render( , ) - fireEvent.click(button("Lower the ripcord")) - expect(setRipcordMock.submit).toHaveBeenCalledWith(false, oracleAdmin) + fireEvent.click(button("Unpause the NAV")) + expect(setNavPausedMock.submit).toHaveBeenCalledWith(false, oracleAdmin) - setRipcordMock.status = { status: "preparing" } + setNavPausedMock.status = { status: "preparing" } rerender() expect( - screen.getByRole("heading", { name: "Preparing to lower the ripcord" }), + screen.getByRole("heading", { name: "Preparing to unpause the NAV" }), ).toBeTruthy() fireEvent.click(button("Clear the latest price")) diff --git a/app/src/components/admin/EmergencySection.tsx b/app/src/components/admin/EmergencySection.tsx index ba755d02..88850f8b 100644 --- a/app/src/components/admin/EmergencySection.tsx +++ b/app/src/components/admin/EmergencySection.tsx @@ -5,13 +5,19 @@ import { useClearLatest, usePauseToken, usePauseVault, - useRaiseRipcord, - useSetRipcord, + usePauseNav, + useSetNavPaused, useUnpauseToken, useUnpauseVault, } from "../../hooks/useEmergencyTransactions" import { useOracleState } from "../../hooks/useOracleState" -import { usePendingTransaction } from "../../hooks/usePendingTransaction" +import { + prepareFor, + type Send, + submitTo, + usePendingTransaction, +} from "../../hooks/usePendingTransaction" +import { useSetupOpen } from "../../hooks/useSetupOpen" import { useTokenPaused } from "../../hooks/useTokenPaused" import { useVaultPaused } from "../../hooks/useVaultPaused" import { @@ -29,8 +35,8 @@ import CycleActionRail from "./CycleActionRail" import SurfaceLayout from "./SurfaceLayout" import layoutStyles from "./SurfaceLayout.module.css" -const RAISE = "raise" -const LOWER = "lower" +const PAUSE = "pause" +const UNPAUSE = "unpause" const EmergencySection: React.FC<{ grants: Grant[] @@ -39,42 +45,38 @@ const EmergencySection: React.FC<{ active?: boolean }> = ({ grants, wallet, contracts, active = true }) => { const { pause: vault } = useVaultPaused({ poll: true }) + const { setup } = useSetupOpen("vault") const { pause: token } = useTokenPaused() const { oracle } = useOracleState() const pauseVault = usePendingTransaction(usePauseVault()) const unpauseVault = usePendingTransaction(useUnpauseVault()) - const raiseRipcord = usePendingTransaction(useRaiseRipcord()) - const setRipcord = usePendingTransaction(useSetRipcord()) + const pauseNav = usePendingTransaction(usePauseNav()) + const setNavPaused = usePendingTransaction(useSetNavPaused()) const clearLatest = usePendingTransaction(useClearLatest()) const pauseToken = usePendingTransaction(usePauseToken()) const unpauseToken = usePendingTransaction(useUnpauseToken()) - const state: EmergencyState = { vault, token, oracle } + const state: EmergencyState = { vault, setup, token, oracle } const actions = toEmergencyActions(state, grants, wallet) - const run = (action: EmergencyAction) => { + const dispatch = (action: EmergencyAction, send: Send) => { switch (action.id) { case "pause-vault": - pauseVault.submit(undefined, "", action.grant) - return + return send(pauseVault, undefined, "", action.grant) case "unpause-vault": - unpauseVault.submit(undefined, "", action.grant) - return - case "raise-ripcord": - if (action.viaAdmin) setRipcord.submit(true, RAISE, action.grant) - else raiseRipcord.submit(undefined, "", action.grant) - return - case "lower-ripcord": - setRipcord.submit(false, LOWER, action.grant) - return + return send(unpauseVault, undefined, "", action.grant) + case "pause-nav": + return action.viaAdmin + ? send(setNavPaused, true, PAUSE, action.grant) + : send(pauseNav, undefined, "", action.grant) + case "unpause-nav": + return send(setNavPaused, false, UNPAUSE, action.grant) case "clear-latest": - clearLatest.submit(undefined, "", action.grant) - return + return send(clearLatest, undefined, "", action.grant) case "pause-token": - pauseToken.submit(undefined, "", action.grant) - return + return send(pauseToken, undefined, "", action.grant) case "unpause-token": - unpauseToken.submit(undefined, "", action.grant) + return send(unpauseToken, undefined, "", action.grant) } } @@ -95,10 +97,10 @@ const EmergencySection: React.FC<{ const modalFlows: TransactionModalProps[] = [ flow("pause-vault", pauseVault), flow("unpause-vault", unpauseVault), - flow("raise-ripcord", raiseRipcord), + flow("pause-nav", pauseNav), flow( - setRipcord.amountLabel === RAISE ? "raise-ripcord" : "lower-ripcord", - setRipcord, + setNavPaused.amountLabel === PAUSE ? "pause-nav" : "unpause-nav", + setNavPaused, ), flow("clear-latest", clearLatest), flow("pause-token", pauseToken), @@ -115,7 +117,10 @@ const EmergencySection: React.FC<{ actions={actions} pending={false} unreadable={false} - onRun={run} + onRun={(action) => dispatch(action, submitTo)} + onReady={ + active ? (action) => dispatch(action, prepareFor) : undefined + } /> } > diff --git a/app/src/components/admin/GovernanceSection.test.tsx b/app/src/components/admin/GovernanceSection.test.tsx index b6cecb3b..21dc3d86 100644 --- a/app/src/components/admin/GovernanceSection.test.tsx +++ b/app/src/components/admin/GovernanceSection.test.tsx @@ -1,17 +1,23 @@ +import { Buffer } from "buffer" import { type PendingTransfer } from "@stellar-scaffold/app-lib" -import { fireEvent, render, screen, within } from "@testing-library/react" -import { beforeEach, describe, expect, it, type Mock, vi } from "vitest" +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest" import { type AdminAddresses, type Grant, type RoleHolders, } from "../../hooks/useAdminAuthority" import { type TransactionStatus } from "../../hooks/useContractTransaction" -import { - type Handovers, - type PendingUpgrade, - type Upgrades, -} from "../../hooks/useGovernanceState" +import { type Handovers, type Upgrades } from "../../hooks/useGovernanceState" +import { type PendingChange } from "../../pages/pendingChanges" import GovernanceSection from "./GovernanceSection" const WALLET = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" @@ -22,10 +28,12 @@ const { txs, useGovernanceStateMock } = vi.hoisted(() => { const tx = (): { status: TransactionStatus submit: Mock<() => boolean> + prepare: Mock reset: Mock } => ({ status: { status: "idle" }, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }) return { @@ -47,8 +55,21 @@ const { txs, useGovernanceStateMock } = vi.hoisted(() => { useAcceptTokenAdmin: tx(), useProposeCodeUpgrade: tx(), useProposeUpgradeDelay: tx(), - useApplyUpgrade: tx(), - useCancelUpgrade: tx(), + useProposeGrantRole: tx(), + useProposeRevokeRole: tx(), + useProposeAdmin: tx(), + useApplyChange: tx(), + useCancelChange: tx(), + useProposeTokenGrantRole: tx(), + useProposeTokenRevokeRole: tx(), + useProposeTokenAdmin: tx(), + useApplyTokenChange: tx(), + useCancelTokenChange: tx(), + useProposeOracleGrantRole: tx(), + useProposeOracleAdmin: tx(), + useProposeOracleUpgrade: tx(), + useApplyOracleChange: tx(), + useCancelOracleChange: tx(), useProposeWindDown: tx(), useCancelWindDown: tx(), }, @@ -82,6 +103,7 @@ const grantOf = (role: string): Grant => ({ }) const governance = grantOf("vault governance") +const oracleAdmin = grantOf("oracle admin") const tokenAdmin = grantOf("share-token admin") const HOLDERS: RoleHolders = { @@ -107,13 +129,18 @@ const handovers = (pending: PendingTransfer | null = null): Handovers => ({ pending: { vault: null, oracle: pending, token: null }, }) -const upgrades = (proposal: PendingUpgrade | null = null): Upgrades => ({ +const upgrades = ( + pending: PendingChange[] = [], + setupOpen = true, + phase: "proposed" | "active" = "proposed", +): Upgrades => ({ status: "ready", - proposal, + pending, + setupOpen, delay: 7n * 86_400n, notice: 86_400n, windDown: { - phase: "proposed", + phase, activeAt: 1_700_100_000n, round: 0, delay: 86_400n, @@ -124,6 +151,12 @@ const upgrades = (proposal: PendingUpgrade | null = null): Upgrades => ({ paused: "open", }) +const queues = ( + vault: Upgrades, + token: Upgrades = upgrades(), + oracle: Upgrades = upgrades(), +) => ({ vault, token, oracle }) + const expand = (name: string) => { const toggle = screen .getAllByRole("button", { name }) @@ -148,15 +181,20 @@ const renderSection = (grants: Grant[], active = true) => ) describe("GovernanceSection", () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { vi.clearAllMocks() useGovernanceStateMock.mockReturnValue({ handovers: handovers(), - upgrades: upgrades(), + upgrades: queues(upgrades()), }) for (const mock of Object.values(txs)) { mock.status = { status: "idle" } mock.submit = vi.fn(() => true) + mock.prepare = vi.fn() mock.reset = vi.fn() } }) @@ -174,7 +212,7 @@ describe("GovernanceSection", () => { it("says when the pending admins could not be read", () => { useGovernanceStateMock.mockReturnValue({ handovers: { status: "unreadable" }, - upgrades: upgrades(), + upgrades: queues(upgrades()), }) renderSection([]) @@ -186,7 +224,7 @@ describe("GovernanceSection", () => { it("keeps the rail checking while the pending admins load", () => { useGovernanceStateMock.mockReturnValue({ handovers: { status: "checking" }, - upgrades: upgrades(), + upgrades: queues(upgrades()), }) renderSection([]) @@ -207,6 +245,53 @@ describe("GovernanceSection", () => { ).toEqual([]) }) + it("prepares a filled-in grant once it settles and still submits it on click", () => { + vi.useFakeTimers() + renderSection([governance]) + expand("Grant a vault role") + fireEvent.change(screen.getByRole("combobox", { name: "Role" }), { + target: { value: "guardian" }, + }) + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value: ACCOUNT }, + }) + + act(() => { + vi.advanceTimersByTime(400) + }) + + const grantArg = { role: "guardian", account: ACCOUNT } + expect(txs.useGrantVaultRole.prepare).toHaveBeenCalledWith( + grantArg, + governance, + ) + + fireEvent.click(submit("Grant a vault role")) + + expect(txs.useGrantVaultRole.submit).toHaveBeenCalledWith( + grantArg, + governance, + ) + }) + + it("prepares nothing while the governance tab is hidden", () => { + vi.useFakeTimers() + renderSection([governance], false) + expand("Grant a vault role") + fireEvent.change(screen.getByRole("combobox", { name: "Role" }), { + target: { value: "guardian" }, + }) + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value: ACCOUNT }, + }) + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(txs.useGrantVaultRole.prepare).not.toHaveBeenCalled() + }) + it("grants the chosen role to the typed account", () => { renderSection([governance]) @@ -354,7 +439,7 @@ describe("GovernanceSection", () => { liveUntilLedger: 5_000, expired: false, }), - upgrades: upgrades(), + upgrades: queues(upgrades()), }) renderSection([grantOf("oracle admin"), grantOf("oracle pending admin")]) @@ -379,35 +464,250 @@ describe("GovernanceSection", () => { }) }) - it("applies a confirmed code upgrade and cancels the wind-down", () => { + it("blocks accepting the admin while the vault winds down", () => { + const windingDown = upgrades([], true, "active") + useGovernanceStateMock.mockReturnValue({ + handovers: handovers({ + address: WALLET, + liveUntilLedger: 5_000, + expired: false, + }), + upgrades: queues(windingDown, windingDown, windingDown), + }) + renderSection([grantOf("oracle pending admin")]) + + expand("Accept oracle admin") + + expect( + screen + .getAllByRole("listitem") + .some((item) => item.textContent === "Fail: Wind-down not active"), + ).toBe(true) + expect(submit("Accept oracle admin").disabled).toBe(true) + }) + + it("proposes a vault role grant once setup is closed", () => { + useGovernanceStateMock.mockReturnValue({ + handovers: handovers(), + upgrades: queues(upgrades([], false)), + }) + renderSection([governance]) + + expand("Grant a vault role") + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value: ACCOUNT }, + }) + fireEvent.click(submit("Grant a vault role")) + + expect(txs.useProposeGrantRole.submit).toHaveBeenCalledWith( + { role: "manager", account: ACCOUNT }, + governance, + ) + expect(txs.useGrantVaultRole.submit).not.toHaveBeenCalled() + }) + + it("applies a confirmed code upgrade, cancels a change and lists both", () => { const hash = "ab".repeat(32) useGovernanceStateMock.mockReturnValue({ handovers: handovers(), - upgrades: upgrades({ kind: "code", hash, eta: 1_700_000_000n }), + upgrades: queues( + upgrades( + [ + { + id: 2, + change: { tag: "Upgrade", values: [Buffer.from(hash, "hex")] }, + eta: 1_700_000_000n, + }, + { + id: 5, + change: { tag: "Custodian", values: [ACCOUNT] }, + eta: 1_900_000_000n, + }, + ], + false, + ), + ), }) renderSection([governance]) - expand("Apply the upgrade") + expand("Apply the change #2") fireEvent.click( screen.getByRole("checkbox", { name: `Replaces the vault's code with ${hash}.`, }), ) - fireEvent.click(submit("Apply the upgrade")) + fireEvent.click(submit("Apply the change #2")) + expand("Cancel the change #5") + fireEvent.click(submit("Cancel the change #5")) + + expect(txs.useApplyChange.submit).toHaveBeenCalledWith(2, governance) + expect(txs.useCancelChange.submit).toHaveBeenCalledWith(5, governance) + const pending = screen.getByRole("region", { + name: "Vault pending changes", + }) + expect(within(pending).getByText("#2 Code upgrade")).toBeTruthy() + expect(within(pending).getByTitle(hash).textContent).toBe("abab...abab") + expect(within(pending).getByText("#5 Custodian")).toBeTruthy() + }) + + it("cancels the proposed wind-down", () => { + renderSection([governance]) + expand("Cancel the wind-down") fireEvent.click(submit("Cancel the wind-down")) - expect(txs.useApplyUpgrade.submit).toHaveBeenCalledWith( - undefined, - governance, - ) expect(txs.useCancelWindDown.submit).toHaveBeenCalledWith( undefined, governance, ) - expect(screen.getByText("Pending upgrade")).toBeTruthy() - expect(screen.getByTitle(`Code ${hash}`).textContent).toBe( - "Code abab...abab", + }) + + it("proposes a share-token role grant and hand-over once the token's setup is closed", () => { + useGovernanceStateMock.mockReturnValue({ + handovers: handovers(), + upgrades: queues(upgrades(), upgrades([], false)), + }) + renderSection([tokenAdmin]) + + expand("Grant a share-token role") + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value: ACCOUNT }, + }) + fireEvent.click(submit("Grant a share-token role")) + expand("Hand over share-token admin") + fireEvent.change(screen.getByRole("textbox", { name: "New admin" }), { + target: { value: ACCOUNT }, + }) + fireEvent.click( + screen.getByRole("checkbox", { + name: "Hands control of the share token to GABA...EJXA.", + }), + ) + fireEvent.click(submit("Hand over share-token admin")) + + expect(txs.useProposeTokenGrantRole.submit).toHaveBeenCalledWith( + { role: "compliance", account: ACCOUNT }, + tokenAdmin, ) + expect(txs.useProposeTokenAdmin.submit).toHaveBeenCalledWith( + ACCOUNT, + tokenAdmin, + ) + expect(txs.useGrantTokenRole.submit).not.toHaveBeenCalled() + expect(txs.useHandOverTokenAdmin.submit).not.toHaveBeenCalled() + }) + + it("applies and cancels share-token changes apart from the vault's and lists them in their own group", () => { + const change = (id: number, tag: string): PendingChange => ({ + id, + change: { tag, values: [VAULT] }, + eta: 1_700_000_000n, + }) + useGovernanceStateMock.mockReturnValue({ + handovers: handovers(), + upgrades: queues( + upgrades([change(1, "Custodian")], false), + upgrades( + [change(1, "Compliance"), change(2, "IdentityVerifier")], + false, + ), + ), + }) + renderSection([tokenAdmin]) + + expand("Apply the change #1") + fireEvent.click(submit("Apply the change #1")) + expand("Cancel the change #2") + fireEvent.click(submit("Cancel the change #2")) + + expect(txs.useApplyTokenChange.submit).toHaveBeenCalledWith(1, tokenAdmin) + expect(txs.useCancelTokenChange.submit).toHaveBeenCalledWith(2, tokenAdmin) + expect(txs.useApplyChange.submit).not.toHaveBeenCalled() + const token = screen.getByRole("region", { + name: "Share token pending changes", + }) + expect(within(token).getByText("#1 Compliance contract")).toBeTruthy() + expect(within(token).getByText("#2 Identity verifier")).toBeTruthy() + const vault = screen.getByRole("region", { name: "Vault pending changes" }) + expect(within(vault).getByText("#1 Custodian")).toBeTruthy() + }) + + it("proposes an oracle role grant, hand-over and upgrade once the oracle's setup is closed", () => { + useGovernanceStateMock.mockReturnValue({ + handovers: handovers(), + upgrades: queues(upgrades(), upgrades(), upgrades([], false)), + }) + renderSection([oracleAdmin]) + + expand("Grant an oracle role") + fireEvent.change(screen.getByRole("combobox", { name: "Role" }), { + target: { value: "guardian" }, + }) + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value: ACCOUNT }, + }) + fireEvent.click(submit("Grant an oracle role")) + expand("Hand over oracle admin") + fireEvent.change(screen.getByRole("textbox", { name: "New admin" }), { + target: { value: ACCOUNT }, + }) + fireEvent.click( + screen.getByRole("checkbox", { + name: "Hands control of the oracle to GABA...EJXA.", + }), + ) + fireEvent.click(submit("Hand over oracle admin")) + expand("Propose an oracle code upgrade") + fireEvent.change(screen.getByRole("textbox", { name: "Code hash" }), { + target: { value: "ab".repeat(32) }, + }) + fireEvent.click(submit("Propose an oracle code upgrade")) + + expect(txs.useProposeOracleGrantRole.submit).toHaveBeenCalledWith( + { role: "guardian", account: ACCOUNT }, + oracleAdmin, + ) + expect(txs.useProposeOracleAdmin.submit).toHaveBeenCalledWith( + ACCOUNT, + oracleAdmin, + ) + expect(txs.useProposeOracleUpgrade.submit).toHaveBeenCalledWith( + "ab".repeat(32), + oracleAdmin, + ) + expect(txs.useGrantOracleRole.submit).not.toHaveBeenCalled() + expect(txs.useHandOverOracleAdmin.submit).not.toHaveBeenCalled() + }) + + it("applies and cancels oracle changes apart from the others and lists them in their own group", () => { + const admin: PendingChange = { + id: 1, + change: { tag: "Admin", values: [ACCOUNT] }, + eta: 1_700_000_000n, + } + useGovernanceStateMock.mockReturnValue({ + handovers: handovers(), + upgrades: queues( + upgrades(), + upgrades(), + upgrades([admin, { ...admin, id: 2 }], false), + ), + }) + renderSection([oracleAdmin]) + + expand("Apply the change #1") + fireEvent.click(submit("Apply the change #1")) + expand("Cancel the change #2") + fireEvent.click(submit("Cancel the change #2")) + + expect(txs.useApplyOracleChange.submit).toHaveBeenCalledWith(1, oracleAdmin) + expect(txs.useCancelOracleChange.submit).toHaveBeenCalledWith( + 2, + oracleAdmin, + ) + const oracle = screen.getByRole("region", { + name: "Oracle pending changes", + }) + expect(within(oracle).getAllByText("#1 Admin hand-over")).toHaveLength(1) }) }) diff --git a/app/src/components/admin/GovernanceSection.tsx b/app/src/components/admin/GovernanceSection.tsx index 2ea12a05..fcf7ec7c 100644 --- a/app/src/components/admin/GovernanceSection.tsx +++ b/app/src/components/admin/GovernanceSection.tsx @@ -7,10 +7,23 @@ import { } from "../../hooks/useAdminAuthority" import { useGovernanceState } from "../../hooks/useGovernanceState" import { - useApplyUpgrade, - useCancelUpgrade, + useApplyChange, + useApplyOracleChange, + useApplyTokenChange, + useCancelChange, + useCancelOracleChange, + useCancelTokenChange, useCancelWindDown, + useProposeAdmin, useProposeCodeUpgrade, + useProposeGrantRole, + useProposeOracleAdmin, + useProposeOracleGrantRole, + useProposeOracleUpgrade, + useProposeRevokeRole, + useProposeTokenAdmin, + useProposeTokenGrantRole, + useProposeTokenRevokeRole, useProposeUpgradeDelay, useProposeWindDown, useAcceptOracleAdmin, @@ -30,6 +43,10 @@ import { useRevokeVaultRole, } from "../../hooks/useGovernanceTransactions" import { + prepareFor, + type Send, + type Sendable, + submitTo, usePendingTransaction, type UsePendingTransaction, } from "../../hooks/usePendingTransaction" @@ -37,6 +54,7 @@ import { useRailInputs } from "../../hooks/useRailInputs" import { GOVERNANCE_ACTION_IDS, type GovernanceAction, + type GovernanceActionId, toGovernanceActions, toGovernanceGroups, } from "../../pages/governance" @@ -83,23 +101,43 @@ function useGovernanceFlows() { "hand-over-token-admin": usePendingTransaction(useHandOverTokenAdmin()), "cancel-token-handover": usePendingTransaction(useCancelTokenHandover()), "accept-token-admin": usePendingTransaction(useAcceptTokenAdmin()), + "propose-grant-role": usePendingTransaction(useProposeGrantRole()), + "propose-revoke-role": usePendingTransaction(useProposeRevokeRole()), + "propose-admin": usePendingTransaction(useProposeAdmin()), "propose-code-upgrade": usePendingTransaction(useProposeCodeUpgrade()), "propose-upgrade-delay": usePendingTransaction(useProposeUpgradeDelay()), - "apply-upgrade": usePendingTransaction(useApplyUpgrade()), - "cancel-upgrade": usePendingTransaction(useCancelUpgrade()), + "apply-change": usePendingTransaction(useApplyChange()), + "cancel-change": usePendingTransaction(useCancelChange()), + "propose-token-grant-role": usePendingTransaction( + useProposeTokenGrantRole(), + ), + "propose-token-revoke-role": usePendingTransaction( + useProposeTokenRevokeRole(), + ), + "propose-token-admin": usePendingTransaction(useProposeTokenAdmin()), + "apply-token-change": usePendingTransaction(useApplyTokenChange()), + "cancel-token-change": usePendingTransaction(useCancelTokenChange()), + "propose-oracle-grant-role": usePendingTransaction( + useProposeOracleGrantRole(), + ), + "propose-oracle-admin": usePendingTransaction(useProposeOracleAdmin()), + "propose-oracle-upgrade": usePendingTransaction(useProposeOracleUpgrade()), + "apply-oracle-change": usePendingTransaction(useApplyOracleChange()), + "cancel-oracle-change": usePendingTransaction(useCancelOracleChange()), "propose-wind-down": usePendingTransaction(useProposeWindDown()), "cancel-wind-down": usePendingTransaction(useCancelWindDown()), - } satisfies Record + } satisfies Record } type Flows = ReturnType -const submitArg = ( - { submit }: { submit: (arg: Arg, label: string, grant?: Grant) => boolean }, +const sendArg = ( + send: Send, + flow: Sendable, { arg, next, grant }: { arg: Arg | undefined; next: string; grant?: Grant }, -) => arg !== undefined && submit(arg, next, grant) +) => (arg === undefined ? undefined : send(flow, arg, next, grant)) -function submit(flows: Flows, action: GovernanceAction) { +function dispatch(flows: Flows, action: GovernanceAction, send: Send) { switch (action.id) { case "grant-vault-role": case "revoke-vault-role": @@ -107,26 +145,40 @@ function submit(flows: Flows, action: GovernanceAction) { case "revoke-oracle-role": case "grant-token-role": case "revoke-token-role": - return submitArg(flows[action.id], action) + case "propose-grant-role": + case "propose-revoke-role": + case "propose-token-grant-role": + case "propose-token-revoke-role": + case "propose-oracle-grant-role": + return sendArg(send, flows[action.id], action) case "hand-over-vault-admin": case "cancel-vault-handover": case "hand-over-oracle-admin": case "cancel-oracle-handover": case "hand-over-token-admin": case "cancel-token-handover": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "propose-code-upgrade": - return submitArg(flows[action.id], action) + case "propose-oracle-upgrade": + case "propose-admin": + case "propose-token-admin": + case "propose-oracle-admin": + return sendArg(send, flows[action.id], action) case "propose-upgrade-delay": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) + case "apply-change": + case "cancel-change": + case "apply-token-change": + case "cancel-token-change": + case "apply-oracle-change": + case "cancel-oracle-change": + return sendArg(send, flows[action.id], action) case "accept-vault-admin": case "accept-oracle-admin": case "accept-token-admin": - case "apply-upgrade": - case "cancel-upgrade": case "propose-wind-down": case "cancel-wind-down": - return flows[action.id].submit(undefined, action.next, action.grant) + return send(flows[action.id], undefined, action.next, action.grant) } } @@ -173,7 +225,12 @@ const GovernanceSection: React.FC<{ actions={actions} pending={handovers.status === "checking"} unreadable={handovers.status === "unreadable"} - onRun={(action) => submit(flows, action)} + onRun={(action) => dispatch(flows, action, submitTo)} + onReady={ + active + ? (action) => dispatch(flows, action, prepareFor) + : undefined + } onInput={onInput} /> } diff --git a/app/src/components/vault/AboutVault.module.css b/app/src/components/vault/AboutVault.module.css index d630daa8..f5422bf0 100644 --- a/app/src/components/vault/AboutVault.module.css +++ b/app/src/components/vault/AboutVault.module.css @@ -42,12 +42,18 @@ .row { display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: baseline; - gap: 12px; + gap: 7px 12px; padding-bottom: 7px; } +.rowNote { + flex-basis: 100%; + color: var(--vault-text-muted); +} + .rowLabel { color: var(--vault-text-muted); white-space: nowrap; diff --git a/app/src/components/vault/AboutVault.test.tsx b/app/src/components/vault/AboutVault.test.tsx index 3fa74fc6..d53dfc01 100644 --- a/app/src/components/vault/AboutVault.test.tsx +++ b/app/src/components/vault/AboutVault.test.tsx @@ -64,6 +64,29 @@ describe("AboutVault", () => { expect(screen.getByRole("progressbar")).toBeTruthy() }) + it("renders a figure's definition inside its row", () => { + render( + , + ) + + const [row] = within(screen.getByRole("list")).getAllByRole("listitem") + + expect(within(row!).getByText("Shares outside the vault")).toBeTruthy() + }) + it("renders the figures group before the address groups", () => { renderAboutVault([ { diff --git a/app/src/components/vault/AboutVault.tsx b/app/src/components/vault/AboutVault.tsx index 8172ea09..4e7adc7e 100644 --- a/app/src/components/vault/AboutVault.tsx +++ b/app/src/components/vault/AboutVault.tsx @@ -14,7 +14,9 @@ export type AddressGroup = { export type FigureRow = { label: string value: string | null + note?: string pending?: boolean + href?: string } export type FigureGroup = { @@ -65,6 +67,11 @@ const AboutVault: React.FC = ({ {row.value ?? "Unavailable"} )} + {row.note !== undefined && ( + + {row.note} + + )} ))} diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 83ce5b65..99b5357e 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -14,6 +14,7 @@ import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" +import { type ClaimWindDownStatus } from "../../hooks/useClaimWindDown" import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" import { type TransactionStatus } from "../../hooks/useContractTransaction" import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" @@ -488,6 +489,40 @@ describe("TransactionModal", () => { expect(screen.queryByText(/Transaction/)).toBeNull() }) + it("names SetupOpen as deposits starting once setup closes", () => { + renderModal({ + status: "failed", + failure: { kind: "contract-error", code: 6064 }, + }) + + expect(screen.getByText(/setup is still open/)).toBeTruthy() + expect(screen.getByText(/once it closes/)).toBeTruthy() + }) + + it("names FeedNotValid as subscriptions paused until the price feed is valid again", () => { + renderModal({ + status: "failed", + failure: { kind: "contract-error", code: 6044 }, + }) + + expect(screen.getByText(/price feed is not valid right now/)).toBeTruthy() + expect(screen.getByText(/subscriptions are paused/)).toBeTruthy() + }) + + it("names EscrowNotCovered as subscriptions refused until the vault is funded again", () => { + renderModal({ + status: "failed", + failure: { kind: "contract-error", code: 6076 }, + }) + + expect( + screen.getByText(/enough to cover its pending deposits/), + ).toBeTruthy() + expect( + screen.getByText(/not taking new subscriptions until it is funded again/), + ).toBeTruthy() + }) + it("falls back to a generic refusal for a contract error it does not recognize, without the raw code", () => { renderModal({ status: "failed", @@ -713,6 +748,20 @@ describe("TransactionModal, cancelling", () => { expect(screen.getByText(/This request could not be found/)).toBeTruthy() }) + it("names EscrowNotCovered as a deposit the vault cannot return until it is funded again", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6076 }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect( + screen.getByText(/You can cancel once it is funded again/), + ).toBeTruthy() + }) + it("falls back to a generic refusal for a cancel error it does not recognize, without the raw code", () => { renderCancelModal({ status: "failed", @@ -792,6 +841,7 @@ describe("TransactionModal, cancelling a redemption", () => { renderCancelRedeemModal({ status: "confirmed", returnedShares: 100_0000000n as Amount, + surrendered: false, hash: "b".repeat(64), }) @@ -806,6 +856,7 @@ describe("TransactionModal, cancelling a redemption", () => { renderCancelRedeemModal({ status: "confirmed", returnedShares: 952380952n as Amount, + surrendered: false, hash: "b".repeat(64), }) @@ -823,15 +874,36 @@ describe("TransactionModal, cancelling a redemption", () => { ).toBeTruthy() }) - it("names a delisted controller as unable to receive its shares back, distinct from the claim wording for the same code", () => { + it("names NotAllowlisted as a delisted holder whose shares stay queued, pointing to the cash claim instead", () => { renderCancelRedeemModal({ status: "failed", - failure: { kind: "contract-error", code: 304 }, + failure: { kind: "contract-error", code: 6068 }, }) - expect(screen.getByText(/shares cannot be returned/)).toBeTruthy() + expect(screen.getByText(/not allowlisted/)).toBeTruthy() expect(screen.getByText(/claim the USDC/i)).toBeTruthy() - expect(screen.queryByText(/cannot receive shares/)).toBeNull() + }) + + it("names HolderNotEligible as shares the address cannot receive, pointing to the cash claim instead", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6073 }, + }) + + expect( + screen.getByText(/cannot receive these shares right now/), + ).toBeTruthy() + expect(screen.getByText(/claim the USDC/i)).toBeTruthy() + }) + + it("falls back to a generic refusal for 304 on a withdrawal cancellation, since the vault checks the holder itself", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() }) it("falls back to a generic refusal for a cancel-redeem error it does not recognize, without the raw code", () => { @@ -977,13 +1049,14 @@ describe("TransactionModal, claiming", () => { expect(screen.getByText(/too large to complete/)).toBeTruthy() }) - it("names the identity verifier's cross-contract refusal as no longer allowlisted, distinct from every other code", () => { + it("falls back to a generic refusal for 304 on a share claim, since a delisted holder's shares join an exit instead", () => { renderClaimModal({ status: "failed", failure: { kind: "contract-error", code: 304 }, }) - expect(screen.getByText(/no longer allowlisted/)).toBeTruthy() + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() }) it("falls back to a generic refusal for a claim error it does not recognize, without the raw code", () => { @@ -1131,6 +1204,16 @@ describe("TransactionModal, redeeming", () => { expect(screen.getByText(/not accepting new redemptions/)).toBeTruthy() }) + it("names SetupOpen as redemptions starting once setup closes", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6064 }, + }) + + expect(screen.getByText(/setup is still open/)).toBeTruthy() + expect(screen.getByText(/Redemptions start once it closes/)).toBeTruthy() + }) + it("falls back to a generic refusal for a redeem error it does not recognize, without the raw code", () => { renderRedeemModal({ status: "failed", @@ -1211,6 +1294,7 @@ describe("TransactionModal, claiming a redemption", () => { renderClaimRedeemModal({ status: "confirmed", assetsClaimed: 150_0000000n as Amount, + sharesReturned: false, hash: "b".repeat(64), }) @@ -1222,6 +1306,7 @@ describe("TransactionModal, claiming a redemption", () => { renderClaimRedeemModal({ status: "confirmed", assetsClaimed: 952380952n as Amount, + sharesReturned: false, hash: "b".repeat(64), }) @@ -1232,6 +1317,7 @@ describe("TransactionModal, claiming a redemption", () => { renderClaimRedeemModal({ status: "confirmed", assetsClaimed: 0n as Amount, + sharesReturned: true, hash: "b".repeat(64), }) @@ -1264,21 +1350,29 @@ describe("TransactionModal, claiming a redemption", () => { expect(screen.getByText(/enough USDC in reserve/)).toBeTruthy() }) - it("names a delisted controller as unable to receive the shares the zero-price branch tried to return, distinct from the claim and cancel-redeem wording for the same code", () => { + it("names HolderNotEligible as the zero-price return the address cannot receive, in the same words as a withdrawal cancellation", () => { renderClaimRedeemModal({ status: "failed", - failure: { kind: "contract-error", code: 304 }, + failure: { kind: "contract-error", code: 6073 }, }) - expect(screen.getByText(/tried to return your shares instead/)).toBeTruthy() + expect(screen.getByText(/tried to return your shares/)).toBeTruthy() expect( - screen.queryByText( - /^Your address is no longer allowlisted, so it cannot receive shares\.$/, - ), - ).toBeNull() + screen.getByText(/cannot receive these shares right now/), + ).toBeTruthy() expect(screen.queryByText(/claim the USDC/i)).toBeNull() }) + it("falls back to a generic refusal for 304 on a withdrawal claim, since the vault checks the holder itself", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) + it("names AlreadyClaimed by delegating to the shared claim wording", () => { renderClaimRedeemModal({ status: "failed", @@ -1397,6 +1491,8 @@ describe("TransactionModal, closing an epoch", () => { [6032, /not open/], [2000, /not authorized to close/], [2007, /does not hold the manager role/], + [6077, /Only the manager can seal this epoch/], + [6064, /setup is still open, so no epoch can be sealed/], ])("names the vault's reason for code %i", (code, sentence) => { renderCloseEpochModal({ status: "failed", @@ -1506,6 +1602,7 @@ describe("TransactionModal, pricing an epoch", () => { [6031, /could not resolve a valid share price/], [6014, /too large/], [1000, /paused/], + [6064, /setup is still open, so no epoch can be priced/], ])("names the vault's reason for code %i", (code, sentence) => { renderFulfillEpochModal({ status: "failed", @@ -1567,7 +1664,7 @@ describe("TransactionModal, attesting a price", () => { ).toBeTruthy() expect( screen.getByText( - "Signing records 1.0400 as the share price. Sealed epochs settle at it once their notice elapses.", + "Signing records 1.0400 as the share price, with its proof. Sealed epochs settle at it once their notice elapses.", ), ).toBeTruthy() }) @@ -1601,6 +1698,9 @@ describe("TransactionModal, attesting a price", () => { [3005, /already have expired/], [3003, /cooldown .* has not elapsed/], [3004, /moves further from the last attested price/], + [3010, /^Unpause the NAV first\.$/], + [3023, /^The proof hash cannot be all zeros\.$/], + [3024, /^The proof URI is longer than 256 bytes\.$/], ])("names the oracle's reason for code %i", (code, sentence) => { renderAttestModal({ status: "failed", @@ -2088,6 +2188,24 @@ describe("TransactionModal, emergency actions", () => { ) }) + it("asks to try again when a prepared transaction went stale", () => { + const { onRetry } = renderModal({ + status: "failed", + failure: { kind: "stale" }, + }) + + expect( + screen.getByRole("heading", { name: "The transaction went stale" }), + ).toBeTruthy() + expect( + screen.getByText( + "The account moved on since it was prepared, so nothing was sent. Try again.", + ), + ).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + it("treats an unexplained network rejection as an unknown outcome", () => { renderEmergencyModal("pause-vault", { status: "failed", @@ -2105,8 +2223,8 @@ describe("TransactionModal, emergency actions", () => { it.each<[EmergencyModalAction, string, string]>([ ["pause-vault", "Preparing to pause the vault", "Vault paused"], ["unpause-vault", "Preparing to unpause the vault", "Vault unpaused"], - ["raise-ripcord", "Preparing to raise the ripcord", "Ripcord raised"], - ["lower-ripcord", "Preparing to lower the ripcord", "Ripcord lowered"], + ["pause-nav", "Preparing to pause the NAV", "NAV paused"], + ["unpause-nav", "Preparing to unpause the NAV", "NAV unpaused"], [ "clear-latest", "Preparing to clear the latest price", @@ -2144,13 +2262,13 @@ describe("TransactionModal, emergency actions", () => { it.each<[EmergencyModalAction, string, string]>([ [ "pause-token", - "Signing stops holder transfers and returns of escrowed shares.", - "Holder transfers and returns of escrowed shares are stopped. Minting, burning and forced transfers stay open.", + "Signing stops holder transfers only.", + "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", ], [ "unpause-token", - "Signing resumes holder transfers and returns of escrowed shares.", - "Holder transfers and returns of escrowed shares are open again.", + "Signing resumes holder transfers.", + "Holder transfers are open again.", ], ])( "words %s after what the share token enforces", @@ -2177,8 +2295,8 @@ describe("TransactionModal, emergency actions", () => { ["unpause-vault", 1001, "vault", /The vault is not paused/], ["pause-token", 1000, "share token", /The share token is already paused/], ["unpause-token", 1001, "share token", /The share token is not paused/], - ["raise-ripcord", 2000, "oracle", /does not hold the role/], - ["clear-latest", 3008, "oracle", /The ripcord is not raised/], + ["pause-nav", 2000, "oracle", /does not hold the role/], + ["clear-latest", 3008, "oracle", /The NAV is not paused/], ])( "names the reason %s was refused with code %i", (action, code, refuser, sentence) => { @@ -2209,7 +2327,7 @@ describe("TransactionModal, emergency actions", () => { }) it("sends an unknown outcome to the Emergency tab", () => { - renderEmergencyModal("raise-ripcord", { + renderEmergencyModal("pause-nav", { status: "failed", failure: { kind: "unknown" }, }) @@ -2249,6 +2367,11 @@ describe("TransactionModal, configuration actions", () => { "Preparing to set the wind-down delay", "Wind-down delay updated", ], + [ + "set-min-epoch-duration", + "Preparing to set the minimum epoch duration", + "Minimum epoch duration updated", + ], [ "set-oracle-config", "Preparing to set the oracle limits", @@ -2306,12 +2429,103 @@ describe("TransactionModal, configuration actions", () => { ["set-custodian", "Treasury deploys to the new address from now on."], ["set-notice", "Applies to epochs closed from now on."], ["set-wind-down-delay", "A proposed wind-down keeps its activation time."], + [ + "set-min-epoch-duration", + "Anyone may seal an epoch once it has been open this long.", + ], ])("names the side effect of %s once recorded", (action, effect) => { renderConfigurationModal(action, { status: "confirmed" }, "3d") expect(screen.getByText(effect)).toBeTruthy() }) + it.each<[ConfigurationModalAction, string, string, string, string]>([ + [ + "propose-custodian", + "GABC...WXYZ", + "Preparing to propose a custodian", + "Signing queues custodian GABC...WXYZ.", + "Custodian GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-notice", + "12h", + "Preparing to propose a notice", + "Signing queues notice 12h.", + "Notice 12h applies once the upgrade delay passes.", + ], + [ + "propose-wind-down-delay", + "8d", + "Preparing to propose a wind-down delay", + "Signing queues wind-down delay 8d.", + "Wind-down delay 8d applies once the upgrade delay passes.", + ], + [ + "propose-min-epoch-duration", + "7d", + "Preparing to propose a minimum epoch duration", + "Signing queues minimum epoch duration 7d.", + "Minimum epoch duration 7d applies once the upgrade delay passes.", + ], + [ + "propose-compliance", + "CABC...WXYZ", + "Preparing to propose a compliance contract", + "Signing queues compliance contract CABC...WXYZ.", + "Compliance contract CABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-identity-verifier", + "CABC...WXYZ", + "Preparing to propose an identity verifier", + "Signing queues identity verifier CABC...WXYZ.", + "Identity verifier CABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-oracle-config", + "", + "Preparing to propose oracle limits", + "Signing queues every new oracle limit.", + "The new oracle limits apply once the vault's upgrade delay passes.", + ], + ])( + "walks %s from preparing to proposed", + (action, value, preparing, signing, doneBody) => { + const { rerender } = renderConfigurationModal( + action, + { status: "preparing" }, + value, + ) + expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() + + rerender( + , + ) + expect(screen.getByText(signing)).toBeTruthy() + + rerender( + , + ) + expect( + screen.getByRole("heading", { name: "Change proposed" }), + ).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + it.each<[ConfigurationModalAction, number, string, RegExp]>([ ["set-deposit-cap", 6007, "vault", /The deposit cap cannot be negative/], ["set-notice", 6045, "vault", /The notice cannot exceed 30 days/], @@ -2322,6 +2536,28 @@ describe("TransactionModal, configuration actions", () => { "vault", /The wind-down delay cannot exceed 90 days/, ], + [ + "set-wind-down-delay", + 6069, + "vault", + /The wind-down delay cannot be under 7 days/, + ], + [ + "set-min-epoch-duration", + 6007, + "vault", + /The minimum epoch duration must be 1 to 30 days/, + ], + [ + "propose-min-epoch-duration", + 6007, + "vault", + /The minimum epoch duration must be 1 to 30 days/, + ], + ["propose-custodian", 6075, "vault", /custodian cannot be the vault/], + ["propose-notice", 6064, "vault", /Setup is still open/], + ["set-notice", 6065, "vault", /goes through the timelock/], + ["propose-custodian", 6063, "vault", /16 pending changes/], [ "set-oracle-config", 3001, @@ -2329,6 +2565,10 @@ describe("TransactionModal, configuration actions", () => { /One of the oracle limits is out of range/, ], ["set-compliance", 2000, "share token", /does not hold the role/], + ["propose-compliance", 7003, "share token", /16 pending changes/], + ["propose-identity-verifier", 7004, "share token", /Setup is still open/], + ["set-compliance", 7005, "share token", /goes through the timelock/], + ["propose-compliance", 7008, "share token", /vault is winding down/], ])( "names the reason %s was refused with code %i", (action, code, refuser, sentence) => { @@ -2420,16 +2660,68 @@ describe("TransactionModal, governance actions", () => { ["hand-over-oracle-admin", 2201, "oracle", /expiry is outside/], ["cancel-token-handover", 2202, "share token", /different address/], ["accept-vault-admin", 2203, "vault", /expired before it was accepted/], - ["apply-upgrade", 1000, "vault", /The vault is paused/], + [ + "accept-vault-admin", + 6046, + "vault", + /admin role can no longer change hands/, + ], + [ + "accept-oracle-admin", + 3018, + "oracle", + /admin role can no longer change hands/, + ], + [ + "accept-token-admin", + 7008, + "share token", + /admin role can no longer change hands/, + ], + ["apply-change", 1000, "vault", /The vault is paused/], + ["apply-change", 6046, "vault", /winding down/], ["propose-wind-down", 6046, "vault", /wind-down is already active/], ["cancel-wind-down", 6047, "vault", /No wind-down is proposed/], ["propose-wind-down", 6048, "vault", /wind-down is already proposed/], - ["propose-code-upgrade", 6055, "vault", /upgrade is already pending/], - ["cancel-upgrade", 6056, "vault", /No upgrade is pending/], - ["apply-upgrade", 6057, "vault", /upgrade delay has not passed/], + ["propose-code-upgrade", 6055, "vault", /code change is already pending/], + ["cancel-change", 6056, "vault", /no pending change with that id/], + ["apply-change", 6057, "vault", /delay has not passed yet/], + ["propose-grant-role", 6063, "vault", /16 pending changes/], + ["propose-admin", 6064, "vault", /Setup is still open/], + ["grant-vault-role", 6065, "vault", /goes through the timelock/], ["propose-upgrade-delay", 6058, "vault", /cannot be under 7 days/], ["propose-upgrade-delay", 6059, "vault", /cannot be under the notice/], ["propose-upgrade-delay", 6060, "vault", /cannot exceed 90 days/], + [ + "cancel-token-change", + 7001, + "share token", + /no pending change with that id/, + ], + ["apply-token-change", 7002, "share token", /delay has not passed yet/], + ["propose-token-grant-role", 7003, "share token", /16 pending changes/], + ["propose-token-admin", 7004, "share token", /Setup is still open/], + ["grant-token-role", 7005, "share token", /goes through the timelock/], + ["apply-token-change", 7008, "share token", /vault is winding down/], + ["apply-token-change", 7009, "share token", /vault is paused/], + ["apply-token-change", 7011, "share token", /upgrade is already pending/], + [ + "hand-over-token-admin", + 7016, + "share token", + /hand-over is still pending/, + ], + ["cancel-oracle-change", 3011, "oracle", /no pending change with that id/], + ["apply-oracle-change", 3012, "oracle", /delay has not passed yet/], + ["propose-oracle-grant-role", 3013, "oracle", /16 pending changes/], + ["propose-oracle-admin", 3014, "oracle", /Setup is still open/], + ["grant-oracle-role", 3015, "oracle", /goes through the timelock/], + ["apply-oracle-change", 3016, "oracle", /setup has already finished/], + ["apply-oracle-change", 3017, "oracle", /not bound to a vault/], + ["apply-oracle-change", 3018, "oracle", /vault is winding down/], + ["apply-oracle-change", 3019, "oracle", /holds every oracle change/], + ["apply-oracle-change", 3020, "oracle", /shorter than the vault's notice/], + ["propose-oracle-upgrade", 3021, "oracle", /upgrade is already pending/], ])( "names the reason %s was refused with code %i", (action, code, refuser, sentence) => { @@ -2495,18 +2787,109 @@ describe("TransactionModal, governance actions", () => { "Code abab...abab can apply once the upgrade delay passes.", ], [ - "apply-upgrade", - "Delay 14d", - "Signing applies Delay 14d.", - "Upgrade applied", - "Delay 14d is in effect.", + "apply-change", + "vault change #3: Upgrade delay 14d", + "Signing applies vault change #3: Upgrade delay 14d.", + "Change applied", + "Applied vault change #3: Upgrade delay 14d.", + ], + [ + "cancel-change", + "vault change #2: Code upgrade abab...abab", + "Signing withdraws vault change #2: Code upgrade abab...abab.", + "Change cancelled", + "Withdrew vault change #2: Code upgrade abab...abab.", + ], + [ + "propose-grant-role", + "Manager to GABC...WXYZ", + "Signing queues the grant of Manager to GABC...WXYZ.", + "Change proposed", + "The grant of Manager to GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-revoke-role", + "Manager from GABC...WXYZ", + "Signing queues the revoke of Manager from GABC...WXYZ.", + "Change proposed", + "The revoke of Manager from GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-admin", + "GABC...WXYZ", + "Signing queues the hand-over to GABC...WXYZ.", + "Change proposed", + "The hand-over to GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-token-grant-role", + "Manager to GABC...WXYZ", + "Signing queues the grant of Manager to GABC...WXYZ.", + "Change proposed", + "The grant of Manager to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-token-revoke-role", + "Manager from GABC...WXYZ", + "Signing queues the revoke of Manager from GABC...WXYZ.", + "Change proposed", + "The revoke of Manager from GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-token-admin", + "GABC...WXYZ", + "Signing queues the hand-over to GABC...WXYZ.", + "Change proposed", + "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "apply-token-change", + "share token change #1: Compliance contract CABC...WXYZ", + "Signing applies share token change #1: Compliance contract CABC...WXYZ.", + "Change applied", + "Applied share token change #1: Compliance contract CABC...WXYZ.", ], [ - "cancel-upgrade", + "cancel-token-change", + "share token change #1: Compliance contract CABC...WXYZ", + "Signing withdraws share token change #1: Compliance contract CABC...WXYZ.", + "Change cancelled", + "Withdrew share token change #1: Compliance contract CABC...WXYZ.", + ], + [ + "propose-oracle-grant-role", + "Attester to GABC...WXYZ", + "Signing queues the grant of Attester to GABC...WXYZ.", + "Change proposed", + "The grant of Attester to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-oracle-admin", + "GABC...WXYZ", + "Signing queues the hand-over to GABC...WXYZ.", + "Change proposed", + "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-oracle-upgrade", "Code abab...abab", - "Signing withdraws Code abab...abab.", - "Upgrade cancelled", - "Code abab...abab is withdrawn.", + "Signing queues Code abab...abab.", + "Upgrade proposed", + "Code abab...abab can apply once the vault's upgrade delay passes.", + ], + [ + "apply-oracle-change", + "oracle change #1: Admin hand-over GABC...WXYZ", + "Signing applies oracle change #1: Admin hand-over GABC...WXYZ.", + "Change applied", + "Applied oracle change #1: Admin hand-over GABC...WXYZ.", + ], + [ + "cancel-oracle-change", + "oracle change #1: Admin hand-over GABC...WXYZ", + "Signing withdraws oracle change #1: Admin hand-over GABC...WXYZ.", + "Change cancelled", + "Withdrew oracle change #1: Admin hand-over GABC...WXYZ.", ], [ "propose-wind-down", @@ -2523,7 +2906,7 @@ describe("TransactionModal, governance actions", () => { "The vault stays open.", ], ])( - "names the vault proposal step of %s", + "names the timelock step of %s", (action, value, signing, done, doneBody) => { const { unmount } = renderGovernanceModal( action, @@ -2583,11 +2966,11 @@ describe("TransactionModal, compliance actions", () => { "Froze 5.00 shares of GABC...WXYZ.", ], [ - "force-transfer", - "5.00 shares from GABC...WXYZ to CVAU...LTID", - "Signing moves 5.00 shares from GABC...WXYZ to CVAU...LTID.", - "Transfer forced", - "Moved 5.00 shares from GABC...WXYZ to CVAU...LTID.", + "legal-transfer", + "5.00 shares from GABC...WXYZ to GDEF...UVWX", + "Signing approves moving 5.00 shares from GABC...WXYZ to GDEF...UVWX.", + "Legal transfer made", + "Moved 5.00 shares from GABC...WXYZ to GDEF...UVWX.", ], ])("names what %s changes", (action, value, signing, done, doneBody) => { const { unmount } = renderComplianceModal( @@ -2610,6 +2993,21 @@ describe("TransactionModal, compliance actions", () => { ["freeze-address", 2000, "share token", /does not hold the role/], ["allow-address", 2000, "identity verifier", /does not hold the role/], ["disallow-address", 2001, "identity verifier", /verifier has no admin/], + [ + "legal-transfer", + 7013, + "share token", + /compliance account and the token admin must be two different accounts/, + ], + ["legal-transfer", 7015, "share token", /reference of 1 to 128 bytes/], + [ + "legal-transfer", + 7012, + "share token", + /never moves shares into or out of the vault/, + ], + ["legal-transfer", 304, "share token", /destination is not allowlisted/], + ["legal-transfer", 300, "share token", /does not hold enough shares/], ])( "names the reason %s was refused with code %i", (action, code, refuser, sentence) => { @@ -2627,6 +3025,45 @@ describe("TransactionModal, compliance actions", () => { }, ) + it("asks for the token admin's authorization before compliance signs", () => { + renderComplianceModal("legal-transfer", { + status: "shared", + xdr: "AAAAXDR", + standing: "admin-authorization", + expiresAt: null, + }) + + expect( + screen.getByRole("heading", { + name: "Needs the token admin's authorization", + }), + ).toBeTruthy() + expect( + screen.getByText( + "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", + ), + ).toBeTruthy() + expect( + screen.getByRole("textbox", { name: "Signing link" }) + .value, + ).toBe(`${window.location.origin}/sign#AAAAXDR`) + }) + + it("names the wallets that can sign an authorization", () => { + renderComplianceModal("legal-transfer", { + status: "failed", + failure: { kind: "cannot-authorize" }, + }) + + expect( + screen.getByText( + "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", + ), + ).toBeTruthy() + expect(screen.queryByText("You declined the request")).toBeNull() + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull() + }) + it("sends an unknown outcome to the Compliance tab", () => { renderComplianceModal("unfreeze-address", { status: "failed", @@ -2638,3 +3075,226 @@ describe("TransactionModal, compliance actions", () => { ).toBeTruthy() }) }) + +describe("TransactionModal, allowlist and freeze refusals", () => { + const contractError = (code: number) => ({ + status: "failed" as const, + failure: { kind: "contract-error" as const, code }, + }) + + it("names NotAllowlisted on a subscription as an address that cannot subscribe", () => { + renderModal(contractError(6068)) + + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + expect(screen.getByText(/can subscribe/)).toBeTruthy() + }) + + it("names Frozen on a subscription as a frozen address", () => { + renderModal(contractError(6071)) + + expect(screen.getByText(/frozen/)).toBeTruthy() + expect(screen.getByText(/cannot subscribe/)).toBeTruthy() + }) + + it("names Frozen on a redemption request as a frozen address or frozen shares", () => { + renderRedeemModal(contractError(6071)) + + expect(screen.getByText(/shares are frozen/)).toBeTruthy() + expect(screen.getByText(/free shares/)).toBeTruthy() + }) + + it.each<[string, () => void]>([ + ["cancelling a subscription", () => renderCancelModal(contractError(6071))], + [ + "cancelling a redemption", + () => renderCancelRedeemModal(contractError(6071)), + ], + ["claiming shares", () => renderClaimModal(contractError(6071))], + [ + "claiming a redemption", + () => renderClaimRedeemModal(contractError(6071)), + ], + ])("names Frozen on %s as held until the freeze lifts", (_name, render) => { + render() + + expect(screen.getByText(/held until the freeze is lifted/)).toBeTruthy() + }) + + it("falls back to a generic refusal for NotAllowlisted on a subscription cancellation, since a delisted holder may still cancel", () => { + renderCancelModal(contractError(6068)) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) +}) + +describe("TransactionModal, conversion and surrender outcomes", () => { + it("tells a delisted investor their claimed shares were queued as a redemption", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 0n as Amount, + queuedInto: 5n, + }) + + expect( + screen.getByRole("heading", { name: "Queued as a redemption" }), + ).toBeTruthy() + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + expect(screen.getByText(/pay out in USDC at the next price/)).toBeTruthy() + expect(screen.queryByText(/returned to your wallet/)).toBeNull() + }) + + it("tells a delisted investor in a wind-down their claimed shares joined the wind-down claim", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 0n as Amount, + queuedInto: 0n, + }) + + expect( + screen.getByRole("heading", { name: "Moved to your wind-down claim" }), + ).toBeTruthy() + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + }) + + it("still reads a zero-share claim without a conversion as a returned deposit", () => { + renderClaimModal({ status: "confirmed", sharesMinted: 0n as Amount }) + + expect( + screen.getByRole("heading", { name: "Deposit returned" }), + ).toBeTruthy() + }) + + it("tells the investor a cancelled redemption moved into the wind-down claim", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 100_0000000n as Amount, + surrendered: true, + }) + + expect( + screen.getByRole("heading", { name: "Moved to your wind-down claim" }), + ).toBeTruthy() + expect(screen.getByText(/winding down/)).toBeTruthy() + expect(screen.queryByText(/returned to your wallet/)).toBeNull() + }) + + it("still reads a returned redemption as cancelled", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 100_0000000n as Amount, + surrendered: false, + }) + + expect( + screen.getByRole("heading", { name: "Request cancelled" }), + ).toBeTruthy() + }) +}) + +describe("TransactionModal, wind-down outcomes", () => { + it("tells the investor a zero-price claim burned the shares during a wind-down, never that they came back", () => { + renderClaimRedeemModal({ + status: "confirmed", + assetsClaimed: 0n as Amount, + sharesReturned: false, + }) + + expect( + screen.getByRole("heading", { name: "Nothing to pay out" }), + ).toBeTruthy() + expect(screen.getByText(/winding down/)).toBeTruthy() + expect(screen.getByText(/burned/)).toBeTruthy() + expect(screen.queryByText(/returned to your wallet/)).toBeNull() + }) +}) + +describe("TransactionModal, cancelling a redemption in a wind-down", () => { + it("tells the signer the shares move into the wind-down claim, never that they return", () => { + render( + , + ) + + expect(screen.getByText(/wind-down claim/)).toBeTruthy() + expect(screen.queryByText(/returns/)).toBeNull() + }) +}) + +const renderClaimWindDownModal = (status: ClaimWindDownStatus) => + render( + , + ) + +describe("TransactionModal, claiming from the wind-down", () => { + it("tells the signer what they receive and that their shares are turned in", () => { + renderClaimWindDownModal({ status: "awaiting-signature" }) + + expect(screen.getByText(/25\.00 USDC/)).toBeTruthy() + expect(screen.getByText(/turns in your unfrozen vUSDC/)).toBeTruthy() + }) + + it("shows the assets paid and the shares turned in once confirmed", () => { + renderClaimWindDownModal({ + status: "confirmed", + assetsClaimed: 25_0000000n as Amount, + sharesSurrendered: 40_0000000n as Amount, + }) + + expect(screen.getByRole("heading", { name: "USDC claimed" })).toBeTruthy() + expect(screen.getByText(/25\.00 USDC has been added/)).toBeTruthy() + expect(screen.getByText(/40\.00 vUSDC/)).toBeTruthy() + }) + + it("reads a claim that paid nothing as shares turned in for later rounds", () => { + renderClaimWindDownModal({ + status: "confirmed", + assetsClaimed: 0n as Amount, + sharesSurrendered: 40_0000000n as Amount, + }) + + expect( + screen.getByRole("heading", { name: "Shares turned in" }), + ).toBeTruthy() + expect(screen.getByText(/later rounds/)).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6051, /first payout/], + [6054, /nothing to claim from the wind-down/], + [6071, /held until the freeze is lifted/], + ])("names refusal %i in the dapp's words", (code, text) => { + renderClaimWindDownModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect(screen.getByText(text)).toBeTruthy() + }) + + it("falls back to a generic refusal for a wind-down claim error it does not recognize", () => { + renderClaimWindDownModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 98ec47ff..e017090d 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -15,6 +15,7 @@ import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" +import { type ClaimWindDownStatus } from "../../hooks/useClaimWindDown" import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" import { type TransactionFailure, @@ -38,8 +39,8 @@ import styles from "./TransactionModal.module.css" export type EmergencyModalAction = | "pause-vault" | "unpause-vault" - | "raise-ripcord" - | "lower-ripcord" + | "pause-nav" + | "unpause-nav" | "clear-latest" | "pause-token" | "unpause-token" @@ -49,11 +50,20 @@ export type ConfigurationModalAction = | "set-custodian" | "set-notice" | "set-wind-down-delay" + | "set-min-epoch-duration" + | "propose-custodian" + | "propose-notice" + | "propose-wind-down-delay" + | "propose-min-epoch-duration" | "set-oracle-config" + | "propose-oracle-config" | "set-compliance" + | "propose-compliance" | "set-identity-verifier" + | "propose-identity-verifier" -export type GovernanceModalAction = GovernanceActionId +export type GovernanceModalAction = + GovernanceActionId | "propose-oracle-revoke-role" export type ComplianceModalAction = ComplianceActionId @@ -100,6 +110,7 @@ export type TransactionModalProps = amount: string ticker: string assetTicker: string + windingDown?: boolean onClose: () => void onRetry: () => void } @@ -129,6 +140,15 @@ export type TransactionModalProps = onClose: () => void onRetry: () => void } + | { + action: "claim-wind-down" + status: ClaimWindDownStatus + amount: string + ticker: string + shareTicker: string + onClose: () => void + onRetry: () => void + } | { action: "close-epoch" status: CloseEpochStatus @@ -182,6 +202,15 @@ export type TransactionModalProps = const GENERIC_REFUSAL = "The vault refused this request. Try again, and contact support if it keeps happening." +export const NOT_ALLOWLISTED = "Your address is not allowlisted." + +const SHARES_NOT_RETURNABLE = + "Your address cannot receive these shares right now, so they stay with the vault." + +const FROZEN = "Your address is frozen." + +export const HELD_WHILE_FROZEN = `${FROZEN} This request is held until the freeze is lifted.` + const subscribeContractErrorReason = (code: number): string => { switch (code) { case 6007: @@ -192,6 +221,16 @@ const subscribeContractErrorReason = (code: number): string => { return "That amount is too large for the vault to accept." case 6046: return "The vault is winding down and is not accepting new subscriptions." + case 6064: + return "The vault's setup is still open. Subscriptions start once it closes." + case 6044: + return "The vault's price feed is not valid right now, so subscriptions are paused until it is." + case 6076: + return "The vault does not hold enough to cover its pending deposits right now, so it is not taking new subscriptions until it is funded again." + case 6068: + return `${NOT_ALLOWLISTED} Only allowlisted addresses can subscribe.` + case 6071: + return `${FROZEN} It cannot subscribe until the freeze is lifted.` default: return GENERIC_REFUSAL } @@ -207,6 +246,10 @@ const cancelContractErrorReason = (code: number): string => { return "This request has already been priced. Claim your shares instead of cancelling." case 6041: return "A price is available, so this request can no longer be cancelled. It will be priced shortly, and your shares are claimable once it is." + case 6076: + return "The vault does not hold enough to return this deposit right now. You can cancel once it is funded again." + case 6071: + return HELD_WHILE_FROZEN default: return GENERIC_REFUSAL } @@ -216,10 +259,15 @@ const cancelRedeemContractErrorReason = ( code: number, assetTicker: string, ): string => { - if (code === 304) { - return `Your address is no longer allowlisted, so these shares cannot be returned to you. Once priced, claim the ${assetTicker} it owes you instead.` + const claimInstead = `Once priced, claim the ${assetTicker} it owes you instead.` + switch (code) { + case 6068: + return `${NOT_ALLOWLISTED} These shares cannot come back to you. ${claimInstead}` + case 6073: + return `${SHARES_NOT_RETURNABLE} ${claimInstead}` + default: + return cancelContractErrorReason(code) } - return cancelContractErrorReason(code) } const claimContractErrorReason = (code: number): string => { @@ -236,8 +284,8 @@ const claimContractErrorReason = (code: number): string => { return "This request has already been claimed." case 6037: return "The vault does not yet hold enough in reserve to cover this claim. Try again shortly." - case 304: - return "Your address is no longer allowlisted, so it cannot receive shares." + case 6071: + return HELD_WHILE_FROZEN default: return GENERIC_REFUSAL } @@ -255,6 +303,10 @@ const redeemContractErrorReason = (code: number): string => { return "This request could not be found." case 6046: return "The vault is winding down and is not accepting new redemptions." + case 6064: + return "The vault's setup is still open. Redemptions start once it closes." + case 6071: + return "Your address or some of these shares are frozen. Redeem only your free shares, or wait until the freeze is lifted." default: return GENERIC_REFUSAL } @@ -272,13 +324,26 @@ const claimRedeemContractErrorReason = ( return uncovered !== undefined ? `The vault is ${uncovered} short of covering every priced claim. Try again once the reserve catches up.` : `The vault does not yet hold enough ${ticker} in reserve to cover this claim. Try again shortly.` - case 304: - return "The price left nothing to claim, so the vault tried to return your shares instead. Your address is no longer allowlisted to receive them." + case 6073: + return `The price left nothing to pay out, so the vault tried to return your shares. ${SHARES_NOT_RETURNABLE}` default: return claimContractErrorReason(code) } } +const windDownClaimContractErrorReason = (code: number): string => { + switch (code) { + case 6051: + return "The wind-down has not made its first payout yet. Check back once it does." + case 6054: + return "You have nothing to claim from the wind-down right now." + case 6071: + return HELD_WHILE_FROZEN + default: + return GENERIC_REFUSAL + } +} + const closeEpochContractErrorReason = (code: number): string => { switch (code) { case 6046: @@ -291,6 +356,10 @@ const closeEpochContractErrorReason = (code: number): string => { return "This wallet is not authorized to close epochs." case 2007: return "This wallet does not hold the manager role." + case 6077: + return "Only the manager can seal this epoch until it has been open for the minimum duration." + case 6064: + return "The vault's setup is still open, so no epoch can be sealed yet." default: return GENERIC_REFUSAL } @@ -316,6 +385,8 @@ const fulfillEpochContractErrorReason = (code: number): string => { return "The settlement total is too large for the vault to record." case 1000: return "The vault is paused, so epochs cannot be priced." + case 6064: + return "The vault's setup is still open, so no epoch can be priced yet." default: return GENERIC_REFUSAL } @@ -331,6 +402,12 @@ const attestContractErrorReason = (code: number): string => { return "The cooldown since the last attestation has not elapsed yet." case 3004: return "That price moves further from the last attested price than the oracle allows." + case 3010: + return "Unpause the NAV first." + case 3023: + return "The proof hash cannot be all zeros." + case 3024: + return "The proof URI is longer than 256 bytes." default: return GENERIC_REFUSAL } @@ -442,6 +519,11 @@ const describeFailure = ( heading: "The wallet did not sign", body: "Nothing was sent. Try again when you're ready.", } + case "cannot-authorize": + return { + heading: "This wallet cannot authorize", + body: "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", + } case "contract-error": return { heading: `The ${refuser} refused this request`, @@ -452,6 +534,11 @@ const describeFailure = ( heading: "We couldn't reach the vault", body: "Nothing was requested from your wallet. Try again when you're ready.", } + case "stale": + return { + heading: "The transaction went stale", + body: "The account moved on since it was prepared, so nothing was sent. Try again.", + } case "unknown": return unknownOutcome(followUp) } @@ -545,6 +632,7 @@ const describeCancelRedeemStatus = ( amount: string, ticker: string, assetTicker: string, + windingDown: boolean, ): { heading: string; body: string; hash?: string } | undefined => { switch (status.status) { case "idle": @@ -557,7 +645,9 @@ const describeCancelRedeemStatus = ( case "awaiting-signature": return { heading: "Confirm in your wallet", - body: `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, + body: windingDown + ? `Your request is withdrawn, not priced. The vault is winding down, so signing moves ${amount} ${ticker} into your wind-down claim instead of your wallet.` + : `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, } case "submitted": return { @@ -566,11 +656,17 @@ const describeCancelRedeemStatus = ( hash: status.hash, } case "confirmed": - return { - heading: "Request cancelled", - body: `${formatExact(status.returnedShares, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, - hash: status.hash, - } + return status.surrendered + ? { + heading: MOVED_TO_WIND_DOWN, + body: "The vault is winding down, so these shares were added to your wind-down claim instead of coming back to your wallet.", + hash: status.hash, + } + : { + heading: "Request cancelled", + body: `${formatExact(status.returnedShares, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, + hash: status.hash, + } case "failed": return { ...describeFailure(status.failure, (code) => @@ -581,6 +677,22 @@ const describeCancelRedeemStatus = ( } } +const MOVED_TO_WIND_DOWN = "Moved to your wind-down claim" + +const convertedOutcome = ( + queuedInto: bigint, + ticker: string, +): { heading: string; body: string } => + queuedInto === 0n + ? { + heading: MOVED_TO_WIND_DOWN, + body: `${NOT_ALLOWLISTED} Your shares were added to your wind-down claim instead.`, + } + : { + heading: "Queued as a redemption", + body: `${NOT_ALLOWLISTED} Your shares were queued as a redemption instead, and pay out in ${ticker} at the next price.`, + } + const describeClaimStatus = ( status: ClaimDepositStatus, amount: string, @@ -607,6 +719,11 @@ const describeClaimStatus = ( hash: status.hash, } case "confirmed": + if (status.queuedInto !== undefined) + return { + ...convertedOutcome(status.queuedInto, ticker), + hash: status.hash, + } return status.sharesMinted > 0n ? { heading: "Shares claimed", @@ -626,6 +743,67 @@ const describeClaimStatus = ( } } +const describeClaimWindDownStatus = ( + status: ClaimWindDownStatus, + amount: string, + ticker: string, + shareTicker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your claim", + body: "We are getting your claim ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `You receive ${amount} ${ticker}, your share of the wind-down so far. Signing also turns in your unfrozen ${shareTicker}, so later rounds pay you for them.`, + } + case "submitted": + return { + heading: "Sending your claim", + body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + ...windDownClaimOutcome(status, ticker, shareTicker), + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, windDownClaimContractErrorReason), + hash: status.hash, + } + } +} + +const windDownClaimOutcome = ( + { + assetsClaimed, + sharesSurrendered, + }: Extract, + ticker: string, + shareTicker: string, +): { heading: string; body: string } => { + const turnedIn = + sharesSurrendered > 0n + ? `${formatExact(sharesSurrendered, AMOUNT_DECIMALS)} ${shareTicker} were turned in, and later rounds pay you for them.` + : "Later rounds pay what you are owed." + return assetsClaimed > 0n + ? { + heading: `${ticker} claimed`, + body: `${formatExact(assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet. ${turnedIn}`, + } + : { + heading: "Shares turned in", + body: `Nothing was paid this time. ${turnedIn}`, + } +} + const describeRedeemStatus = ( status: RequestRedeemStatus, amount: string, @@ -696,11 +874,17 @@ const describeClaimRedeemStatus = ( body: `${formatExact(status.assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet.`, hash: status.hash, } - : { - heading: "Shares returned", - body: "The price left nothing to claim, so your shares have been returned to your wallet instead.", - hash: status.hash, - } + : status.sharesReturned + ? { + heading: "Shares returned", + body: "The price left nothing to claim, so your shares have been returned to your wallet instead.", + hash: status.hash, + } + : { + heading: "Nothing to pay out", + body: "The price left nothing to pay out, and the vault is winding down, so these shares were burned.", + hash: status.hash, + } case "failed": return { ...describeFailure(status.failure, (code) => @@ -809,7 +993,7 @@ const describeAttestStatus = ( case "awaiting-signature": return { heading: "Confirm in your wallet", - body: `Signing records ${price} as the share price. Sealed epochs settle at it once their notice elapses.`, + body: `Signing records ${price} as the share price, with its proof. Sealed epochs settle at it once their notice elapses.`, } case "submitted": return { @@ -1026,19 +1210,19 @@ const EMERGENCY_COPY: Record = { done: "Vault unpaused", doneBody: "Deposits, pricing and upgrades are open again.", }, - "raise-ripcord": { + "pause-nav": { refuser: "oracle", - verb: "raise the ripcord", - signing: "Signing stops pricing until the ripcord is lowered.", - done: "Ripcord raised", + verb: "pause the NAV", + signing: "Signing stops pricing until the NAV is unpaused.", + done: "NAV paused", doneBody: - "Pricing is stopped. No epoch can be fulfilled until the ripcord is lowered.", + "Pricing is stopped. No epoch can be fulfilled until the NAV is unpaused.", }, - "lower-ripcord": { + "unpause-nav": { refuser: "oracle", - verb: "lower the ripcord", + verb: "unpause the NAV", signing: "Signing resumes pricing.", - done: "Ripcord lowered", + done: "NAV unpaused", doneBody: "Epochs can be fulfilled again while the price is valid.", }, "clear-latest": { @@ -1047,22 +1231,22 @@ const EMERGENCY_COPY: Record = { signing: "Signing removes the recorded price.", done: "Latest price cleared", doneBody: - "The next attestation is not limited by its distance from the last. Pricing stays stopped until the ripcord is lowered.", + "The next attestation is not limited by its distance from the last. Pricing stays stopped until the NAV is unpaused.", }, "pause-token": { refuser: "share token", verb: "pause the share token", - signing: "Signing stops holder transfers and returns of escrowed shares.", + signing: "Signing stops holder transfers only.", done: "Share token paused", doneBody: - "Holder transfers and returns of escrowed shares are stopped. Minting, burning and forced transfers stay open.", + "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", }, "unpause-token": { refuser: "share token", verb: "unpause the share token", - signing: "Signing resumes holder transfers and returns of escrowed shares.", + signing: "Signing resumes holder transfers.", done: "Share token unpaused", - doneBody: "Holder transfers and returns of escrowed shares are open again.", + doneBody: "Holder transfers are open again.", }, } @@ -1076,6 +1260,34 @@ const setterCopy = doneBody: effect ?? `${subject}: ${value}.`, }) +const capitalize = (text: string): string => + `${text.charAt(0).toUpperCase()}${text.slice(1)}` + +type TimelockRefuser = Extract< + AdminCopy["refuser"], + "vault" | "share token" | "oracle" +> + +const WAITS: Record = { + vault: "the upgrade delay", + "share token": "the vault's upgrade delay", + oracle: "the vault's upgrade delay", +} + +const proposeCopy = + (refuser: TimelockRefuser, verb: string, subject: string) => + (value: string): AdminCopy => ({ + refuser, + verb, + signing: `Signing queues ${subject} ${value}.`, + done: "Change proposed", + doneBody: `${capitalize(subject)} ${value} applies once ${WAITS[refuser]} passes.`, + }) + +const MIN_EPOCH_DURATION_REASONS = { + 6007: "The minimum epoch duration must be 1 to 30 days.", +} + const CONFIGURATION_COPY: Record< ConfigurationModalAction, (value: string) => AdminCopy @@ -1088,6 +1300,29 @@ const CONFIGURATION_COPY: Record< "Wind-down delay", EFFECTS.windDownDelay, ), + "set-min-epoch-duration": (value) => ({ + ...setterCopy( + "vault", + "Minimum epoch duration", + EFFECTS.minEpochDuration, + )(value), + reasons: MIN_EPOCH_DURATION_REASONS, + }), + "propose-custodian": proposeCopy("vault", "propose a custodian", "custodian"), + "propose-notice": proposeCopy("vault", "propose a notice", "notice"), + "propose-wind-down-delay": proposeCopy( + "vault", + "propose a wind-down delay", + "wind-down delay", + ), + "propose-min-epoch-duration": (value) => ({ + ...proposeCopy( + "vault", + "propose a minimum epoch duration", + "minimum epoch duration", + )(value), + reasons: MIN_EPOCH_DURATION_REASONS, + }), "set-oracle-config": () => ({ refuser: "oracle", verb: "set the oracle limits", @@ -1095,8 +1330,25 @@ const CONFIGURATION_COPY: Record< done: "Oracle limits updated", doneBody: "The new limits are recorded.", }), + "propose-oracle-config": () => ({ + refuser: "oracle", + verb: "propose oracle limits", + signing: "Signing queues every new oracle limit.", + done: "Change proposed", + doneBody: `The new oracle limits apply once ${WAITS.oracle} passes.`, + }), "set-compliance": setterCopy("share token", "Compliance contract"), + "propose-compliance": proposeCopy( + "share token", + "propose a compliance contract", + "compliance contract", + ), "set-identity-verifier": setterCopy("share token", "Identity verifier"), + "propose-identity-verifier": proposeCopy( + "share token", + "propose an identity verifier", + "identity verifier", + ), } const grantCopy = @@ -1139,38 +1391,59 @@ const cancelHandoverCopy = doneBody: `${value} can no longer accept.`, }) -const acceptAdminCopy = (refuser: AdminCopy["refuser"]) => (): AdminCopy => ({ +const ACCEPT_WIND_DOWN_CODES: Record = { + vault: 6046, + oracle: 3018, + "share token": 7008, +} + +const acceptAdminCopy = (refuser: TimelockRefuser) => (): AdminCopy => ({ refuser, + reasons: { + [ACCEPT_WIND_DOWN_CODES[refuser]]: + "The vault is winding down, so the admin role can no longer change hands.", + }, verb: "accept the admin role", signing: `Signing makes this wallet the ${refuser} admin.`, done: "Admin accepted", doneBody: `This wallet is now the ${refuser} admin.`, }) -const proposalCopy = (value: string): AdminCopy => ({ - refuser: "vault", - verb: "propose an upgrade", - signing: `Signing queues ${value}.`, - done: "Upgrade proposed", - doneBody: `${value} can apply once the upgrade delay passes.`, -}) +const proposalCopy = + (refuser: TimelockRefuser) => + (value: string): AdminCopy => ({ + refuser, + verb: "propose an upgrade", + signing: `Signing queues ${value}.`, + done: "Upgrade proposed", + doneBody: `${value} can apply once ${WAITS[refuser]} passes.`, + }) -const applyUpgradeCopy = (value: string): AdminCopy => ({ - refuser: "vault", - reasons: { 1000: "The vault is paused; unpause it first." }, - verb: "apply the upgrade", - signing: `Signing applies ${value}.`, - done: "Upgrade applied", - doneBody: `${value} is in effect.`, -}) +const VAULT_APPLY_REASONS = { + 1000: "The vault is paused; unpause it first.", + 6046: "The vault is winding down, so no change can apply.", +} -const cancelUpgradeCopy = (value: string): AdminCopy => ({ - refuser: "vault", - verb: "cancel the upgrade", - signing: `Signing withdraws ${value}.`, - done: "Upgrade cancelled", - doneBody: `${value} is withdrawn.`, -}) +const applyChangeCopy = + (refuser: TimelockRefuser) => + (value: string): AdminCopy => ({ + refuser, + ...(refuser === "vault" ? { reasons: VAULT_APPLY_REASONS } : {}), + verb: "apply the change", + signing: `Signing applies ${value}.`, + done: "Change applied", + doneBody: `Applied ${value}.`, + }) + +const cancelChangeCopy = + (refuser: TimelockRefuser) => + (value: string): AdminCopy => ({ + refuser, + verb: "cancel the change", + signing: `Signing withdraws ${value}.`, + done: "Change cancelled", + doneBody: `Withdrew ${value}.`, + }) const proposeWindDownCopy = (value: string): AdminCopy => ({ refuser: "vault", @@ -1207,14 +1480,71 @@ const GOVERNANCE_COPY: Record< "hand-over-token-admin": handOverCopy("share token"), "cancel-token-handover": cancelHandoverCopy("share token"), "accept-token-admin": acceptAdminCopy("share token"), - "propose-code-upgrade": proposalCopy, - "propose-upgrade-delay": proposalCopy, - "apply-upgrade": applyUpgradeCopy, - "cancel-upgrade": cancelUpgradeCopy, + "propose-grant-role": proposeCopy( + "vault", + "propose a role grant", + "the grant of", + ), + "propose-revoke-role": proposeCopy( + "vault", + "propose a role revoke", + "the revoke of", + ), + "propose-admin": proposeCopy( + "vault", + "propose the hand-over", + "the hand-over to", + ), + "propose-token-grant-role": proposeCopy( + "share token", + "propose a role grant", + "the grant of", + ), + "propose-token-revoke-role": proposeCopy( + "share token", + "propose a role revoke", + "the revoke of", + ), + "propose-token-admin": proposeCopy( + "share token", + "propose the hand-over", + "the hand-over to", + ), + "propose-oracle-grant-role": proposeCopy( + "oracle", + "propose a role grant", + "the grant of", + ), + "propose-oracle-revoke-role": proposeCopy( + "oracle", + "propose a role revoke", + "the revoke of", + ), + "propose-oracle-admin": proposeCopy( + "oracle", + "propose the hand-over", + "the hand-over to", + ), + "propose-code-upgrade": proposalCopy("vault"), + "propose-upgrade-delay": proposalCopy("vault"), + "propose-oracle-upgrade": proposalCopy("oracle"), + "apply-change": applyChangeCopy("vault"), + "cancel-change": cancelChangeCopy("vault"), + "apply-token-change": applyChangeCopy("share token"), + "cancel-token-change": cancelChangeCopy("share token"), + "apply-oracle-change": applyChangeCopy("oracle"), + "cancel-oracle-change": cancelChangeCopy("oracle"), "propose-wind-down": proposeWindDownCopy, "cancel-wind-down": cancelWindDownCopy, } +const LEGAL_TRANSFER_REASONS: Partial> = { + 7012: "A legal transfer never moves shares into or out of the vault: they are other investors' escrow.", + 7013: "The compliance account and the token admin must be two different accounts.", + 7015: "Enter a reference of 1 to 128 bytes.", + 304: "The destination is not allowlisted, so it cannot receive shares.", +} + const COMPLIANCE_COPY: Record< ComplianceModalAction, (value: string) => AdminCopy @@ -1261,11 +1591,12 @@ const COMPLIANCE_COPY: Record< done: "Shares unfrozen", doneBody: `Unfroze ${value}.`, }), - "force-transfer": (value) => ({ + "legal-transfer": (value) => ({ refuser: "share token", - verb: "force a transfer", - signing: `Signing moves ${value}.`, - done: "Transfer forced", + reasons: LEGAL_TRANSFER_REASONS, + verb: "make a legal transfer", + signing: `Signing approves moving ${value}.`, + done: "Legal transfer made", doneBody: `Moved ${value}.`, }), } @@ -1325,21 +1656,53 @@ const adminContractErrorReason = case 6048: return "A wind-down is already proposed." case 6055: - return "An upgrade is already pending." + return "A code change is already pending; one at a time." case 6056: - return "No upgrade is pending." + return "There is no pending change with that id." case 6057: - return "The upgrade delay has not passed yet." + return "The change's delay has not passed yet." case 6058: return "The upgrade delay cannot be under 7 days." case 6059: return "The upgrade delay cannot be under the notice." case 6060: return "The upgrade delay cannot exceed 90 days." + case 6063: + return "There are already 16 pending changes; apply or cancel one first." + case 6064: + return "Setup is still open, so no change can be proposed yet." + case 6065: + return "After setup this change goes through the timelock." + case 6069: + return "The wind-down delay cannot be under 7 days." + case 6075: + return "The custodian cannot be the vault itself." case 3001: return "One of the oracle limits is out of range." case 3008: - return "The ripcord is not raised, so the latest price cannot be cleared." + return "The NAV is not paused, so the latest price cannot be cleared." + case 3011: + return "There is no pending change with that id." + case 3012: + return "The change's delay has not passed yet." + case 3013: + return "There are already 16 pending changes; apply or cancel one first." + case 3014: + return "Setup is still open, so no change can be proposed yet." + case 3015: + return "After setup this change goes through the timelock." + case 3016: + return "The oracle's setup has already finished." + case 3017: + return "The oracle is not bound to a vault yet." + case 3018: + return "The vault is winding down, so no change can be proposed or applied." + case 3019: + return "The vault is paused, which holds every oracle change; unpause it first." + case 3020: + return "The change's delay is shorter than the vault's notice now, so it cannot apply." + case 3021: + return "A code upgrade is already pending; cancel it first." case 6007: return "The deposit cap cannot be negative." case 6045: @@ -1348,6 +1711,24 @@ const adminContractErrorReason = return "The wind-down delay cannot exceed 90 days." case 6061: return "The notice cannot exceed the upgrade delay." + case 7001: + return "There is no pending change with that id." + case 7002: + return "The change's delay has not passed yet." + case 7003: + return "There are already 16 pending changes; apply or cancel one first." + case 7004: + return "Setup is still open, so no change can be proposed yet." + case 7005: + return "After setup this change goes through the timelock." + case 7008: + return "The vault is winding down, so no change can be proposed or applied." + case 7009: + return "The vault is paused, which holds every share-token change; unpause it first." + case 7011: + return "A code upgrade is already pending; cancel it first." + case 7016: + return "An admin hand-over is still pending; cancel it first." default: return `The ${refuser} refused this request. Try again, and contact support if it keeps happening.` } @@ -1375,6 +1756,11 @@ const describeAdminStatus = ( hash: status.hash, } case "shared": + if (status.standing === "admin-authorization") + return { + heading: "Needs the token admin's authorization", + body: "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", + } return { heading: describeStanding(status.standing), body: describeShared(status.expiresAt), @@ -1410,6 +1796,7 @@ const describeStatus = ( props.amount, props.ticker, props.assetTicker, + props.windingDown === true, ) case "claim": return describeClaimStatus( @@ -1427,6 +1814,13 @@ const describeStatus = ( props.ticker, props.uncovered, ) + case "claim-wind-down": + return describeClaimWindDownStatus( + props.status, + props.amount, + props.ticker, + props.shareTicker, + ) case "close-epoch": return describeCloseEpochStatus(props.status, props.epoch) case "fulfill-epoch": @@ -1443,8 +1837,8 @@ const describeStatus = ( return describeFinalizeRoundStatus(props.status, props.round) case "pause-vault": case "unpause-vault": - case "raise-ripcord": - case "lower-ripcord": + case "pause-nav": + case "unpause-nav": case "clear-latest": case "pause-token": case "unpause-token": @@ -1457,9 +1851,17 @@ const describeStatus = ( case "set-custodian": case "set-notice": case "set-wind-down-delay": + case "set-min-epoch-duration": + case "propose-custodian": + case "propose-notice": + case "propose-wind-down-delay": + case "propose-min-epoch-duration": case "set-oracle-config": + case "propose-oracle-config": case "set-compliance": + case "propose-compliance": case "set-identity-verifier": + case "propose-identity-verifier": return describeAdminStatus( CONFIGURATION_COPY[props.action](props.value), props.status, @@ -1480,10 +1882,24 @@ const describeStatus = ( case "hand-over-token-admin": case "cancel-token-handover": case "accept-token-admin": + case "propose-grant-role": + case "propose-revoke-role": + case "propose-admin": + case "propose-token-grant-role": + case "propose-token-revoke-role": + case "propose-token-admin": case "propose-code-upgrade": case "propose-upgrade-delay": - case "apply-upgrade": - case "cancel-upgrade": + case "apply-change": + case "cancel-change": + case "apply-token-change": + case "cancel-token-change": + case "propose-oracle-grant-role": + case "propose-oracle-revoke-role": + case "propose-oracle-admin": + case "propose-oracle-upgrade": + case "apply-oracle-change": + case "cancel-oracle-change": case "propose-wind-down": case "cancel-wind-down": return describeAdminStatus( @@ -1497,7 +1913,7 @@ const describeStatus = ( case "unfreeze-address": case "freeze-shares": case "unfreeze-shares": - case "force-transfer": + case "legal-transfer": return describeAdminStatus( COMPLIANCE_COPY[props.action](props.value), props.status, @@ -1557,7 +1973,8 @@ const computeSteps = ( } if ( status.failure.kind === "unknown" || - status.failure.kind === "network" + status.failure.kind === "network" || + status.failure.kind === "stale" ) { return { signature: "done", network: "failed", recorded: "upcoming" } } @@ -1644,7 +2061,9 @@ const TransactionModal: React.FC = (props) => { content.hash === undefined ? null : explorerTransaction(content.hash) const offersRetry = status.status === "failed" && - (status.failure.kind === "declined" || status.failure.kind === "unsigned") + (status.failure.kind === "declined" || + status.failure.kind === "unsigned" || + status.failure.kind === "stale") const headingId = "transaction-modal-heading" const headingClassName = isConfirmed diff --git a/app/src/hooks/testSupport.ts b/app/src/hooks/testSupport.ts index 26fe6e76..13d8e018 100644 --- a/app/src/hooks/testSupport.ts +++ b/app/src/hooks/testSupport.ts @@ -1,3 +1,4 @@ +import { xdr } from "@stellar/stellar-sdk" import { networkPassphrase } from "@stellar-scaffold/app-lib" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { renderHook } from "@testing-library/react" @@ -17,6 +18,7 @@ const wallet: WalletContextType = { isPending: false, updateBalances: async () => {}, signTransaction: vi.fn() as WalletContextType["signTransaction"], + signAuthEntry: vi.fn() as WalletContextType["signAuthEntry"], } export const renderWithWallet = (useHook: () => T) => { @@ -41,3 +43,25 @@ export const deferred = () => { }) return { promise, resolve, reject } } + +export const contractEvent = (name: string, data: xdr.ScVal) => + new xdr.ContractEvent({ + ext: new xdr.ExtensionPoint(0), + contractId: null, + type: xdr.ContractEventType.contract(), + body: new xdr.ContractEventBody( + 0, + new xdr.ContractEventV0({ + topics: [xdr.ScVal.scvSymbol(name)], + data, + }), + ), + }) + +export const succeededWith = (result: T, events: xdr.ContractEvent[]) => ({ + getTransactionResponse: { + status: "SUCCESS", + events: { transactionEventsXdr: [], contractEventsXdr: [events] }, + }, + result, +}) diff --git a/app/src/hooks/useActivateWindDown.ts b/app/src/hooks/useActivateWindDown.ts index bf6e7124..9314081d 100644 --- a/app/src/hooks/useActivateWindDown.ts +++ b/app/src/hooks/useActivateWindDown.ts @@ -2,8 +2,9 @@ import { type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" @@ -12,6 +13,7 @@ export type ActivateWindDownStatus = TransactionStatus export interface UseActivateWindDown { status: ActivateWindDownStatus submit: () => boolean + prepare: () => Release reset: () => void } diff --git a/app/src/hooks/useAdminAuthority.ts b/app/src/hooks/useAdminAuthority.ts index 3b4a18e3..ba0ee749 100644 --- a/app/src/hooks/useAdminAuthority.ts +++ b/app/src/hooks/useAdminAuthority.ts @@ -7,6 +7,7 @@ import { type PendingAdmins, readContract, readContractAdmin, + requiredWeight, type ShareTokenApi, uniformWeights, } from "@stellar-scaffold/app-lib" @@ -159,7 +160,7 @@ const standingOf = ( if (signers === undefined) return wallet === holder ? "signs-alone" : null const weight = signers.signers.find(({ key }) => key === wallet)?.weight if (weight === undefined || weight === 0) return null - const threshold = signers.thresholds.med + const threshold = requiredWeight(signers) if (weight >= threshold) return "signs-alone" return uniformWeights(signers.signers) ? { needs: threshold } diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index 7be5ae71..68771092 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -11,14 +11,19 @@ import { complianceReadsKey } from "./useComplianceState" import { useAllowAddress, useDisallowAddress, - useForceTransfer, useFreezeAddress, + useLegalTransfer, useFreezeShares, useUnfreezeAddress, useUnfreezeShares, } from "./useComplianceTransactions" import { configurationReadsKey } from "./useConfigurationState" import { + useProposeCompliance, + useProposeCustodian, + useProposeIdentityVerifier, + useProposeNotice, + useProposeWindDownDelay, useSetCompliance, useSetCustodian, useSetDepositCap, @@ -27,25 +32,27 @@ import { useSetOracleConfig, useSetWindDownDelay, } from "./useConfigurationTransactions" -import { cycleReadsKey } from "./useCycleState" +import { cycleReadsKey, legalTransfersKey } from "./useCycleState" import { useClearLatest, usePauseToken, usePauseVault, - useRaiseRipcord, - useSetRipcord, + usePauseNav, + useSetNavPaused, useUnpauseToken, useUnpauseVault, } from "./useEmergencyTransactions" -import { upgradeProposalKey } from "./useGovernanceState" +import { pendingChangesKey } from "./useGovernanceState" import { useAcceptOracleAdmin, useAcceptTokenAdmin, useAcceptVaultAdmin, - useApplyUpgrade, + useApplyChange, + useApplyTokenChange, useCancelOracleHandover, useCancelTokenHandover, - useCancelUpgrade, + useCancelChange, + useCancelTokenChange, useCancelVaultHandover, useCancelWindDown, useGrantOracleRole, @@ -54,7 +61,13 @@ import { useHandOverOracleAdmin, useHandOverTokenAdmin, useHandOverVaultAdmin, + useProposeAdmin, useProposeCodeUpgrade, + useProposeGrantRole, + useProposeRevokeRole, + useProposeTokenAdmin, + useProposeTokenGrantRole, + useProposeTokenRevokeRole, useProposeUpgradeDelay, useProposeWindDown, useRevokeOracleRole, @@ -91,10 +104,9 @@ const { revoke_role: vi.fn(), transfer_admin_role: vi.fn(), accept_admin_transfer: vi.fn(), - propose_upgrade: vi.fn(), - propose_upgrade_delay: vi.fn(), - apply_upgrade: vi.fn(), - cancel_upgrade: vi.fn(), + propose_change: vi.fn(), + apply_change: vi.fn(), + cancel_change: vi.fn(), propose_wind_down: vi.fn(), cancel_wind_down_proposal: vi.fn(), }, @@ -110,13 +122,16 @@ const { set_address_frozen: vi.fn(), freeze_partial_tokens: vi.fn(), unfreeze_partial_tokens: vi.fn(), - forced_transfer: vi.fn(), + legal_transfer: vi.fn(), + propose_change: vi.fn(), + apply_change: vi.fn(), + cancel_change: vi.fn(), }, verifier: { allow: vi.fn() }, verifierWriterMock: vi.fn(), oracle: { - raise_ripcord: vi.fn(), - set_ripcord: vi.fn(), + pause_nav: vi.fn(), + set_nav_paused: vi.fn(), clear_latest: vi.fn(), set_config: vi.fn(), grant_role: vi.fn(), @@ -144,7 +159,7 @@ const ORACLE_CONFIG = { freshness_duration: 86_400n, cooldown_secs: 3_600n, max_up_bps: 1_000, - max_down_bps: null, + max_down_bps: 500, min_answer: 1n, max_answer: 10n, } @@ -200,9 +215,9 @@ const cases: Case[] = [ refusal: 1001, }, { - name: "useRaiseRipcord", - useHook: useRaiseRipcord, - method: oracle.raise_ripcord, + name: "usePauseNav", + useHook: usePauseNav, + method: oracle.pause_nav, arg: undefined, args: { caller: investorAddress }, keys: oracleWriteKeys, @@ -213,16 +228,15 @@ const cases: Case[] = [ useHook: useClearLatest, method: oracle.clear_latest, arg: undefined, - args: { caller: investorAddress }, keys: oracleWriteKeys, refusal: 3008, }, { - name: "useSetRipcord", - useHook: useSetRipcord, - method: oracle.set_ripcord, + name: "useSetNavPaused", + useHook: useSetNavPaused, + method: oracle.set_nav_paused, arg: false, - args: { paused: false, caller: investorAddress }, + args: { paused: false }, keys: oracleWriteKeys, refusal: 2000, }, @@ -231,7 +245,7 @@ const cases: Case[] = [ useHook: useSetDepositCap, method: vault.set_deposit_cap, arg: null, - args: { cap: null, caller: investorAddress }, + args: { cap: null }, keys: [cycleReadsKey], refusal: 6007, }, @@ -240,7 +254,7 @@ const cases: Case[] = [ useHook: useSetCustodian, method: vault.set_custodian, arg: CONTRACT, - args: { custodian: CONTRACT, caller: investorAddress }, + args: { custodian: CONTRACT }, keys: [vaultAuthoritiesKey], refusal: 2000, }, @@ -249,7 +263,7 @@ const cases: Case[] = [ useHook: useSetNotice, method: vault.set_notice, arg: 86_400n, - args: { secs: 86_400n, caller: investorAddress }, + args: { secs: 86_400n }, keys: [cycleReadsKey], refusal: 6061, }, @@ -258,10 +272,37 @@ const cases: Case[] = [ useHook: useSetWindDownDelay, method: vault.set_wind_down_delay, arg: 86_400n, - args: { secs: 86_400n, caller: investorAddress }, + args: { secs: 86_400n }, keys: [cycleReadsKey], refusal: 6053, }, + { + name: "useProposeCustodian", + useHook: useProposeCustodian, + method: vault.propose_change, + arg: CONTRACT, + args: { change: { tag: "Custodian", values: [CONTRACT] } }, + keys: [pendingChangesKey("vault"), vaultAuthoritiesKey], + refusal: 6075, + }, + { + name: "useProposeNotice", + useHook: useProposeNotice, + method: vault.propose_change, + arg: 43_200n, + args: { change: { tag: "Notice", values: [43_200n] } }, + keys: [pendingChangesKey("vault"), cycleReadsKey], + refusal: 6061, + }, + { + name: "useProposeWindDownDelay", + useHook: useProposeWindDownDelay, + method: vault.propose_change, + arg: 691_200n, + args: { change: { tag: "WindDownDelay", values: [691_200n] } }, + keys: [pendingChangesKey("vault"), cycleReadsKey], + refusal: 6069, + }, { name: "useSetOracleConfig", useHook: useSetOracleConfig, @@ -289,6 +330,24 @@ const cases: Case[] = [ keys: [configurationReadsKey, adminAuthorityKey, isAllowedKey], refusal: 2000, }, + { + name: "useProposeCompliance", + useHook: useProposeCompliance, + method: token.propose_change, + arg: CONTRACT, + args: { change: { tag: "Compliance", values: [CONTRACT] } }, + keys: [pendingChangesKey("token"), configurationReadsKey], + refusal: 7004, + }, + { + name: "useProposeIdentityVerifier", + useHook: useProposeIdentityVerifier, + method: token.propose_change, + arg: CONTRACT, + args: { change: { tag: "IdentityVerifier", values: [CONTRACT] } }, + keys: [pendingChangesKey("token"), configurationReadsKey], + refusal: 7003, + }, ] const ROLE_CHANGE = { role: "guardian", account: CONTRACT } @@ -403,45 +462,129 @@ const governanceCases: Case[] = [ { name: "useProposeCodeUpgrade", useHook: useProposeCodeUpgrade, - method: vault.propose_upgrade, + method: vault.propose_change, arg: WASM_HASH, - args: { wasm_hash: Buffer.alloc(32, 0xab), caller: investorAddress }, - keys: [upgradeProposalKey, configurationReadsKey], + args: { change: { tag: "Upgrade", values: [Buffer.alloc(32, 0xab)] } }, + keys: [pendingChangesKey("vault"), configurationReadsKey], refusal: 6055, }, { name: "useProposeUpgradeDelay", useHook: useProposeUpgradeDelay, - method: vault.propose_upgrade_delay, + method: vault.propose_change, arg: 604_800n, - args: { secs: 604_800n, caller: investorAddress }, - keys: [upgradeProposalKey, configurationReadsKey], + args: { change: { tag: "UpgradeDelay", values: [604_800n] } }, + keys: [pendingChangesKey("vault"), configurationReadsKey], refusal: 6058, }, { - name: "useApplyUpgrade", - useHook: useApplyUpgrade, - method: vault.apply_upgrade, - arg: undefined, - args: { caller: investorAddress }, - keys: [upgradeProposalKey, configurationReadsKey], + name: "useProposeGrantRole", + useHook: useProposeGrantRole, + method: vault.propose_change, + arg: ROLE_CHANGE, + args: { change: { tag: "GrantRole", values: ["guardian", CONTRACT] } }, + keys: [pendingChangesKey("vault")], + refusal: 6063, + }, + { + name: "useProposeRevokeRole", + useHook: useProposeRevokeRole, + method: vault.propose_change, + arg: ROLE_CHANGE, + args: { change: { tag: "RevokeRole", values: ["guardian", CONTRACT] } }, + keys: [pendingChangesKey("vault")], + refusal: 6064, + }, + { + name: "useProposeAdmin", + useHook: useProposeAdmin, + method: vault.propose_change, + arg: CONTRACT, + args: { change: { tag: "Admin", values: [CONTRACT] } }, + keys: [pendingChangesKey("vault")], + refusal: 6046, + }, + { + name: "useApplyChange", + useHook: useApplyChange, + method: vault.apply_change, + arg: 3, + args: { id: 3 }, + keys: [ + pendingChangesKey("vault"), + configurationReadsKey, + cycleReadsKey, + governanceReadsKey, + adminAuthorityKey, + vaultAuthoritiesKey, + ], refusal: 6057, }, { - name: "useCancelUpgrade", - useHook: useCancelUpgrade, - method: vault.cancel_upgrade, - arg: undefined, - args: { caller: investorAddress }, - keys: [upgradeProposalKey, configurationReadsKey], + name: "useCancelChange", + useHook: useCancelChange, + method: vault.cancel_change, + arg: 3, + args: { id: 3 }, + keys: [pendingChangesKey("vault")], refusal: 6056, }, + { + name: "useProposeTokenGrantRole", + useHook: useProposeTokenGrantRole, + method: token.propose_change, + arg: ROLE_CHANGE, + args: { change: { tag: "GrantRole", values: ["guardian", CONTRACT] } }, + keys: [pendingChangesKey("token")], + refusal: 7003, + }, + { + name: "useProposeTokenRevokeRole", + useHook: useProposeTokenRevokeRole, + method: token.propose_change, + arg: ROLE_CHANGE, + args: { change: { tag: "RevokeRole", values: ["guardian", CONTRACT] } }, + keys: [pendingChangesKey("token")], + refusal: 7008, + }, + { + name: "useProposeTokenAdmin", + useHook: useProposeTokenAdmin, + method: token.propose_change, + arg: CONTRACT, + args: { change: { tag: "Admin", values: [CONTRACT] } }, + keys: [pendingChangesKey("token")], + refusal: 7004, + }, + { + name: "useApplyTokenChange", + useHook: useApplyTokenChange, + method: token.apply_change, + arg: 3, + args: { id: 3 }, + keys: [ + pendingChangesKey("token"), + configurationReadsKey, + governanceReadsKey, + adminAuthorityKey, + isAllowedKey, + ], + refusal: 7009, + }, + { + name: "useCancelTokenChange", + useHook: useCancelTokenChange, + method: token.cancel_change, + arg: 3, + args: { id: 3 }, + keys: [pendingChangesKey("token")], + refusal: 7001, + }, { name: "useProposeWindDown", useHook: useProposeWindDown, method: vault.propose_wind_down, arg: undefined, - args: { caller: investorAddress }, keys: [cycleReadsKey], refusal: 6046, }, @@ -450,7 +593,6 @@ const governanceCases: Case[] = [ useHook: useCancelWindDown, method: vault.cancel_wind_down_proposal, arg: undefined, - args: { caller: investorAddress }, keys: [cycleReadsKey], refusal: 6047, }, @@ -474,7 +616,7 @@ const complianceCases: Case[] = [ useHook: useAllowAddress, method: verifier.allow, arg: { verifier: VERIFIER, account: TARGET }, - args: { account: TARGET, allowed: true, caller: investorAddress }, + args: { account: TARGET, allowed: true }, keys: [complianceReadsKey, isAllowedKey], refusal: 2000, }, @@ -483,7 +625,7 @@ const complianceCases: Case[] = [ useHook: useDisallowAddress, method: verifier.allow, arg: { verifier: VERIFIER, account: TARGET }, - args: { account: TARGET, allowed: false, caller: investorAddress }, + args: { account: TARGET, allowed: false }, keys: [complianceReadsKey, isAllowedKey], refusal: 2001, }, @@ -524,17 +666,18 @@ const complianceCases: Case[] = [ refusal: 303, }, { - name: "useForceTransfer", - useHook: useForceTransfer, - method: token.forced_transfer, - arg: { from: TARGET, to: DESTINATION, amount: 5n }, + name: "useLegalTransfer", + useHook: useLegalTransfer, + method: token.legal_transfer, + arg: { from: TARGET, to: DESTINATION, amount: 5n, reference: "ORDER-1" }, args: { from: TARGET, to: DESTINATION, amount: 5n, - operator: investorAddress, + reference: "ORDER-1", + caller: investorAddress, }, - keys: [complianceReadsKey, ["share", "balance"]], + keys: [complianceReadsKey, ["share", "balance"], legalTransfersKey], refusal: 300, }, ] @@ -647,11 +790,13 @@ describe("usePauseVault with a grant", () => { const signable = () => { const signed = { toEnvelope: () => ({ toXDR: () => "SIGNEDXDR" }), + hash: () => ({ toString: () => "abcd" }), } const tx = { simulation: undefined, result: undefined, signed: undefined as typeof signed | undefined, + needsNonInvokerSigningBy: vi.fn((): string[] => []), sign: vi.fn(async () => { tx.signed = signed }), @@ -749,3 +894,53 @@ describe("usePauseVault with a grant", () => { expect(result.current.status).toEqual({ status: "confirmed" }) }) }) + +describe("useLegalTransfer with a grant", () => { + const AUTHORITY = "GAUTHORITYACCOUNT0987654321" + const ADMIN = "GTOKENADMINACCOUNT12345678" + const order = { + from: "GFROMACCOUNT", + to: "GTOACCOUNT", + amount: 5n, + reference: "ORDER-1", + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("shares the simulated transaction unsigned while the token admin must authorize it", async () => { + const tx = { + simulation: undefined, + result: undefined, + built: { toEnvelope: () => ({ toXDR: () => "UNSIGNEDXDR" }) }, + needsNonInvokerSigningBy: vi.fn(() => [ADMIN]), + sign: vi.fn(), + signAndSend: vi.fn(), + } + token.legal_transfer.mockResolvedValue(tx) + const { result } = renderWithWallet(useLegalTransfer) + + await act(() => + result.current.submit(order, { + role: "share-token compliance", + authority: AUTHORITY, + standing: "signs-alone", + }), + ) + + expect(token.legal_transfer).toHaveBeenCalledWith({ + ...order, + caller: AUTHORITY, + }) + expect(tx.sign).not.toHaveBeenCalled() + expect(tx.signAndSend).not.toHaveBeenCalled() + expect(submitSignedMock).not.toHaveBeenCalled() + expect(result.current.status).toEqual({ + status: "shared", + xdr: "UNSIGNEDXDR", + standing: "admin-authorization", + expiresAt: null, + }) + }) +}) diff --git a/app/src/hooks/useAdminTransaction.ts b/app/src/hooks/useAdminTransaction.ts index 475b7cda..cf9e6a7b 100644 --- a/app/src/hooks/useAdminTransaction.ts +++ b/app/src/hooks/useAdminTransaction.ts @@ -11,9 +11,9 @@ type NoResult = Record const toConfirmed = () => ({}) export const adminTransaction = - ( - call: (signer: Signer, arg: TArg) => Promise>, + ( + call: (signer: Signer, arg: TArg) => Promise>, keys: readonly QueryKey[], ) => (): UseContractTransaction => - useContractTransaction(call, toConfirmed, keys) + useContractTransaction(call, toConfirmed, keys) diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index 8db22ae6..65cd994c 100644 --- a/app/src/hooks/useAttest.test.ts +++ b/app/src/hooks/useAttest.test.ts @@ -1,3 +1,4 @@ +import { Buffer } from "buffer" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { type Price } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" @@ -19,6 +20,8 @@ vi.mock("../config/clients", () => ({ const attestation = { price: 1_040_000_000_000_000_000n as Price, freshness: 86_400n, + proofHash: "ab".repeat(32), + proofUri: "https://example.com/nav.pdf", } const wallClock = 1_700_200_000 @@ -73,15 +76,32 @@ describe("useAttest", () => { ) expect(oracleMock.attest).toHaveBeenCalledWith({ - report: { + input: { nav_per_share: attestation.price, expires_at: BigInt(wallClock) + attestation.freshness, - timestamp: 0n, + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "https://example.com/nav.pdf", }, caller: investorAddress, }) }) + it("sends an empty proof URI when the attester gives none", async () => { + oracleMock.attest.mockResolvedValue(successful()) + const { result } = renderAttest() + + await act(() => result.current.submit({ ...attestation, proofUri: "" })) + + expect(oracleMock.attest).toHaveBeenCalledWith({ + input: expect.objectContaining({ + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "", + }), + caller: investorAddress, + }) + expect(result.current.status.status).toBe("confirmed") + }) + it("refuses before ever asking for a signature when the simulation carries the oracle's reason", async () => { const signAndSend = vi.fn() oracleMock.attest.mockResolvedValue({ diff --git a/app/src/hooks/useAttest.ts b/app/src/hooks/useAttest.ts index ff94526d..35f974a3 100644 --- a/app/src/hooks/useAttest.ts +++ b/app/src/hooks/useAttest.ts @@ -1,34 +1,43 @@ +import { Buffer } from "buffer" import { type Price, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { navOracleWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" import { navLatestKey } from "./useNavPrice" export type AttestStatus = TransactionStatus -export type Attestation = { price: Price; freshness: bigint } +export type Attestation = { + price: Price + freshness: bigint + proofHash: string + proofUri: string +} export const attestWriteKeys = [...cycleWriteKeys, navLatestKey] as const export interface UseAttest { status: AttestStatus submit: (attestation: Attestation) => boolean + prepare: (attestation: Attestation) => Release reset: () => void } export function useAttest(): UseAttest { const call = useCallback( - (signer: Signer, { price, freshness }: Attestation) => + (signer: Signer, { price, freshness, proofHash, proofUri }: Attestation) => navOracleWriter(signer).then((oracle) => oracle.attest({ - report: { + input: { nav_per_share: price, expires_at: BigInt(Math.floor(Date.now() / 1000)) + freshness, - timestamp: 0n, + proof_hash: Buffer.from(proofHash, "hex"), + proof_uri: proofUri, }, caller: signer.publicKey, }), diff --git a/app/src/hooks/useCancelDeposit.test.ts b/app/src/hooks/useCancelDeposit.test.ts index a15185b2..8d2884f5 100644 --- a/app/src/hooks/useCancelDeposit.test.ts +++ b/app/src/hooks/useCancelDeposit.test.ts @@ -67,7 +67,7 @@ describe("useCancelDeposit", () => { ) expect(vaultMock.cancel_deposit).toHaveBeenCalledWith({ - from: investorAddress, + holder: investorAddress, epoch_id: epochId, }) }) @@ -304,7 +304,7 @@ describe("useCancelDeposit", () => { ) expect(vaultMock.cancel_deposit).toHaveBeenCalledTimes(1) expect(vaultMock.cancel_deposit).toHaveBeenCalledWith({ - from: investorAddress, + holder: investorAddress, epoch_id: epochId, }) diff --git a/app/src/hooks/useCancelDeposit.ts b/app/src/hooks/useCancelDeposit.ts index 987c5e41..44582f53 100644 --- a/app/src/hooks/useCancelDeposit.ts +++ b/app/src/hooks/useCancelDeposit.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" export type CancelDepositStatus = TransactionStatus<{ refundedAmount: Amount }> @@ -11,6 +12,7 @@ export type CancelDepositStatus = TransactionStatus<{ refundedAmount: Amount }> export interface UseCancelDeposit { status: CancelDepositStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } @@ -18,7 +20,7 @@ export function useCancelDeposit(): UseCancelDeposit { const call = useCallback( (signer: Signer, epochId: bigint) => asyncVaultWriter(signer).then((vault) => - vault.cancel_deposit({ from: signer.publicKey, epoch_id: epochId }), + vault.cancel_deposit({ holder: signer.publicKey, epoch_id: epochId }), ), [], ) diff --git a/app/src/hooks/useCancelRedeem.test.ts b/app/src/hooks/useCancelRedeem.test.ts index 61f8ca80..7b41d9d8 100644 --- a/app/src/hooks/useCancelRedeem.test.ts +++ b/app/src/hooks/useCancelRedeem.test.ts @@ -1,7 +1,14 @@ +import { nativeToScVal } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { deferred, investorAddress, renderWithWallet } from "./testSupport" +import { + contractEvent, + deferred, + investorAddress, + renderWithWallet, + succeededWith, +} from "./testSupport" import { useCancelRedeem } from "./useCancelRedeem" import { investorRequestsKey } from "./useInvestorRequests" import { sharePositionKey } from "./useSharePosition" @@ -27,6 +34,55 @@ describe("useCancelRedeem", () => { asyncVaultWriterMock.mockResolvedValue(vaultMock) }) + it("reports a redemption surrendered into the wind-down claim instead of returned", async () => { + const surrendered = contractEvent( + "redemption_surrendered", + nativeToScVal( + { epoch: epochId, shares: 100_0000000n }, + { type: { epoch: ["symbol", "u64"], shares: ["symbol", "i128"] } }, + ), + ) + vaultMock.cancel_redeem.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockResolvedValue(succeededWith(100_0000000n, [surrendered])), + }) + const { result } = renderCancelRedeem() + + await act(async () => { + result.current.submit(epochId) + }) + + await waitFor(() => + expect(result.current.status).toEqual({ + status: "confirmed", + returnedShares: 100_0000000n, + surrendered: true, + }), + ) + }) + + it("reports a returned redemption as not surrendered", async () => { + vaultMock.cancel_redeem.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue(succeededWith(100_0000000n, [])), + }) + const { result } = renderCancelRedeem() + + await act(async () => { + result.current.submit(epochId) + }) + + await waitFor(() => + expect(result.current.status).toEqual({ + status: "confirmed", + returnedShares: 100_0000000n, + surrendered: false, + }), + ) + }) + it("moves through awaiting signature, submitted, then confirmed with the returned shares, in order", async () => { const signingGate = deferred() const confirmGate = deferred() @@ -63,11 +119,12 @@ describe("useCancelRedeem", () => { expect(result.current.status).toEqual({ status: "confirmed", returnedShares: 100_0000000n, + surrendered: false, }), ) expect(vaultMock.cancel_redeem).toHaveBeenCalledWith({ - from: investorAddress, + holder: investorAddress, epoch_id: epochId, }) }) @@ -286,7 +343,7 @@ describe("useCancelRedeem", () => { ) expect(vaultMock.cancel_redeem).toHaveBeenCalledTimes(1) expect(vaultMock.cancel_redeem).toHaveBeenCalledWith({ - from: investorAddress, + holder: investorAddress, epoch_id: epochId, }) @@ -330,6 +387,7 @@ describe("useCancelRedeem", () => { expect(result.current.status).toEqual({ status: "confirmed", returnedShares: 100_0000000n, + surrendered: false, hash: "a".repeat(64), }), ) diff --git a/app/src/hooks/useCancelRedeem.ts b/app/src/hooks/useCancelRedeem.ts index 80df2695..c18dc648 100644 --- a/app/src/hooks/useCancelRedeem.ts +++ b/app/src/hooks/useCancelRedeem.ts @@ -1,16 +1,25 @@ -import { type Amount, type Signer } from "@stellar-scaffold/app-lib" +import { + type Amount, + type Signer, + type TransactionEvent, +} from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" -export type CancelRedeemStatus = TransactionStatus<{ returnedShares: Amount }> +export type CancelRedeemStatus = TransactionStatus<{ + returnedShares: Amount + surrendered: boolean +}> export interface UseCancelRedeem { status: CancelRedeemStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } @@ -18,12 +27,17 @@ export function useCancelRedeem(): UseCancelRedeem { const call = useCallback( (signer: Signer, epochId: bigint) => asyncVaultWriter(signer).then((vault) => - vault.cancel_redeem({ from: signer.publicKey, epoch_id: epochId }), + vault.cancel_redeem({ holder: signer.publicKey, epoch_id: epochId }), ), [], ) const toConfirmed = useCallback( - (returnedShares: bigint) => ({ returnedShares: returnedShares as Amount }), + (returnedShares: bigint, events: TransactionEvent[]) => ({ + returnedShares: returnedShares as Amount, + surrendered: events.some( + (event) => event.name === "redemption_surrendered", + ), + }), [], ) return useContractTransaction(call, toConfirmed) diff --git a/app/src/hooks/useClaimDeposit.test.ts b/app/src/hooks/useClaimDeposit.test.ts index 0c2eec19..f519f16f 100644 --- a/app/src/hooks/useClaimDeposit.test.ts +++ b/app/src/hooks/useClaimDeposit.test.ts @@ -1,7 +1,14 @@ +import { nativeToScVal } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { deferred, investorAddress, renderWithWallet } from "./testSupport" +import { + contractEvent, + deferred, + investorAddress, + renderWithWallet, + succeededWith, +} from "./testSupport" import { useClaimDeposit } from "./useClaimDeposit" import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" @@ -27,6 +34,60 @@ describe("useClaimDeposit", () => { asyncVaultWriterMock.mockResolvedValue(vaultMock) }) + it.each([ + ["a redemption in a later epoch", 5n], + ["the wind-down claim", 0n], + ])( + "reports a delisted holder's shares converted into %s", + async (_label, intoEpoch) => { + const converted = contractEvent( + "deposit_converted_to_exit", + nativeToScVal( + { epoch: epochId, into_epoch: intoEpoch, shares: 50_0000000n }, + { + type: { + epoch: ["symbol", "u64"], + into_epoch: ["symbol", "u64"], + shares: ["symbol", "i128"], + }, + }, + ), + ) + vaultMock.claim_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue(succeededWith(0n, [converted])), + }) + const { result } = renderClaimDeposit() + + await act(async () => { + result.current.submit(epochId) + }) + + await waitFor(() => + expect(result.current.status).toEqual({ + status: "confirmed", + sharesMinted: 0n, + queuedInto: intoEpoch, + }), + ) + }, + ) + + it("reports no conversion when the claim mints shares", async () => { + vaultMock.claim_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue(succeededWith(50_0000000n, [])), + }) + const { result } = renderClaimDeposit() + + await act(async () => { + result.current.submit(epochId) + }) + + await waitFor(() => expect(result.current.status.status).toBe("confirmed")) + expect(result.current.status).not.toHaveProperty("queuedInto") + }) + it("reports whether the submission was accepted synchronously, not through a promise", async () => { vaultMock.claim_deposit.mockResolvedValue({ simulation: undefined, @@ -83,7 +144,7 @@ describe("useClaimDeposit", () => { ) expect(vaultMock.claim_deposit).toHaveBeenCalledWith({ - caller: investorAddress, + holder: investorAddress, epoch_id: epochId, }) }) diff --git a/app/src/hooks/useClaimDeposit.ts b/app/src/hooks/useClaimDeposit.ts index bcaba295..28c50b47 100644 --- a/app/src/hooks/useClaimDeposit.ts +++ b/app/src/hooks/useClaimDeposit.ts @@ -1,16 +1,32 @@ -import { type Amount, type Signer } from "@stellar-scaffold/app-lib" +import { + type Amount, + type Signer, + type TransactionEvent, +} from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" -export type ClaimDepositStatus = TransactionStatus<{ sharesMinted: Amount }> +export type ClaimDepositOutcome = { sharesMinted: Amount; queuedInto?: bigint } + +export type ClaimDepositStatus = TransactionStatus + +const queuedIntoOf = (events: TransactionEvent[]): bigint | undefined => { + const converted = events.find( + (event) => event.name === "deposit_converted_to_exit", + ) + const data = converted?.data as { into_epoch?: unknown } | undefined + return typeof data?.into_epoch === "bigint" ? data.into_epoch : undefined +} export interface UseClaimDeposit { status: ClaimDepositStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } @@ -18,12 +34,16 @@ export function useClaimDeposit(): UseClaimDeposit { const call = useCallback( (signer: Signer, epochId: bigint) => asyncVaultWriter(signer).then((vault) => - vault.claim_deposit({ caller: signer.publicKey, epoch_id: epochId }), + vault.claim_deposit({ holder: signer.publicKey, epoch_id: epochId }), ), [], ) const toConfirmed = useCallback( - (sharesMinted: bigint) => ({ sharesMinted: sharesMinted as Amount }), + (sharesMinted: bigint, events: TransactionEvent[]): ClaimDepositOutcome => { + const queuedInto = queuedIntoOf(events) + const outcome = { sharesMinted: sharesMinted as Amount } + return queuedInto === undefined ? outcome : { ...outcome, queuedInto } + }, [], ) return useContractTransaction(call, toConfirmed) diff --git a/app/src/hooks/useClaimRedeem.test.ts b/app/src/hooks/useClaimRedeem.test.ts index 62900a76..7e6f1b63 100644 --- a/app/src/hooks/useClaimRedeem.test.ts +++ b/app/src/hooks/useClaimRedeem.test.ts @@ -1,7 +1,14 @@ +import { xdr } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { deferred, investorAddress, renderWithWallet } from "./testSupport" +import { + contractEvent, + deferred, + investorAddress, + renderWithWallet, + succeededWith, +} from "./testSupport" import { useClaimRedeem } from "./useClaimRedeem" import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" @@ -79,11 +86,12 @@ describe("useClaimRedeem", () => { expect(result.current.status).toEqual({ status: "confirmed", assetsClaimed: 50_0000000n, + sharesReturned: false, }), ) expect(vaultMock.claim_redeem).toHaveBeenCalledWith({ - caller: investorAddress, + holder: investorAddress, epoch_id: epochId, }) }) @@ -103,6 +111,29 @@ describe("useClaimRedeem", () => { expect(result.current.status).toEqual({ status: "confirmed", assetsClaimed: 0n, + sharesReturned: false, + }) + }) + + it("reports the shares returned when a zero-price claim gives them back", async () => { + vaultMock.claim_redeem.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockResolvedValue( + succeededWith(0n, [ + contractEvent("redeem_cancelled", xdr.ScVal.scvVoid()), + ]), + ), + }) + const { result } = renderClaimRedeem() + + await act(() => result.current.submit(epochId)) + + expect(result.current.status).toEqual({ + status: "confirmed", + assetsClaimed: 0n, + sharesReturned: true, }) }) diff --git a/app/src/hooks/useClaimRedeem.ts b/app/src/hooks/useClaimRedeem.ts index dc6c8333..9789d3e2 100644 --- a/app/src/hooks/useClaimRedeem.ts +++ b/app/src/hooks/useClaimRedeem.ts @@ -1,16 +1,25 @@ -import { type Amount, type Signer } from "@stellar-scaffold/app-lib" +import { + type Amount, + type Signer, + type TransactionEvent, +} from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" -export type ClaimRedeemStatus = TransactionStatus<{ assetsClaimed: Amount }> +export type ClaimRedeemStatus = TransactionStatus<{ + assetsClaimed: Amount + sharesReturned: boolean +}> export interface UseClaimRedeem { status: ClaimRedeemStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } @@ -18,12 +27,15 @@ export function useClaimRedeem(): UseClaimRedeem { const call = useCallback( (signer: Signer, epochId: bigint) => asyncVaultWriter(signer).then((vault) => - vault.claim_redeem({ caller: signer.publicKey, epoch_id: epochId }), + vault.claim_redeem({ holder: signer.publicKey, epoch_id: epochId }), ), [], ) const toConfirmed = useCallback( - (assetsClaimed: bigint) => ({ assetsClaimed: assetsClaimed as Amount }), + (assetsClaimed: bigint, events: TransactionEvent[]) => ({ + assetsClaimed: assetsClaimed as Amount, + sharesReturned: events.some((event) => event.name === "redeem_cancelled"), + }), [], ) return useContractTransaction(call, toConfirmed) diff --git a/app/src/hooks/useClaimWindDown.test.ts b/app/src/hooks/useClaimWindDown.test.ts new file mode 100644 index 00000000..4f94ed60 --- /dev/null +++ b/app/src/hooks/useClaimWindDown.test.ts @@ -0,0 +1,105 @@ +import { nativeToScVal } from "@stellar/stellar-sdk" +import { act } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { + contractEvent, + investorAddress, + renderWithWallet, + succeededWith, +} from "./testSupport" +import { useClaimWindDown } from "./useClaimWindDown" +import { windDownClaimableKey } from "./useWindDownClaimable" + +const { vaultMock } = vi.hoisted(() => ({ + vaultMock: { claim_wind_down: vi.fn() }, +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: async () => vaultMock, +})) + +const claimed = (surrendered: bigint, assets: bigint) => + contractEvent( + "wind_down_claimed", + nativeToScVal( + { surrendered, assets }, + { + type: { + surrendered: ["symbol", "i128"], + assets: ["symbol", "i128"], + }, + }, + ), + ) + +describe("useClaimWindDown", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("claims for the connected wallet and reports the assets paid and shares turned in", async () => { + vaultMock.claim_wind_down.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockResolvedValue( + succeededWith(25_0000000n, [claimed(40_0000000n, 25_0000000n)]), + ), + }) + const { result } = renderWithWallet(useClaimWindDown) + + await act(() => result.current.submit(undefined)) + + expect(vaultMock.claim_wind_down).toHaveBeenCalledWith({ + holder: investorAddress, + }) + expect(result.current.status).toEqual({ + status: "confirmed", + assetsClaimed: 25_0000000n, + sharesSurrendered: 40_0000000n, + }) + }) + + it("reports no shares turned in when the claim carries no claim event", async () => { + vaultMock.claim_wind_down.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue(succeededWith(25_0000000n, [])), + }) + const { result } = renderWithWallet(useClaimWindDown) + + await act(() => result.current.submit(undefined)) + + expect(result.current.status).toEqual({ + status: "confirmed", + assetsClaimed: 25_0000000n, + sharesSurrendered: 0n, + }) + }) + + it("refreshes the wind-down claimable amount once the claim confirms", async () => { + vaultMock.claim_wind_down.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue(succeededWith(25_0000000n, [])), + }) + const { result, queryClient } = renderWithWallet(useClaimWindDown) + const invalidate = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(undefined)) + + expect(invalidate).toHaveBeenCalledWith({ queryKey: windDownClaimableKey }) + }) + + it("maps a refusal at simulation to its contract code", async () => { + vaultMock.claim_wind_down.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6071)" }, + }) + const { result } = renderWithWallet(useClaimWindDown) + + await act(() => result.current.submit(undefined)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 6071 }, + }) + }) +}) diff --git a/app/src/hooks/useClaimWindDown.ts b/app/src/hooks/useClaimWindDown.ts new file mode 100644 index 00000000..a02234ee --- /dev/null +++ b/app/src/hooks/useClaimWindDown.ts @@ -0,0 +1,51 @@ +import { + type Amount, + type Signer, + type TransactionEvent, +} from "@stellar-scaffold/app-lib" +import { useCallback } from "react" +import { asyncVaultWriter } from "../config/clients" +import { + type Release, + type TransactionStatus, + useContractTransaction, +} from "./useContractTransaction" +import { windDownClaimableKey } from "./useWindDownClaimable" + +export type ClaimWindDownStatus = TransactionStatus<{ + assetsClaimed: Amount + sharesSurrendered: Amount +}> + +export interface UseClaimWindDown { + status: ClaimWindDownStatus + submit: (arg: undefined) => boolean + prepare: (arg: undefined) => Release + reset: () => void +} + +const EXTRA_KEYS = [windDownClaimableKey] + +const surrenderedOf = (events: TransactionEvent[]): bigint => { + const claimed = events.find((event) => event.name === "wind_down_claimed") + const data = claimed?.data as { surrendered?: unknown } | undefined + return typeof data?.surrendered === "bigint" ? data.surrendered : 0n +} + +export function useClaimWindDown(): UseClaimWindDown { + const call = useCallback( + (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault.claim_wind_down({ holder: signer.publicKey }), + ), + [], + ) + const toConfirmed = useCallback( + (assetsClaimed: bigint, events: TransactionEvent[]) => ({ + assetsClaimed: assetsClaimed as Amount, + sharesSurrendered: surrenderedOf(events) as Amount, + }), + [], + ) + return useContractTransaction(call, toConfirmed, EXTRA_KEYS) +} diff --git a/app/src/hooks/useCloseEpoch.ts b/app/src/hooks/useCloseEpoch.ts index 64359c6f..50e19bed 100644 --- a/app/src/hooks/useCloseEpoch.ts +++ b/app/src/hooks/useCloseEpoch.ts @@ -2,8 +2,9 @@ import { type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" @@ -12,6 +13,7 @@ export type CloseEpochStatus = TransactionStatus<{ sealedEpoch: bigint }> export interface UseCloseEpoch { status: CloseEpochStatus submit: () => boolean + prepare: () => Release reset: () => void } diff --git a/app/src/hooks/useComplianceTransactions.ts b/app/src/hooks/useComplianceTransactions.ts index 820d51a0..17b88f83 100644 --- a/app/src/hooks/useComplianceTransactions.ts +++ b/app/src/hooks/useComplianceTransactions.ts @@ -2,11 +2,17 @@ import { type Signer } from "@stellar-scaffold/app-lib" import { identityVerifierWriter, shareTokenWriter } from "../config/clients" import { adminTransaction } from "./useAdminTransaction" import { complianceReadsKey } from "./useComplianceState" +import { legalTransfersKey } from "./useCycleState" import { isAllowedKey } from "./useIsAllowed" export type AllowanceChange = { verifier: string; account: string } export type SharesChange = { account: string; amount: bigint } -export type ForcedTransfer = { from: string; to: string; amount: bigint } +export type LegalTransferOrder = { + from: string + to: string + amount: bigint + reference: string +} const SHARE_BALANCES_KEY = ["share", "balance"] as const @@ -14,7 +20,7 @@ const allowCall = (allowed: boolean) => (signer: Signer, { verifier, account }: AllowanceChange) => identityVerifierWriter(verifier)(signer).then((client) => - client.allow({ account, allowed, caller: signer.publicKey }), + client.allow({ account, allowed }), ) const freezeAddressCall = @@ -38,9 +44,9 @@ const sharesCall = }), ) -const forcedTransferCall = (signer: Signer, transfer: ForcedTransfer) => +const legalTransferCall = (signer: Signer, order: LegalTransferOrder) => shareTokenWriter(signer).then((token) => - token.forced_transfer({ ...transfer, operator: signer.publicKey }), + token.legal_transfer({ ...order, caller: signer.publicKey }), ) const allowKeys = [complianceReadsKey, isAllowedKey] @@ -61,7 +67,8 @@ export const useUnfreezeShares = adminTransaction( sharesCall("unfreeze_partial_tokens"), [complianceReadsKey], ) -export const useForceTransfer = adminTransaction(forcedTransferCall, [ +export const useLegalTransfer = adminTransaction(legalTransferCall, [ complianceReadsKey, SHARE_BALANCES_KEY, + legalTransfersKey, ]) diff --git a/app/src/hooks/useConfigurationState.test.ts b/app/src/hooks/useConfigurationState.test.ts index 01a3ec0e..345305f2 100644 --- a/app/src/hooks/useConfigurationState.test.ts +++ b/app/src/hooks/useConfigurationState.test.ts @@ -3,17 +3,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { renderWithWallet } from "./testSupport" import { useConfigurationState } from "./useConfigurationState" -const { vaultMock, tokenMock, useCycleReadsMock, useVaultAuthoritiesMock } = - vi.hoisted(() => ({ - vaultMock: { upgrade_delay: vi.fn() }, - tokenMock: { compliance: vi.fn(), identity_verifier: vi.fn() }, - useCycleReadsMock: vi.fn(), - useVaultAuthoritiesMock: vi.fn(), - })) +const { + vaultMock, + tokenMock, + oracleMock, + useCycleReadsMock, + useVaultAuthoritiesMock, +} = vi.hoisted(() => ({ + vaultMock: { upgrade_delay: vi.fn(), setup_open: vi.fn() }, + tokenMock: { + compliance: vi.fn(), + identity_verifier: vi.fn(), + setup_open: vi.fn(), + }, + oracleMock: { setup_open: vi.fn() }, + useCycleReadsMock: vi.fn(), + useVaultAuthoritiesMock: vi.fn(), +})) vi.mock("../config/clients", () => ({ asyncVault: async () => vaultMock, shareToken: async () => tokenMock, + navOracle: async () => oracleMock, })) vi.mock("./useCycleState", async (importOriginal) => ({ ...(await importOriginal()), @@ -30,7 +41,7 @@ const LIMITS = { freshness: 86_400n, cooldown: 3_600n, maxUpBps: 1_000, - maxDownBps: null, + maxDownBps: 500, min: 1n, max: 10n, } @@ -39,6 +50,7 @@ const cycleReads = { notice: { kind: "value", value: 3_600n }, depositCap: { kind: "uncapped" }, windDown: { delay: 172_800n }, + minEpochDuration: { kind: "set", secs: 86_400n }, oracle: { limits: LIMITS }, } @@ -51,8 +63,11 @@ describe("useConfigurationState", () => { isPending: false, }) vaultMock.upgrade_delay.mockResolvedValue({ result: 604_800n }) + vaultMock.setup_open.mockResolvedValue({ result: false }) tokenMock.compliance.mockResolvedValue({ result: COMPLIANCE }) tokenMock.identity_verifier.mockResolvedValue({ result: VERIFIER }) + tokenMock.setup_open.mockResolvedValue({ result: true }) + oracleMock.setup_open.mockResolvedValue({ result: true }) }) it("gathers every parameter the configuration surface can change", async () => { @@ -65,7 +80,11 @@ describe("useConfigurationState", () => { custodian: { kind: "set", address: CUSTODIAN }, notice: 3_600n, windDownDelay: 172_800n, + minEpochDuration: { kind: "set", secs: 86_400n }, upgradeDelay: 604_800n, + setupOpen: false, + tokenSetupOpen: true, + oracleSetupOpen: true, oracle: LIMITS, compliance: COMPLIANCE, identityVerifier: VERIFIER, @@ -73,6 +92,41 @@ describe("useConfigurationState", () => { ) }) + it("says setup is open while the vault reports it", async () => { + vaultMock.setup_open.mockResolvedValue({ result: true }) + const { result } = renderWithWallet(() => useConfigurationState()) + + await waitFor(() => + expect(result.current.configuration).toMatchObject({ setupOpen: true }), + ) + }) + + it("reads the share token's setup state from the token, not the vault", async () => { + vaultMock.setup_open.mockResolvedValue({ result: true }) + tokenMock.setup_open.mockResolvedValue({ result: false }) + const { result } = renderWithWallet(() => useConfigurationState()) + + await waitFor(() => + expect(result.current.configuration).toMatchObject({ + setupOpen: true, + tokenSetupOpen: false, + }), + ) + }) + + it("reads the oracle's setup state from the oracle, not the vault", async () => { + vaultMock.setup_open.mockResolvedValue({ result: true }) + oracleMock.setup_open.mockResolvedValue({ result: false }) + const { result } = renderWithWallet(() => useConfigurationState()) + + await waitFor(() => + expect(result.current.configuration).toMatchObject({ + setupOpen: true, + oracleSetupOpen: false, + }), + ) + }) + it("is checking until the cycle reads arrive", () => { useCycleReadsMock.mockReturnValue(undefined) const { result } = renderWithWallet(() => useConfigurationState()) @@ -82,7 +136,10 @@ describe("useConfigurationState", () => { it("leaves a parameter it could not read unknown, never guessed", async () => { vaultMock.upgrade_delay.mockRejectedValue(new Error("simulation failed")) + vaultMock.setup_open.mockRejectedValue(new Error("simulation failed")) tokenMock.compliance.mockRejectedValue(new Error("simulation failed")) + tokenMock.setup_open.mockRejectedValue(new Error("simulation failed")) + oracleMock.setup_open.mockRejectedValue(new Error("simulation failed")) useCycleReadsMock.mockReturnValue({ ...cycleReads, notice: { kind: "unreadable" }, @@ -94,6 +151,9 @@ describe("useConfigurationState", () => { status: "ready", notice: null, upgradeDelay: null, + setupOpen: null, + tokenSetupOpen: null, + oracleSetupOpen: null, compliance: null, identityVerifier: VERIFIER, }), diff --git a/app/src/hooks/useConfigurationState.ts b/app/src/hooks/useConfigurationState.ts index 67fa52a6..a338f8c8 100644 --- a/app/src/hooks/useConfigurationState.ts +++ b/app/src/hooks/useConfigurationState.ts @@ -3,9 +3,11 @@ import { useQuery } from "@tanstack/react-query" import { asyncVault, shareToken } from "../config/clients" import { type DepositCap, + type MinEpochDuration, type OracleLimits, useCycleReads, } from "./useCycleState" +import { type SetupState, useSetupOpen } from "./useSetupOpen" import { type Custodian, useVaultAuthorities } from "./useVaultAuthorities" const STALE_MS = 30_000 @@ -18,7 +20,11 @@ export type ConfigurationValues = { custodian: Custodian notice: bigint | null windDownDelay: bigint | null + minEpochDuration: MinEpochDuration | null upgradeDelay: bigint | null + setupOpen: boolean | null + tokenSetupOpen: boolean | null + oracleSetupOpen: boolean | null oracle: OracleLimits | null compliance: string | null identityVerifier: string | null @@ -27,6 +33,9 @@ export type ConfigurationValues = { export type ConfigurationState = { status: "checking" } | ({ status: "ready" } & ConfigurationValues) +const openOf = (setup: SetupState): boolean | null => + setup === "unreadable" ? null : setup === "open" + const valueOf = (read: ContractRead): T | null => read.kind === "value" ? read.value : null @@ -48,6 +57,9 @@ export function useConfigurationState(): { } { const cycle = useCycleReads() const { authorities, isPending } = useVaultAuthorities() + const { setup } = useSetupOpen("vault") + const { setup: tokenSetup } = useSetupOpen("token") + const { setup: oracleSetup } = useSetupOpen("oracle") const { data: reads } = useQuery({ queryKey: configurationReadsKey, queryFn: fetchConfigurationReads, @@ -55,7 +67,14 @@ export function useConfigurationState(): { refetchInterval: REFRESH_MS, }) - if (cycle === undefined || reads === undefined || isPending) + if ( + cycle === undefined || + reads === undefined || + isPending || + setup === "checking" || + tokenSetup === "checking" || + oracleSetup === "checking" + ) return { configuration: { status: "checking" } } return { @@ -65,6 +84,10 @@ export function useConfigurationState(): { custodian: authorities?.custodian ?? { kind: "unreadable" }, notice: valueOf(cycle.notice), windDownDelay: cycle.windDown.delay, + minEpochDuration: cycle.minEpochDuration, + setupOpen: openOf(setup), + tokenSetupOpen: openOf(tokenSetup), + oracleSetupOpen: openOf(oracleSetup), oracle: cycle.oracle.limits, ...reads, }, diff --git a/app/src/hooks/useConfigurationTransactions.ts b/app/src/hooks/useConfigurationTransactions.ts index 7be2785c..5296bb92 100644 --- a/app/src/hooks/useConfigurationTransactions.ts +++ b/app/src/hooks/useConfigurationTransactions.ts @@ -8,6 +8,11 @@ import { adminAuthorityKey } from "./useAdminAuthority" import { adminTransaction } from "./useAdminTransaction" import { configurationReadsKey } from "./useConfigurationState" import { cycleReadsKey } from "./useCycleState" +import { + proposeOracleChange, + proposeTokenChange, + proposeVaultChange, +} from "./useGovernanceTransactions" import { isAllowedKey } from "./useIsAllowed" import { oracleWriteKeys } from "./useOracleState" import { vaultAuthoritiesKey } from "./useVaultAuthorities" @@ -16,42 +21,70 @@ export type OracleConfig = Parameters[0]["config"] export const useSetDepositCap = adminTransaction( (signer: Signer, cap: bigint | null) => - asyncVaultWriter(signer).then((vault) => - vault.set_deposit_cap({ cap, caller: signer.publicKey }), - ), + asyncVaultWriter(signer).then((vault) => vault.set_deposit_cap({ cap })), [cycleReadsKey], ) export const useSetCustodian = adminTransaction( (signer: Signer, custodian: string) => asyncVaultWriter(signer).then((vault) => - vault.set_custodian({ custodian, caller: signer.publicKey }), + vault.set_custodian({ custodian }), ), [vaultAuthoritiesKey], ) export const useSetNotice = adminTransaction( + (signer: Signer, secs: bigint) => + asyncVaultWriter(signer).then((vault) => vault.set_notice({ secs })), + [cycleReadsKey], +) + +export const useSetWindDownDelay = adminTransaction( (signer: Signer, secs: bigint) => asyncVaultWriter(signer).then((vault) => - vault.set_notice({ secs, caller: signer.publicKey }), + vault.set_wind_down_delay({ secs }), ), [cycleReadsKey], ) -export const useSetWindDownDelay = adminTransaction( +export const useSetMinEpochDuration = adminTransaction( (signer: Signer, secs: bigint) => asyncVaultWriter(signer).then((vault) => - vault.set_wind_down_delay({ secs, caller: signer.publicKey }), + vault.set_min_epoch_duration({ secs }), ), [cycleReadsKey], ) +export const useProposeCustodian = proposeVaultChange( + (custodian: string) => ({ tag: "Custodian", values: [custodian] }), + [vaultAuthoritiesKey], +) + +export const useProposeNotice = proposeVaultChange( + (secs: bigint) => ({ tag: "Notice", values: [secs] }), + [cycleReadsKey], +) + +export const useProposeWindDownDelay = proposeVaultChange( + (secs: bigint) => ({ tag: "WindDownDelay", values: [secs] }), + [cycleReadsKey], +) + +export const useProposeMinEpochDuration = proposeVaultChange( + (secs: bigint) => ({ tag: "MinEpochDuration", values: [secs] }), + [cycleReadsKey], +) + export const useSetOracleConfig = adminTransaction( (signer: Signer, config: OracleConfig) => navOracleWriter(signer).then((oracle) => oracle.set_config({ config })), oracleWriteKeys, ) +export const useProposeOracleConfig = proposeOracleChange( + (config: OracleConfig) => ({ tag: "Config", values: [config] }), +) + export const useSetCompliance = adminTransaction( (signer: Signer, compliance: string) => shareTokenWriter(signer).then((token) => @@ -70,3 +103,13 @@ export const useSetIdentityVerifier = adminTransaction( ), [configurationReadsKey, adminAuthorityKey, isAllowedKey], ) + +export const useProposeCompliance = proposeTokenChange( + (compliance: string) => ({ tag: "Compliance", values: [compliance] }), + [configurationReadsKey], +) + +export const useProposeIdentityVerifier = proposeTokenChange( + (verifier: string) => ({ tag: "IdentityVerifier", values: [verifier] }), + [configurationReadsKey], +) diff --git a/app/src/hooks/useContractTransaction.test.ts b/app/src/hooks/useContractTransaction.test.ts index c5ce300c..36783aa0 100644 --- a/app/src/hooks/useContractTransaction.test.ts +++ b/app/src/hooks/useContractTransaction.test.ts @@ -1,10 +1,20 @@ +import { xdr } from "@stellar/stellar-sdk" import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" -import { act } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import type * as AppLib from "@stellar-scaffold/app-lib" +import { type TransactionEvent } from "@stellar-scaffold/app-lib" +import { act, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { investorAddress, renderWithWallet } from "./testSupport" import { useContractTransaction } from "./useContractTransaction" import { investorRequestsKey } from "./useInvestorRequests" +const { submitSignedMock } = vi.hoisted(() => ({ submitSignedMock: vi.fn() })) + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + submitSigned: submitSignedMock, +})) + const extraKeys = [ ["cycle", "history"], ["vault", "figures"], @@ -18,8 +28,24 @@ const transaction = (simulationError?: string) => getTransactionResponse: { status: "SUCCESS" }, result: 1n, }), + sign: vi.fn().mockResolvedValue(undefined), + signed: { signedBy: "wallet", hash: () => ({ toString: () => "abcd" }) }, + result: 1n, }) as unknown as AssembledTransaction +const surrendered = new xdr.ContractEvent({ + ext: new xdr.ExtensionPoint(0), + contractId: null, + type: xdr.ContractEventType.contract(), + body: new xdr.ContractEventBody( + 0, + new xdr.ContractEventV0({ + topics: [xdr.ScVal.scvSymbol("redemption_surrendered")], + data: xdr.ScVal.scvVoid(), + }), + ), +}) + const renderTransaction = (simulationError?: string) => renderWithWallet(() => useContractTransaction( @@ -30,6 +56,37 @@ const renderTransaction = (simulationError?: string) => ) describe("useContractTransaction", () => { + it("hands the confirmed transaction's contract events to its confirmation", async () => { + const tx = { + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { + status: "SUCCESS", + events: { + transactionEventsXdr: [], + contractEventsXdr: [[surrendered]], + }, + }, + result: 1n, + }), + } as unknown as AssembledTransaction + const { result } = renderWithWallet(() => + useContractTransaction( + async () => tx, + (_result: bigint, events: TransactionEvent[]) => ({ + names: events.map((event) => event.name), + }), + ), + ) + + await act(() => result.current.submit(undefined)) + + expect(result.current.status).toEqual({ + status: "confirmed", + names: ["redemption_surrendered"], + }) + }) + it("refreshes the extra query keys alongside the investor's once the transaction confirms", async () => { const { result, queryClient } = renderTransaction() const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") @@ -86,3 +143,381 @@ describe("useContractTransaction", () => { expect(invalidateQueries).toHaveBeenCalledTimes(3) }) }) + +const flush = () => + waitFor(() => new Promise((resolve) => setTimeout(resolve, 0))) + +const renderPrepared = ( + call: (signer: unknown, arg: bigint) => Promise>, +) => + renderWithWallet(() => + useContractTransaction(call, (result: bigint) => ({ result }), extraKeys), + ) + +describe("useContractTransaction, prepared simulations", () => { + beforeEach(() => { + submitSignedMock.mockReset() + submitSignedMock.mockResolvedValue({ + status: "success", + hash: "h", + result: 1n, + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it("signs the prepared transaction without simulating again", async () => { + const prepared = transaction() + const call = vi.fn().mockResolvedValue(prepared) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(1) + expect(prepared.sign).toHaveBeenCalledTimes(1) + expect(submitSignedMock).toHaveBeenCalledWith(prepared.signed) + expect(result.current.status).toEqual({ + status: "confirmed", + result: 1n, + hash: "h", + }) + }) + + it("simulates again when the prepared transaction is 30 seconds old", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000) + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + now.mockReturnValue(1_030_000) + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + }) + + it("simulates again when the argument differs from the prepared one", async () => { + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => result.current.submit(6n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(call).toHaveBeenLastCalledWith(expect.anything(), 6n) + }) + + it("keeps the prepared transaction when a refetch returns the same reads", async () => { + const call = vi.fn(async () => transaction()) + const { result, queryClient } = renderPrepared(call) + const read = () => + queryClient.fetchQuery({ + queryKey: [...extraKeys[0], "epoch"], + queryFn: () => ({ epoch: 4 }), + }) + await act(read) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => queryClient.refetchQueries({ queryKey: extraKeys[0] })) + await flush() + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(1) + }) + + it("simulates again in the background when a refetch changes the reads", async () => { + const first = transaction() + const second = transaction() + const call = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second) + const { result, queryClient } = renderPrepared(call) + let epoch = 4 + const queryKey = [...extraKeys[0], "epoch"] + await act(() => + queryClient.fetchQuery({ queryKey, queryFn: () => ({ epoch }) }), + ) + + act(() => { + result.current.prepare(5n) + }) + await flush() + epoch = 5 + await act(() => queryClient.refetchQueries({ queryKey })) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(first.sign).not.toHaveBeenCalled() + expect(second.sign).toHaveBeenCalledTimes(1) + }) + + it("simulates again in the background once the prepared transaction expires", async () => { + vi.useFakeTimers() + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await act(() => vi.advanceTimersByTimeAsync(0)) + await act(() => vi.advanceTimersByTimeAsync(29_999)) + + expect(call).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(1)) + + expect(call).toHaveBeenCalledTimes(2) + }) + + it("shows nothing when the background simulation fails", async () => { + const call = vi.fn().mockRejectedValue(new Error("rpc down")) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + + expect(result.current.status).toEqual({ status: "idle" }) + + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "interrupted" }, + }) + }) + + it("confirms a reused transaction with the value the network returned", async () => { + submitSignedMock.mockResolvedValueOnce({ + status: "success", + hash: "h", + result: 9n, + }) + const call = vi.fn().mockResolvedValue(transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => result.current.submit(5n)) + await flush() + + expect(result.current.status).toEqual({ + status: "confirmed", + result: 9n, + hash: "h", + }) + }) + + it("hands a reused transaction's contract events to its confirmation", async () => { + submitSignedMock.mockResolvedValueOnce({ + status: "success", + hash: "h", + result: 1n, + events: [[surrendered]], + }) + const call = vi.fn().mockResolvedValue(transaction()) + const { result } = renderWithWallet(() => + useContractTransaction( + call, + (_result: bigint, events: TransactionEvent[]) => ({ + names: events.map((event) => event.name), + }), + ), + ) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => result.current.submit(5n)) + await flush() + + expect(result.current.status).toEqual({ + status: "confirmed", + names: ["redemption_surrendered"], + hash: "h", + }) + }) + + it("carries the signed hash while a reused transaction is in flight", async () => { + let settle: (value: unknown) => void = () => {} + submitSignedMock.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve + }), + ) + const call = vi.fn().mockResolvedValue(transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(async () => { + result.current.submit(5n) + await flush() + }) + + expect(result.current.status).toEqual({ status: "submitted", hash: "abcd" }) + + await act(async () => { + settle({ status: "success", hash: "abcd", result: 1n }) + await flush() + }) + }) + + it("does not keep a background simulation the contract refused", async () => { + const call = vi + .fn() + .mockResolvedValueOnce(transaction("HostError: Error(Contract, #6046)")) + .mockResolvedValueOnce(transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + + expect(result.current.status).toEqual({ status: "idle" }) + + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(result.current.status).toEqual({ status: "confirmed", result: 1n }) + }) + + it("asks to try again when the prepared transaction's sequence was consumed", async () => { + submitSignedMock.mockResolvedValueOnce({ + status: "failed", + reason: "sequence-consumed", + hash: "h", + }) + const call = vi + .fn() + .mockResolvedValueOnce(transaction()) + .mockResolvedValueOnce(transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => result.current.submit(5n)) + await flush() + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "stale" }, + hash: "h", + }) + + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(result.current.status).toEqual({ status: "confirmed", result: 1n }) + }) + + it("keeps a released transaction reusable within 30 seconds", async () => { + vi.useFakeTimers() + const prepared = transaction() + const call = vi.fn().mockResolvedValue(prepared) + const { result } = renderPrepared(call) + + let release = () => {} + act(() => { + release = result.current.prepare(5n) + }) + await act(() => vi.advanceTimersByTimeAsync(0)) + release() + await act(() => vi.advanceTimersByTimeAsync(20_000)) + await act(() => result.current.submit(5n)) + await act(() => vi.advanceTimersByTimeAsync(0)) + + expect(call).toHaveBeenCalledTimes(1) + expect(prepared.sign).toHaveBeenCalledTimes(1) + }) + + it("does not refresh a released transaction once it expires", async () => { + vi.useFakeTimers() + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + let release = () => {} + act(() => { + release = result.current.prepare(5n) + }) + await act(() => vi.advanceTimersByTimeAsync(0)) + release() + await act(() => vi.advanceTimersByTimeAsync(30_000)) + + expect(call).toHaveBeenCalledTimes(1) + }) + + it("does not refresh a released transaction when the reads change", async () => { + const call = vi.fn(async () => transaction()) + const { result, queryClient } = renderPrepared(call) + let epoch = 4 + const queryKey = [...extraKeys[0], "epoch"] + await act(() => + queryClient.fetchQuery({ queryKey, queryFn: () => ({ epoch }) }), + ) + + let release = () => {} + act(() => { + release = result.current.prepare(5n) + }) + await flush() + release() + epoch = 5 + await act(() => queryClient.refetchQueries({ queryKey })) + await flush() + + expect(call).toHaveBeenCalledTimes(1) + }) + + it("does not refresh in the background while the page is hidden", async () => { + vi.useFakeTimers() + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden") + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await act(() => vi.advanceTimersByTimeAsync(30_000)) + + expect(call).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/src/hooks/useContractTransaction.ts b/app/src/hooks/useContractTransaction.ts index 41b4ca94..f6eb4add 100644 --- a/app/src/hooks/useContractTransaction.ts +++ b/app/src/hooks/useContractTransaction.ts @@ -2,14 +2,27 @@ import { type Transaction } from "@stellar/stellar-sdk" import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" import { decodePending, + decodeTransactionEvents, isUserRejection, OPERATOR_TIMEOUT_SECONDS, parseErrorCode, type SubmitFailure, submitSigned, + type TransactionEvent, } from "@stellar-scaffold/app-lib" -import { type QueryKey, useQueryClient } from "@tanstack/react-query" -import { useCallback, useRef, useState } from "react" +import { + partialMatchKey, + type QueryKey, + useQueryClient, +} from "@tanstack/react-query" +import { + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" import { type WriterSigner } from "../config/clients" import { type Grant, type Standing } from "./useAdminAuthority" import { depositBalanceKey } from "./useDepositBalance" @@ -20,15 +33,20 @@ import { useWallet } from "./useWallet" export type TransactionFailure = | { kind: "declined" } | { kind: "unsigned" } + | { kind: "cannot-authorize" } | { kind: "contract-error"; code: number } | { kind: "interrupted" } | { kind: "network"; reason: SubmitFailure } + | { kind: "stale" } | { kind: "unknown" } +export type SharedStanding = + Exclude | "admin-authorization" + export type SharedTransaction = { status: "shared" xdr: string - standing: Exclude + standing: SharedStanding expiresAt: Date | null } @@ -45,12 +63,32 @@ export type TransactionStatus< const NO_EXTRA_KEYS: readonly QueryKey[] = [] +const FRESH_MS = 30_000 + +type Simulated = { + key: string + tx: AssembledTransaction + at: number +} + export interface UseContractTransaction { status: TransactionStatus submit: (arg: TArg, grant?: Grant) => boolean + prepare: (arg: TArg, grant?: Grant) => Release reset: () => void } +const requestKey = ( + owner: string, + arg: unknown, + grant: Grant | undefined, +): string => + JSON.stringify( + [owner, arg ?? null, grant?.authority ?? null, grant?.standing ?? null], + (_name, value: unknown) => + typeof value === "bigint" ? `${value}n` : value, + ) + const signerFor = ( owner: string, grant: Grant | undefined, @@ -77,6 +115,19 @@ const shared = ( } } +const awaitingAuthorization = ( + tx: AssembledTransaction, +): SharedTransaction | null => { + if (tx.needsNonInvokerSigningBy().length === 0 || tx.built === undefined) + return null + return { + status: "shared", + xdr: tx.built.toEnvelope().toXDR("base64"), + standing: "admin-authorization", + expiresAt: null, + } +} + const failureOf = ( error: unknown, signatureRequested: boolean, @@ -92,6 +143,144 @@ const signedOf = (tx: AssembledTransaction): Transaction => { return tx.signed } +export type Release = () => void + +type Request = { arg: TArg; grant: Grant | undefined } + +const isPageHidden = (): boolean => document.visibilityState === "hidden" + +const isFresh = (at: number): boolean => Date.now() - at < FRESH_MS + +function usePrepared( + call: ( + signer: WriterSigner, + arg: TArg, + ) => Promise>, + owner: string | undefined, + signTransaction: WriterSigner["signTransaction"], + readKeys: readonly QueryKey[], + submitting: RefObject, +) { + const queryClient = useQueryClient() + const simulated = useRef | null>(null) + const simulating = useRef(null) + const generation = useRef(0) + const last = useRef | null>(null) + const reads = useRef(new Map()) + const expiry = useRef>(undefined) + const refresh = useRef(() => {}) + + const drop = useCallback(() => { + simulated.current = null + simulating.current = null + generation.current += 1 + clearTimeout(expiry.current) + }, []) + + const matches = useCallback( + (queryKey: QueryKey) => + readKeys.some((key) => partialMatchKey(queryKey, key)), + [readKeys], + ) + + const simulate = useCallback( + (request: Request) => { + if (owner === undefined || submitting.current) return + const key = requestKey(owner, request.arg, request.grant) + simulating.current = key + reads.current = new Map( + queryClient + .getQueryCache() + .findAll() + .filter((query) => matches(query.queryKey)) + .map((query) => [query.queryHash, query.state.data]), + ) + const started = generation.current + const at = Date.now() + const latest = () => + started === generation.current && simulating.current === key + call(signerFor(owner, request.grant, signTransaction), request.arg).then( + (tx) => { + if (!latest()) return + simulating.current = null + if (parseErrorCode(tx.simulation) !== null) return + simulated.current = { key, tx, at } + expiry.current = setTimeout( + () => refresh.current(), + Math.max(0, at + FRESH_MS - Date.now()), + ) + }, + () => { + if (latest()) simulating.current = null + }, + ) + }, + [owner, submitting, queryClient, matches, call, signTransaction], + ) + + useEffect(() => { + refresh.current = () => { + const request = last.current + drop() + if (request !== null && !isPageHidden()) simulate(request) + } + }, [drop, simulate]) + + useEffect( + () => () => { + last.current = null + drop() + }, + [drop], + ) + + useEffect( + () => + queryClient.getQueryCache().subscribe((event) => { + if (event.type !== "updated" || event.action.type !== "success") return + if (simulated.current === null && simulating.current === null) return + const { query } = event + if (!matches(query.queryKey)) return + if (reads.current.get(query.queryHash) === query.state.data) return + refresh.current() + }), + [queryClient, matches], + ) + + const prepare = useCallback( + (arg: TArg, grant?: Grant): Release => { + const request = { arg, grant } + const release = () => { + if (last.current === request) last.current = null + } + if (owner === undefined) return release + last.current = request + const key = requestKey(owner, arg, grant) + const cached = simulated.current + if (cached?.key === key && isFresh(cached.at)) return release + if (simulating.current === key) return release + drop() + simulate(request) + return release + }, + [owner, drop, simulate], + ) + + const take = useCallback( + (key: string): AssembledTransaction | undefined => { + const cached = simulated.current + last.current = null + drop() + if (cached === null || cached.key !== key || !isFresh(cached.at)) + return undefined + return cached.tx + }, + [drop], + ) + + return { prepare, take } +} + export function useContractTransaction< TArg, TResult, @@ -101,7 +290,7 @@ export function useContractTransaction< signer: WriterSigner, arg: TArg, ) => Promise>, - toConfirmed: (result: TResult) => TConfirmed, + toConfirmed: (result: TResult, events: TransactionEvent[]) => TConfirmed, extraKeys: readonly QueryKey[] = NO_EXTRA_KEYS, ): UseContractTransaction { const { address, signTransaction } = useWallet() @@ -112,9 +301,34 @@ export function useContractTransaction< const submitting = useRef(false) const dismissed = useRef(false) const lastStatus = useRef>({ status: "idle" }) + const readKeys = useMemo( + () => + address === undefined + ? extraKeys + : [ + investorRequestsKey(address), + depositBalanceKey(address), + sharePositionKey(address), + ...extraKeys, + ], + [address, extraKeys], + ) + + const { prepare, take } = usePrepared( + call, + address, + signTransaction, + readKeys, + submitting, + ) const runSubmission = useCallback( - async (arg: TArg, owner: string, grant: Grant | undefined) => { + async ( + arg: TArg, + owner: string, + grant: Grant | undefined, + reused: AssembledTransaction | undefined, + ) => { let hash: string | undefined let signatureRequested = false let reachedNetwork = false @@ -134,15 +348,15 @@ export function useContractTransaction< sharePositionKey(owner), ...extraKeys, ]) - const confirm = (result: TResult) => { + const confirm = (result: TResult, events: TransactionEvent[] = []) => { applyStatus({ status: "confirmed", - ...toConfirmed(result), + ...toConfirmed(result, events), hash, } as TransactionStatus) invalidateRequestData() } - const submitAsOperator = async ( + const signAndSubmit = async ( tx: AssembledTransaction, standing: Standing, ) => { @@ -153,19 +367,24 @@ export function useContractTransaction< return } reachedNetwork = true - applyStatus({ status: "submitted" }) + hash = signed.hash().toString("hex") + applyStatus({ status: "submitted", hash }) const sent = await submitSigned(signed) hash = sent.hash if (sent.status === "failed") { + const stale = + reused !== undefined && sent.reason === "sequence-consumed" applyStatus({ status: "failed", - failure: { kind: "network", reason: sent.reason }, + failure: stale + ? { kind: "stale" } + : { kind: "network", reason: sent.reason }, hash, }) invalidateRequestData() return } - confirm(tx.result) + confirm(sent.result as TResult, decodeTransactionEvents(sent.events)) } const signAndSend = async (tx: AssembledTransaction) => { const sent = await tx.signAndSend({ @@ -183,11 +402,17 @@ export function useContractTransaction< invalidateRequestData() return } - confirm(sent.result) + confirm( + sent.result, + decodeTransactionEvents( + sent.getTransactionResponse.events?.contractEventsXdr, + ), + ) } applyStatus({ status: "preparing" }) try { - const tx = await call(signerFor(owner, grant, signTransaction), arg) + const tx = + reused ?? (await call(signerFor(owner, grant, signTransaction), arg)) const code = parseErrorCode(tx.simulation) if (code !== null) { applyStatus({ @@ -197,10 +422,15 @@ export function useContractTransaction< invalidate(extraKeys) return } + const awaiting = grant === undefined ? null : awaitingAuthorization(tx) + if (awaiting !== null) { + applyStatus(awaiting) + return + } signatureRequested = true applyStatus({ status: "awaiting-signature" }) - if (grant === undefined) await signAndSend(tx) - else await submitAsOperator(tx, grant.standing) + if (grant === undefined && reused === undefined) await signAndSend(tx) + else await signAndSubmit(tx, grant?.standing ?? "signs-alone") } catch (error) { applyStatus({ status: "failed", @@ -225,10 +455,11 @@ export function useContractTransaction< } submitting.current = true dismissed.current = false - void runSubmission(arg, address, grant) + const reused = take(requestKey(address, arg, grant)) + void runSubmission(arg, address, grant, reused) return true }, - [address, runSubmission], + [address, runSubmission, take], ) const reset = useCallback(() => { @@ -236,5 +467,5 @@ export function useContractTransaction< setStatus({ status: "idle" }) }, []) - return { status, submit, reset } + return { status, submit, prepare, reset } } diff --git a/app/src/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts index ee5f77f5..ed89cc20 100644 --- a/app/src/hooks/useCycleState.test.ts +++ b/app/src/hooks/useCycleState.test.ts @@ -1,3 +1,4 @@ +import { Buffer } from "buffer" import type * as AppLib from "@stellar-scaffold/app-lib" import { type Amount, @@ -9,12 +10,14 @@ import { } from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { addresses } from "../config/addresses" import { renderWithWallet } from "./testSupport" import { toEpoch, toOracle, toWindDown, useCycleEvents, + useLegalTransfers, useCycleState, } from "./useCycleState" import { type EpochHistoryRead, type EpochRecord } from "./useEpochHistory" @@ -26,6 +29,8 @@ const { vaultMock, oracleMock, assetMock, readEpochsMock, readEventsMock } = notice: vi.fn(), free_reserve: vi.fn(), deposit_cap: vi.fn(), + min_epoch_duration: vi.fn(), + epoch_opened_at: vi.fn(), wind_down: vi.fn(), wind_down_delay: vi.fn(), wind_down_owed: vi.fn(), @@ -70,14 +75,16 @@ const unreadable = { kind: "unreadable" } as const const report: NavReport = { expires_at: 1_700_136_000n, nav_per_share: 10n ** 18n, - timestamp: 1_700_050_000n, + accepted_at: 1_700_050_000n, + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "https://example.com/nav.pdf", } const config = { freshness_duration: 86_400n, cooldown_secs: 3_600n, max_up_bps: 500, - max_down_bps: null, + max_down_bps: 500, min_answer: 5n * 10n ** 17n, max_answer: 2n * 10n ** 18n, } @@ -86,19 +93,20 @@ const state = (tag: OracleState["tag"]): ContractRead => value({ tag, values: undefined } as OracleState) describe("toOracle", () => { - it("carries the report, the limits and no ripcord while the price is valid", () => { + it("carries the report, the limits and no NAV pause while the price is valid", () => { expect(toOracle(state("Valid"), value(report), value(config))).toEqual({ state: "valid", price: (10n ** 18n) as Price, attestedAt: 1_700_050_000n, expiresAt: 1_700_136_000n, - ripcord: false, + proof: { hash: "ab".repeat(32), uri: "https://example.com/nav.pdf" }, + navPaused: false, recorded: true, limits: { freshness: 86_400n, cooldown: 3_600n, maxUpBps: 500, - maxDownBps: null, + maxDownBps: 500, min: 5n * 10n ** 17n, max: 2n * 10n ** 18n, }, @@ -112,9 +120,9 @@ describe("toOracle", () => { expect(oracle.expiresAt).toBe(1_700_136_000n) }) - it("reads a paused oracle as a raised ripcord", () => { + it("reads a paused oracle as a paused NAV", () => { expect( - toOracle(state("Paused"), value(report), value(config)).ripcord, + toOracle(state("Paused"), value(report), value(config)).navPaused, ).toBe(true) }) @@ -130,10 +138,11 @@ describe("toOracle", () => { price: null, attestedAt: null, expiresAt: null, + proof: null, }) }) - it("knows there is no record even while the ripcord is raised", () => { + it("knows there is no record even while the NAV is paused", () => { const oracle = toOracle( state("Paused"), { kind: "contract-error", code: 3006 }, @@ -142,7 +151,7 @@ describe("toOracle", () => { expect(oracle).toMatchObject({ state: "paused", - ripcord: true, + navPaused: true, recorded: false, price: null, attestedAt: null, @@ -155,7 +164,8 @@ describe("toOracle", () => { price: null, attestedAt: null, expiresAt: null, - ripcord: null, + proof: null, + navPaused: null, recorded: null, limits: null, }) @@ -223,7 +233,11 @@ describe("toEpoch", () => { record(2n, "Pending"), record(1n, "Fulfilled"), ]), - value(3_600n), + { + notice: value(3_600n), + minEpochDuration: null, + epochOpenedAt: null, + }, ) expect(epoch.id).toBe(4n) @@ -236,7 +250,7 @@ describe("toEpoch", () => { it("has nothing awaiting when every sealed epoch is priced", () => { const epoch = toEpoch( loaded([record(4n, "Open"), record(3n, "Fulfilled")]), - unreadable, + { notice: unreadable, minEpochDuration: null, epochOpenedAt: null }, ) expect(epoch.awaiting).toBeNull() @@ -245,7 +259,11 @@ describe("toEpoch", () => { it("cannot rule out a sealed epoch when one was unreadable", () => { expect( - toEpoch(loaded([record(4n, "Open")], [3n]), value(0n)).awaitingKnown, + toEpoch(loaded([record(4n, "Open")], [3n]), { + notice: value(0n), + minEpochDuration: null, + epochOpenedAt: null, + }).awaitingKnown, ).toBe(false) }) }) @@ -264,6 +282,8 @@ const resolveVault = () => { vaultMock.notice.mockResolvedValue({ result: 3_600n }) vaultMock.free_reserve.mockResolvedValue({ result: 900n }) vaultMock.deposit_cap.mockResolvedValue({ result: null }) + vaultMock.min_epoch_duration.mockResolvedValue({ result: null }) + vaultMock.epoch_opened_at.mockResolvedValue({ result: 1_700_000_000n }) vaultMock.wind_down.mockResolvedValue({ result: null }) vaultMock.wind_down_delay.mockResolvedValue({ result: 60n }) vaultMock.wind_down_owed.mockResolvedValue({ result: 0n }) @@ -315,6 +335,38 @@ describe("deposit cap", () => { }) }) +describe("minimum epoch duration", () => { + beforeEach(() => { + resolveVault() + }) + + it.each<[string, bigint | null, object]>([ + ["null as no minimum", null, { kind: "unset" }], + ["a duration as the minimum", 86_400n, { kind: "set", secs: 86_400n }], + ])( + "reads %s, with when the open epoch opened", + async (_name, result, expected) => { + vaultMock.min_epoch_duration.mockResolvedValue({ result }) + + const { result: hook } = renderWithWallet(useCycleState) + await waitFor(() => expect(hook.current.cycle.status).toBe("ready")) + + expect(hook.current.cycle).toMatchObject({ + epoch: { minDuration: expected, openedAt: 1_700_000_000n }, + }) + }, + ) + + it("leaves a failed read as unavailable", async () => { + vaultMock.min_epoch_duration.mockRejectedValue(new Error("network down")) + + const { result: hook } = renderWithWallet(useCycleState) + await waitFor(() => expect(hook.current.cycle.status).toBe("ready")) + + expect(hook.current.cycle).toMatchObject({ epoch: { minDuration: null } }) + }) +}) + describe("useCycleState", () => { beforeEach(() => { vi.clearAllMocks() @@ -460,3 +512,47 @@ describe("useCycleEvents", () => { ) }) }) + +describe("useLegalTransfers", () => { + beforeEach(() => { + readEventsMock.mockReset() + }) + + it("asks for the share token's legal transfers over the last seven days of ledgers", async () => { + readEventsMock.mockResolvedValue({ + kind: "value", + value: [], + partial: false, + }) + + const { result } = renderWithWallet(useLegalTransfers) + await waitFor(() => + expect(result.current.legalTransfers).toEqual({ + status: "loaded", + events: [], + partial: false, + }), + ) + + expect(readEventsMock).toHaveBeenCalledWith( + [ + { + contractId: addresses.share_token, + names: ["legal_transfer"], + topics: 2, + }, + ], + 120_960, + ) + }) + + it("is unreadable when the RPC cannot serve the events", async () => { + readEventsMock.mockResolvedValue(unreadable) + + const { result } = renderWithWallet(useLegalTransfers) + + await waitFor(() => + expect(result.current.legalTransfers).toEqual({ status: "unreadable" }), + ) + }) +}) diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index ed3e02e5..6165932a 100644 --- a/app/src/hooks/useCycleState.ts +++ b/app/src/hooks/useCycleState.ts @@ -1,7 +1,9 @@ +import { Buffer } from "buffer" import { type Amount, type ChainEvent, type ContractRead, + type EventSelection, type NavReport, type OracleState, type Price, @@ -48,7 +50,7 @@ export type OracleLimits = { freshness: bigint cooldown: bigint maxUpBps: number - maxDownBps: number | null + maxDownBps: number min: bigint max: bigint } @@ -58,7 +60,8 @@ export type CycleOracle = { price: Price | null attestedAt: bigint | null expiresAt: bigint | null - ripcord: boolean | null + proof: { hash: string; uri: string } | null + navPaused: boolean | null recorded: boolean | null limits: OracleLimits | null } @@ -66,6 +69,8 @@ export type CycleOracle = { export type DepositCap = { kind: "uncapped" } | { kind: "capped"; amount: Amount } +export type MinEpochDuration = { kind: "unset" } | { kind: "set"; secs: bigint } + export type WindDownPhase = "none" | "proposed" | "active" export type CycleWindDown = { @@ -94,6 +99,8 @@ export type CycleEpoch = { awaiting: EpochRecord | null awaitingKnown: boolean noticeSeconds: bigint | null + minDuration: MinEpochDuration | null + openedAt: bigint | null } export type CycleState = @@ -128,7 +135,7 @@ export function toOracle( freshness_duration: bigint cooldown_secs: bigint max_up_bps: number - max_down_bps: number | null + max_down_bps: number min_answer: bigint max_answer: bigint }>, @@ -139,9 +146,16 @@ export function toOracle( return { state: nav.status, price: report === null ? null : (report.nav_per_share as Price), - attestedAt: report?.timestamp ?? null, + attestedAt: report?.accepted_at ?? null, expiresAt: report?.expires_at ?? null, - ripcord: nav.status === "unreadable" ? null : nav.status === "paused", + proof: + report === null + ? null + : { + hash: Buffer.from(report.proof_hash).toString("hex"), + uri: report.proof_uri, + }, + navPaused: nav.status === "unreadable" ? null : nav.status === "paused", recorded: recordedOf(latest), limits: config.kind === "value" @@ -185,6 +199,15 @@ export function toWindDown( } } +const toMinEpochDuration = ( + read: ContractRead, +): MinEpochDuration | null => { + if (read.kind !== "value") return null + return read.value === null + ? { kind: "unset" } + : { kind: "set", secs: read.value } +} + const toDepositCap = (read: ContractRead): DepositCap | null => { if (read.kind !== "value") return null return read.value === null @@ -194,8 +217,9 @@ const toDepositCap = (read: ContractRead): DepositCap | null => { export function toEpoch( history: Extract, - notice: ContractRead, + reads: Pick, ): CycleEpoch { + const { notice } = reads const sealed = history.epochs.filter((epoch) => epoch.status === "Pending") return { id: history.currentEpoch, @@ -204,6 +228,8 @@ export function toEpoch( awaiting: sealed.at(-1) ?? null, awaitingKnown: history.unreadable.length === 0, noticeSeconds: notice.kind === "value" ? notice.value : null, + minDuration: reads.minEpochDuration, + openedAt: reads.epochOpenedAt, } } @@ -212,6 +238,8 @@ export type CycleReads = { notice: ContractRead freeReserve: Amount | null depositCap: DepositCap | null + minEpochDuration: MinEpochDuration | null + epochOpenedAt: bigint | null windDown: CycleWindDown oracle: CycleOracle } @@ -222,6 +250,8 @@ async function fetchCycleReads(): Promise { notice, freeReserve, depositCap, + minEpochDuration, + epochOpenedAt, proposal, delay, owed, @@ -234,6 +264,8 @@ async function fetchCycleReads(): Promise { readContract(async () => (await asyncVault()).notice()), readContract(async () => (await asyncVault()).free_reserve()), readContract(async () => (await asyncVault()).deposit_cap()), + readContract(async () => (await asyncVault()).min_epoch_duration()), + readContract(async () => (await asyncVault()).epoch_opened_at()), readContract(async () => (await asyncVault()).wind_down()), readContract(async () => (await asyncVault()).wind_down_delay()), readContract(async () => (await asyncVault()).wind_down_owed()), @@ -248,6 +280,8 @@ async function fetchCycleReads(): Promise { notice, freeReserve: toAmount(freeReserve), depositCap: toDepositCap(depositCap), + minEpochDuration: toMinEpochDuration(minEpochDuration), + epochOpenedAt: epochOpenedAt.kind === "value" ? epochOpenedAt.value : null, windDown: toWindDown(proposal, delay, owed, supply), oracle: toOracle(oracleState, latest, config), } @@ -306,7 +340,7 @@ export function useCycleState(): { cycle: { status: "ready", ledgerTime: reads.ledgerTime, - epoch: toEpoch(history, reads.notice), + epoch: toEpoch(history, reads), oracle: reads.oracle, reserve: { free: reads.freeReserve, @@ -329,19 +363,26 @@ export type CycleEventsRead = | { status: "unreadable" } | { status: "loaded"; events: ChainEvent[]; partial: boolean } -async function fetchCycleEvents(): Promise { - const read = await readEvents( - [ - { contractId: addresses.async_vault, names: VAULT_EVENTS }, - { contractId: addresses.nav_oracle, names: ORACLE_EVENTS }, - ], - LEDGERS_IN_SEVEN_DAYS, - ) +async function fetchEvents( + selections: EventSelection[], +): Promise { + const read = await readEvents(selections, LEDGERS_IN_SEVEN_DAYS) return read.kind === "value" ? { status: "loaded", events: read.value, partial: read.partial } : { status: "unreadable" } } +const fetchCycleEvents = () => + fetchEvents([ + { contractId: addresses.async_vault, names: VAULT_EVENTS }, + { contractId: addresses.nav_oracle, names: ORACLE_EVENTS }, + ]) + +const fetchLegalTransfers = () => + fetchEvents([ + { contractId: addresses.share_token, names: ["legal_transfer"], topics: 2 }, + ]) + export function useCycleEvents(): { cycleEvents: CycleEventsRead } { const { data } = useQuery({ queryKey: cycleEventsKey, @@ -351,3 +392,15 @@ export function useCycleEvents(): { cycleEvents: CycleEventsRead } { return { cycleEvents: data ?? { status: "checking" } } } + +export const legalTransfersKey = ["compliance", "legal-transfers"] as const + +export function useLegalTransfers(): { legalTransfers: CycleEventsRead } { + const { data } = useQuery({ + queryKey: legalTransfersKey, + queryFn: fetchLegalTransfers, + staleTime: STALE_MS, + }) + + return { legalTransfers: data ?? { status: "checking" } } +} diff --git a/app/src/hooks/useDeployToCustodian.ts b/app/src/hooks/useDeployToCustodian.ts index 779c061b..0449864a 100644 --- a/app/src/hooks/useDeployToCustodian.ts +++ b/app/src/hooks/useDeployToCustodian.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { custodianBalanceKey, cycleWriteKeys } from "./useCycleState" @@ -12,6 +13,7 @@ export type DeployToCustodianStatus = TransactionStatus export interface UseDeployToCustodian { status: DeployToCustodianStatus submit: (assets: Amount) => boolean + prepare: (assets: Amount) => Release reset: () => void } diff --git a/app/src/hooks/useEmergencyTransactions.ts b/app/src/hooks/useEmergencyTransactions.ts index d78e1a22..66f61493 100644 --- a/app/src/hooks/useEmergencyTransactions.ts +++ b/app/src/hooks/useEmergencyTransactions.ts @@ -19,17 +19,17 @@ const tokenCall = (method: "pause" | "unpause") => (signer: Signer) => token[method]({ caller: signer.publicKey }), ) -const oracleCall = - (method: "raise_ripcord" | "clear_latest") => (signer: Signer) => - navOracleWriter(signer).then((oracle) => - oracle[method]({ caller: signer.publicKey }), - ) - -const setRipcordCall = (signer: Signer, paused: boolean) => +const pauseNavCall = (signer: Signer) => navOracleWriter(signer).then((oracle) => - oracle.set_ripcord({ paused, caller: signer.publicKey }), + oracle.pause_nav({ caller: signer.publicKey }), ) +const clearLatestCall = (signer: Signer) => + navOracleWriter(signer).then((oracle) => oracle.clear_latest()) + +const setNavPausedCall = (signer: Signer, paused: boolean) => + navOracleWriter(signer).then((oracle) => oracle.set_nav_paused({ paused })) + export const usePauseVault = adminTransaction(vaultCall("pause"), [ vaultPausedKey, ]) @@ -42,12 +42,9 @@ export const usePauseToken = adminTransaction(tokenCall("pause"), [ export const useUnpauseToken = adminTransaction(tokenCall("unpause"), [ tokenPausedKey, ]) -export const useRaiseRipcord = adminTransaction( - oracleCall("raise_ripcord"), - oracleWriteKeys, -) -export const useClearLatest = adminTransaction( - oracleCall("clear_latest"), +export const usePauseNav = adminTransaction(pauseNavCall, oracleWriteKeys) +export const useClearLatest = adminTransaction(clearLatestCall, oracleWriteKeys) +export const useSetNavPaused = adminTransaction( + setNavPausedCall, oracleWriteKeys, ) -export const useSetRipcord = adminTransaction(setRipcordCall, oracleWriteKeys) diff --git a/app/src/hooks/useFinalizeWindDownRound.ts b/app/src/hooks/useFinalizeWindDownRound.ts index 34223c7c..c60d9739 100644 --- a/app/src/hooks/useFinalizeWindDownRound.ts +++ b/app/src/hooks/useFinalizeWindDownRound.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" @@ -14,6 +15,7 @@ export type FinalizeWindDownRoundStatus = TransactionStatus<{ export interface UseFinalizeWindDownRound { status: FinalizeWindDownRoundStatus submit: () => boolean + prepare: () => Release reset: () => void } diff --git a/app/src/hooks/useFulfillEpoch.ts b/app/src/hooks/useFulfillEpoch.ts index 03322d97..3ab3e9d7 100644 --- a/app/src/hooks/useFulfillEpoch.ts +++ b/app/src/hooks/useFulfillEpoch.ts @@ -2,8 +2,9 @@ import { type Price, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" @@ -12,6 +13,7 @@ export type FulfillEpochStatus = TransactionStatus<{ sharePrice: Price }> export interface UseFulfillEpoch { status: FulfillEpochStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts index 7803d49d..0b5f1861 100644 --- a/app/src/hooks/useFund.test.ts +++ b/app/src/hooks/useFund.test.ts @@ -78,7 +78,7 @@ describe("useFund", () => { ) expect(vaultMock.fund).toHaveBeenCalledWith({ - from: investorAddress, + caller: investorAddress, assets, }) }) diff --git a/app/src/hooks/useFund.ts b/app/src/hooks/useFund.ts index 4c5e2b30..d97fd18e 100644 --- a/app/src/hooks/useFund.ts +++ b/app/src/hooks/useFund.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback, useMemo } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { custodianBalanceKey, cycleWriteKeys } from "./useCycleState" import { depositBalanceKey } from "./useDepositBalance" @@ -14,6 +15,7 @@ export type FundStatus = TransactionStatus export interface UseFund { status: FundStatus submit: (assets: Amount) => boolean + prepare: (assets: Amount) => Release reset: () => void } @@ -29,7 +31,7 @@ export function useFund(): UseFund { const call = useCallback( (signer: Signer, assets: Amount) => asyncVaultWriter(signer).then((vault) => - vault.fund({ from: signer.publicKey, assets }), + vault.fund({ caller: signer.publicKey, assets }), ), [], ) diff --git a/app/src/hooks/useGovernanceState.test.ts b/app/src/hooks/useGovernanceState.test.ts index 2550246d..86e135a1 100644 --- a/app/src/hooks/useGovernanceState.test.ts +++ b/app/src/hooks/useGovernanceState.test.ts @@ -9,18 +9,26 @@ import { useGovernanceState } from "./useGovernanceState" const { readPendingAdminsMock, vaultMock, + tokenMock, + oracleMock, useCycleReadsMock, useConfigurationStateMock, useVaultPausedMock, } = vi.hoisted(() => ({ readPendingAdminsMock: vi.fn(), - vaultMock: { upgrade_proposal: vi.fn() }, + vaultMock: { pending_changes: vi.fn() }, + tokenMock: { pending_changes: vi.fn() }, + oracleMock: { pending_changes: vi.fn() }, useCycleReadsMock: vi.fn(), useConfigurationStateMock: vi.fn(), useVaultPausedMock: vi.fn(), })) -vi.mock("../config/clients", () => ({ asyncVault: async () => vaultMock })) +vi.mock("../config/clients", () => ({ + asyncVault: async () => vaultMock, + shareToken: async () => tokenMock, + navOracle: async () => oracleMock, +})) vi.mock("./useCycleState", async (importOriginal) => ({ ...(await importOriginal()), useCycleReads: useCycleReadsMock, @@ -70,7 +78,9 @@ describe("useGovernanceState", () => { beforeEach(() => { vi.clearAllMocks() readPendingAdminsMock.mockResolvedValue({ kind: "unreadable" }) - vaultMock.upgrade_proposal.mockResolvedValue({ result: null }) + vaultMock.pending_changes.mockResolvedValue({ result: [] }) + tokenMock.pending_changes.mockResolvedValue({ result: [] }) + oracleMock.pending_changes.mockResolvedValue({ result: [] }) useCycleReadsMock.mockReturnValue({ ledgerTime: 1_800_000_000n, windDown: WIND_DOWN, @@ -80,6 +90,9 @@ describe("useGovernanceState", () => { status: "ready", upgradeDelay: 7n * DAY, notice: DAY, + setupOpen: false, + tokenSetupOpen: true, + oracleSetupOpen: false, }, }) useVaultPausedMock.mockReturnValue({ pause: "open" }) @@ -113,7 +126,9 @@ describe("useGovernanceState", () => { useGovernanceState({ contracts: undefined }), ) - await waitFor(() => expect(result.current.upgrades.status).toBe("ready")) + await waitFor(() => + expect(result.current.upgrades.vault.status).toBe("ready"), + ) expect(result.current.handovers).toEqual({ status: "checking" }) expect(readPendingAdminsMock).not.toHaveBeenCalled() }) @@ -145,10 +160,13 @@ describe("useGovernanceState", () => { it("gathers the upgrade delay, notice, wind-down, ledger time and pause", async () => { const { result } = render() - await waitFor(() => expect(result.current.upgrades.status).toBe("ready")) - expect(result.current.upgrades).toEqual({ + await waitFor(() => + expect(result.current.upgrades.vault.status).toBe("ready"), + ) + expect(result.current.upgrades.vault).toEqual({ status: "ready", - proposal: null, + pending: [], + setupOpen: false, delay: 7n * DAY, notice: DAY, windDown: WIND_DOWN, @@ -157,34 +175,95 @@ describe("useGovernanceState", () => { }) }) - it.each([ - [ - { action: { tag: "Wasm", values: [Buffer.alloc(32, 0xab)] }, eta: 5n }, - { kind: "code", hash: "ab".repeat(32), eta: 5n }, - ], - [ - { action: { tag: "Delay", values: [14n * DAY] }, eta: 5n }, - { kind: "delay", secs: 14n * DAY, eta: 5n }, - ], - ])("decodes a pending upgrade proposal", async (raw, proposal) => { - vaultMock.upgrade_proposal.mockResolvedValue({ result: raw }) + it("reads every pending change as the vault reports it", async () => { + const pending = [ + { + id: 2, + change: { tag: "Upgrade", values: [Buffer.alloc(32, 0xab)] }, + eta: 5n, + }, + { + id: 3, + change: { tag: "GrantRole", values: ["manager", "G1"] }, + eta: 6n, + }, + ] + vaultMock.pending_changes.mockResolvedValue({ result: pending }) const { result } = render() - await waitFor(() => expect(result.current.upgrades.status).toBe("ready")) - expect(result.current.upgrades).toMatchObject({ proposal }) + await waitFor(() => + expect(result.current.upgrades.vault.status).toBe("ready"), + ) + expect(result.current.upgrades.vault).toMatchObject({ pending }) }) - it("keeps an unreadable proposal unknown and waits for the configuration", async () => { - vaultMock.upgrade_proposal.mockRejectedValue(new Error("down")) + it("keeps unreadable pending changes unknown and waits for the configuration", async () => { + vaultMock.pending_changes.mockRejectedValue(new Error("down")) const { result, rerender } = render() - await waitFor(() => expect(result.current.upgrades.status).toBe("ready")) - expect(result.current.upgrades).toMatchObject({ proposal: undefined }) + await waitFor(() => + expect(result.current.upgrades.vault.status).toBe("ready"), + ) + expect(result.current.upgrades.vault).toMatchObject({ pending: undefined }) useConfigurationStateMock.mockReturnValue({ configuration: { status: "checking" }, }) rerender() - expect(result.current.upgrades).toEqual({ status: "checking" }) + expect(result.current.upgrades.vault).toEqual({ status: "checking" }) + expect(result.current.upgrades.token).toEqual({ status: "checking" }) + }) + + it("reads the share token's own queue and setup state on the vault's clock", async () => { + const pending = [ + { + id: 1, + change: { tag: "Compliance", values: ["CCOMPLIANCE"] }, + eta: 7n, + }, + ] + tokenMock.pending_changes.mockResolvedValue({ result: pending }) + const { result } = render() + + await waitFor(() => + expect(result.current.upgrades.token.status).toBe("ready"), + ) + expect(result.current.upgrades.token).toEqual({ + status: "ready", + pending, + setupOpen: true, + delay: 7n * DAY, + notice: DAY, + windDown: WIND_DOWN, + now: 1_800_000_000n, + paused: "open", + }) + expect(result.current.upgrades.vault).toMatchObject({ + pending: [], + setupOpen: false, + }) + }) + + it("reads the oracle's own queue and setup state on the vault's clock", async () => { + const pending = [ + { id: 1, change: { tag: "Admin", values: ["GNEXT"] }, eta: 8n }, + ] + oracleMock.pending_changes.mockResolvedValue({ result: pending }) + const { result } = render() + + await waitFor(() => + expect(result.current.upgrades.oracle.status).toBe("ready"), + ) + expect(result.current.upgrades.oracle).toEqual({ + status: "ready", + pending, + setupOpen: false, + delay: 7n * DAY, + notice: DAY, + windDown: WIND_DOWN, + now: 1_800_000_000n, + paused: "open", + }) + expect(result.current.upgrades.token).toMatchObject({ pending: [] }) }) }) diff --git a/app/src/hooks/useGovernanceState.ts b/app/src/hooks/useGovernanceState.ts index 815c4835..e0340835 100644 --- a/app/src/hooks/useGovernanceState.ts +++ b/app/src/hooks/useGovernanceState.ts @@ -5,16 +5,25 @@ import { readContract, } from "@stellar-scaffold/app-lib" import { useQuery } from "@tanstack/react-query" -import { asyncVault } from "../config/clients" +import { asyncVault, navOracle, shareToken } from "../config/clients" +import { type PendingChange } from "../pages/pendingChanges" import { type GovernedContract, pendingAdminsQuery } from "./pendingAdmins" import { useConfigurationState } from "./useConfigurationState" import { type CycleWindDown, useCycleReads } from "./useCycleState" +import { type TimelockedContract } from "./useSetupOpen" import { type PauseState, useVaultPaused } from "./useVaultPaused" const STALE_MS = 30_000 const REFRESH_MS = 15_000 -export const upgradeProposalKey = ["governance", "upgradeProposal"] as const +const PENDING_CHANGES_KEYS = { + vault: ["governance", "pendingChanges"], + token: ["governance", "tokenPendingChanges"], + oracle: ["governance", "oraclePendingChanges"], +} as const + +export const pendingChangesKey = (contract: C) => + PENDING_CHANGES_KEYS[contract] export type Handovers = | { status: "checking" } @@ -25,15 +34,12 @@ export type Handovers = pending: Record } -export type PendingUpgrade = - | { kind: "code"; hash: string; eta: bigint } - | { kind: "delay"; secs: bigint; eta: bigint } - export type Upgrades = | { status: "checking" } | { status: "ready" - proposal: PendingUpgrade | null | undefined + pending: PendingChange[] | undefined + setupOpen: boolean | null delay: bigint | null notice: bigint | null windDown: CycleWindDown @@ -41,47 +47,71 @@ export type Upgrades = paused: PauseState } -type ProposalRead = { proposal: PendingUpgrade | null | undefined } +type PendingRead = { pending: PendingChange[] | undefined } -async function fetchUpgradeProposal(): Promise { - const read = await readContract(async () => - (await asyncVault()).upgrade_proposal(), - ) - if (read.kind !== "value") return { proposal: undefined } - if (read.value === null) return { proposal: null } - const { action, eta } = read.value - return { - proposal: - action.tag === "Wasm" - ? { kind: "code", hash: action.values[0].toString("hex"), eta } - : { kind: "delay", secs: action.values[0], eta }, - } +const PENDING_READERS = { + vault: async () => (await asyncVault()).pending_changes(), + token: async () => (await shareToken()).pending_changes(), + oracle: async () => (await navOracle()).pending_changes(), +} satisfies Record + +async function fetchPendingChanges( + contract: TimelockedContract, +): Promise { + const read = await readContract(PENDING_READERS[contract]) + return { pending: read.kind === "value" ? read.value : undefined } } -function useUpgrades(): Upgrades { +export const pendingChangesQuery = (contract: TimelockedContract) => ({ + queryKey: pendingChangesKey(contract), + queryFn: () => fetchPendingChanges(contract), +}) + +const SETUP_FLAGS = { + vault: "setupOpen", + token: "tokenSetupOpen", + oracle: "oracleSetupOpen", +} as const satisfies Record + +const usePendingChanges = (contract: TimelockedContract) => + useQuery({ + ...pendingChangesQuery(contract), + staleTime: STALE_MS, + refetchInterval: REFRESH_MS, + }).data + +function useUpgrades(): Record { const cycle = useCycleReads() const { configuration } = useConfigurationState() const { pause } = useVaultPaused({ poll: true }) - const { data } = useQuery({ - queryKey: upgradeProposalKey, - queryFn: fetchUpgradeProposal, - staleTime: STALE_MS, - refetchInterval: REFRESH_MS, - }) - if ( - cycle === undefined || - data === undefined || - configuration.status !== "ready" - ) - return { status: "checking" } + const reads = { + vault: usePendingChanges("vault"), + token: usePendingChanges("token"), + oracle: usePendingChanges("oracle"), + } + const upgradesOf = (contract: TimelockedContract): Upgrades => { + const data = reads[contract] + if ( + cycle === undefined || + data === undefined || + configuration.status !== "ready" + ) + return { status: "checking" } + return { + status: "ready", + pending: data.pending, + setupOpen: configuration[SETUP_FLAGS[contract]], + delay: configuration.upgradeDelay, + notice: configuration.notice, + windDown: cycle.windDown, + now: cycle.ledgerTime, + paused: pause, + } + } return { - status: "ready", - proposal: data.proposal, - delay: configuration.upgradeDelay, - notice: configuration.notice, - windDown: cycle.windDown, - now: cycle.ledgerTime, - paused: pause, + vault: upgradesOf("vault"), + token: upgradesOf("token"), + oracle: upgradesOf("oracle"), } } @@ -106,7 +136,10 @@ export function useGovernanceState({ contracts, }: { contracts: Record | undefined -}): { handovers: Handovers; upgrades: Upgrades } { +}): { + handovers: Handovers + upgrades: Record +} { const { data, isError } = useQuery({ ...pendingAdminsQuery(contracts), refetchInterval: REFRESH_MS, diff --git a/app/src/hooks/useGovernanceTransactions.ts b/app/src/hooks/useGovernanceTransactions.ts index 0c09b476..7aaca879 100644 --- a/app/src/hooks/useGovernanceTransactions.ts +++ b/app/src/hooks/useGovernanceTransactions.ts @@ -1,5 +1,12 @@ import { Buffer } from "buffer" -import { type AsyncVaultApi, type Signer } from "@stellar-scaffold/app-lib" +import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { + type AsyncVaultApi, + type OracleChange, + type Signer, + type TokenChange, + type VaultChange, +} from "@stellar-scaffold/app-lib" import { type QueryKey } from "@tanstack/react-query" import { asyncVaultWriter, @@ -11,11 +18,59 @@ import { adminAuthorityKey } from "./useAdminAuthority" import { adminTransaction } from "./useAdminTransaction" import { configurationReadsKey } from "./useConfigurationState" import { cycleReadsKey } from "./useCycleState" -import { upgradeProposalKey } from "./useGovernanceState" +import { pendingChangesKey } from "./useGovernanceState" +import { isAllowedKey } from "./useIsAllowed" +import { oracleWriteKeys } from "./useOracleState" +import { type TimelockedContract } from "./useSetupOpen" import { vaultAuthoritiesKey } from "./useVaultAuthorities" export type RoleChange = { role: string; account: string } +type ChangeClient = Pick< + AsyncVaultApi, + "apply_change" | "cancel_change" +> & { + propose_change: (args: { + change: Change + }) => Promise> +} + +const timelock = ( + contract: TimelockedContract, + connect: (signer: Signer) => Promise>, +) => ({ + propose: ( + toChange: (arg: TArg) => Change, + keys: readonly QueryKey[] = [], + ) => + adminTransaction( + (signer: Signer, arg: TArg) => + connect(signer).then((client) => + client.propose_change({ change: toChange(arg) }), + ), + [pendingChangesKey(contract), ...keys], + ), + apply: (keys: readonly QueryKey[]) => + adminTransaction( + (signer: Signer, id: number) => + connect(signer).then((client) => client.apply_change({ id })), + [pendingChangesKey(contract), ...keys], + ), + cancel: adminTransaction( + (signer: Signer, id: number) => + connect(signer).then((client) => client.cancel_change({ id })), + [pendingChangesKey(contract)], + ), +}) + +const vaultTimelock = timelock("vault", asyncVaultWriter) +const tokenTimelock = timelock("token", shareTokenWriter) +const oracleTimelock = timelock("oracle", navOracleWriter) + +export const proposeVaultChange = vaultTimelock.propose +export const proposeTokenChange = tokenTimelock.propose +export const proposeOracleChange = oracleTimelock.propose + type RoleClient = Pick const roleChange = ( @@ -112,55 +167,73 @@ export const useCancelTokenHandover = offerAdmin( ) export const useAcceptTokenAdmin = acceptAdmin(shareTokenWriter, HANDOVER_KEYS) -const UPGRADE_KEYS = [upgradeProposalKey, configurationReadsKey] +const upgrade = (hash: string) => ({ + tag: "Upgrade" as const, + values: [Buffer.from(hash, "hex")] as const, +}) -export const useProposeCodeUpgrade = adminTransaction( - (signer: Signer, hash: string) => - asyncVaultWriter(signer).then((vault) => - vault.propose_upgrade({ - wasm_hash: Buffer.from(hash, "hex"), - caller: signer.publicKey, - }), - ), - UPGRADE_KEYS, -) +export const useProposeCodeUpgrade = proposeVaultChange(upgrade, [ + configurationReadsKey, +]) -export const useProposeUpgradeDelay = adminTransaction( - (signer: Signer, secs: bigint) => - asyncVaultWriter(signer).then((vault) => - vault.propose_upgrade_delay({ secs, caller: signer.publicKey }), - ), - UPGRADE_KEYS, +export const useProposeUpgradeDelay = proposeVaultChange( + (secs: bigint) => ({ tag: "UpgradeDelay", values: [secs] }), + [configurationReadsKey], ) -export const useApplyUpgrade = adminTransaction( - (signer: Signer) => - asyncVaultWriter(signer).then((vault) => - vault.apply_upgrade({ caller: signer.publicKey }), - ), - UPGRADE_KEYS, -) +const grantRole = ({ role, account }: RoleChange) => ({ + tag: "GrantRole" as const, + values: [role, account] as const, +}) + +const revokeRole = ({ role, account }: RoleChange) => ({ + tag: "RevokeRole" as const, + values: [role, account] as const, +}) + +const admin = (account: string) => ({ + tag: "Admin" as const, + values: [account] as const, +}) + +export const useProposeGrantRole = proposeVaultChange(grantRole) +export const useProposeRevokeRole = proposeVaultChange(revokeRole) +export const useProposeAdmin = proposeVaultChange(admin) +export const useProposeTokenGrantRole = proposeTokenChange(grantRole) +export const useProposeTokenRevokeRole = proposeTokenChange(revokeRole) +export const useProposeTokenAdmin = proposeTokenChange(admin) +export const useProposeOracleGrantRole = proposeOracleChange(grantRole) +export const useProposeOracleAdmin = proposeOracleChange(admin) +export const useProposeOracleUpgrade = proposeOracleChange(upgrade) + +export const useApplyChange = vaultTimelock.apply([ + configurationReadsKey, + cycleReadsKey, + ...VAULT_HANDOVER_KEYS, +]) +export const useCancelChange = vaultTimelock.cancel -export const useCancelUpgrade = adminTransaction( - (signer: Signer) => - asyncVaultWriter(signer).then((vault) => - vault.cancel_upgrade({ caller: signer.publicKey }), - ), - UPGRADE_KEYS, -) +export const useApplyTokenChange = tokenTimelock.apply([ + configurationReadsKey, + ...HANDOVER_KEYS, + isAllowedKey, +]) +export const useCancelTokenChange = tokenTimelock.cancel + +export const useApplyOracleChange = oracleTimelock.apply([ + ...HANDOVER_KEYS, + ...oracleWriteKeys, +]) +export const useCancelOracleChange = oracleTimelock.cancel export const useProposeWindDown = adminTransaction( (signer: Signer) => - asyncVaultWriter(signer).then((vault) => - vault.propose_wind_down({ caller: signer.publicKey }), - ), + asyncVaultWriter(signer).then((vault) => vault.propose_wind_down()), [cycleReadsKey], ) export const useCancelWindDown = adminTransaction( (signer: Signer) => - asyncVaultWriter(signer).then((vault) => - vault.cancel_wind_down_proposal({ caller: signer.publicKey }), - ), + asyncVaultWriter(signer).then((vault) => vault.cancel_wind_down_proposal()), [cycleReadsKey], ) diff --git a/app/src/hooks/useHolderFrozen.test.ts b/app/src/hooks/useHolderFrozen.test.ts new file mode 100644 index 00000000..02dfaea6 --- /dev/null +++ b/app/src/hooks/useHolderFrozen.test.ts @@ -0,0 +1,31 @@ +import { waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { investorAddress, renderWithWallet } from "./testSupport" +import { useHolderFrozen } from "./useHolderFrozen" + +const { isFrozenMock } = vi.hoisted(() => ({ + isFrozenMock: vi.fn(async (_args: { user_address: string }) => ({ + result: true, + })), +})) + +vi.mock("../config/clients", () => ({ + shareToken: async () => ({ is_frozen: isFrozenMock }), +})) + +describe("useHolderFrozen", () => { + it("asks the share token whether the connected wallet is frozen", async () => { + const { result } = renderWithWallet(useHolderFrozen) + + await waitFor(() => expect(result.current.frozen).toBe(true)) + expect(isFrozenMock).toHaveBeenCalledWith({ user_address: investorAddress }) + }) + + it("reports an unreadable freeze as unknown, never as unfrozen", async () => { + isFrozenMock.mockRejectedValueOnce(new Error("rpc down")) + const { result } = renderWithWallet(useHolderFrozen) + + await waitFor(() => expect(isFrozenMock).toHaveBeenCalled()) + expect(result.current.frozen).toBeNull() + }) +}) diff --git a/app/src/hooks/useHolderFrozen.ts b/app/src/hooks/useHolderFrozen.ts new file mode 100644 index 00000000..b2bdf56f --- /dev/null +++ b/app/src/hooks/useHolderFrozen.ts @@ -0,0 +1,25 @@ +import { readContract } from "@stellar-scaffold/app-lib" +import { skipToken, useQuery } from "@tanstack/react-query" +import { shareToken } from "../config/clients" +import { useWallet } from "./useWallet" + +export const holderFrozenKey = (address: string | undefined) => + ["token", "frozen", address] as const + +async function fetchFrozen(address: string): Promise { + const read = await readContract(async () => + (await shareToken()).is_frozen({ user_address: address }), + ) + return read.kind === "value" ? read.value : null +} + +export function useHolderFrozen(): { frozen: boolean | null } { + const { address } = useWallet() + const { data } = useQuery({ + queryKey: holderFrozenKey(address), + queryFn: address === undefined ? skipToken : () => fetchFrozen(address), + staleTime: 30_000, + }) + + return { frozen: data ?? null } +} diff --git a/app/src/hooks/useInvestorRequests.test.ts b/app/src/hooks/useInvestorRequests.test.ts index b5209bbb..79adcb69 100644 --- a/app/src/hooks/useInvestorRequests.test.ts +++ b/app/src/hooks/useInvestorRequests.test.ts @@ -113,6 +113,7 @@ describe("classifyRequest", () => { amount: 100_0000000n as Amount, claimed: false, priceableAt: openEpoch.priceable_at, + closedAt: openEpoch.closed_at, }, }) }) @@ -130,6 +131,7 @@ describe("classifyRequest", () => { amount: 100_0000000n as Amount, claimed: true, priceableAt: fulfilledEpoch.priceable_at, + closedAt: fulfilledEpoch.closed_at, }, }) }) @@ -147,6 +149,7 @@ describe("classifyRequest", () => { amount: 20_0000000n as Amount, claimed: false, priceableAt: fulfilledEpoch.priceable_at, + closedAt: fulfilledEpoch.closed_at, }, }) }) @@ -240,6 +243,7 @@ describe("fetchInvestorRequests", () => { amount: 100_0000000n as Amount, claimed: false, priceableAt: openEpoch.priceable_at, + closedAt: openEpoch.closed_at, }, { epochId: 3n, @@ -249,6 +253,7 @@ describe("fetchInvestorRequests", () => { amount: 50_0000000n as Amount, claimed: true, priceableAt: fulfilledEpoch.priceable_at, + closedAt: fulfilledEpoch.closed_at, }, { epochId: 3n, @@ -258,6 +263,7 @@ describe("fetchInvestorRequests", () => { amount: 20_0000000n as Amount, claimed: false, priceableAt: fulfilledEpoch.priceable_at, + closedAt: fulfilledEpoch.closed_at, }, ], archived: [], @@ -341,6 +347,7 @@ describe("fetchInvestorRequests", () => { amount: 30_0000000n as Amount, claimed: false, priceableAt: pendingEpoch.priceable_at, + closedAt: pendingEpoch.closed_at, }, ], archived: [], @@ -390,6 +397,7 @@ describe("fetchInvestorRequests", () => { amount: 30_0000000n as Amount, claimed: false, priceableAt: pendingEpoch.priceable_at, + closedAt: pendingEpoch.closed_at, }, ], archived: [], @@ -453,6 +461,7 @@ describe("fetchInvestorRequests", () => { amount: 10_0000000n as Amount, claimed: false, priceableAt: openEpoch.priceable_at, + closedAt: openEpoch.closed_at, }, ], archived: [], @@ -508,6 +517,7 @@ describe("fetchInvestorRequests", () => { amount: 10_0000000n as Amount, claimed: false, priceableAt: pendingEpoch.priceable_at, + closedAt: pendingEpoch.closed_at, }, ], archived: [], @@ -554,6 +564,7 @@ describe("fetchInvestorRequests", () => { amount: 10_0000000n as Amount, claimed: false, priceableAt: openEpoch.priceable_at, + closedAt: openEpoch.closed_at, }, ], archived: [], @@ -601,6 +612,7 @@ describe("fetchInvestorRequests", () => { amount: 10_0000000n as Amount, claimed: false, priceableAt: openEpoch.priceable_at, + closedAt: openEpoch.closed_at, }, ], archived: [], @@ -619,6 +631,7 @@ describe("useInvestorRequests", () => { isPending: false, updateBalances: async () => {}, signTransaction: vi.fn() as WalletContextType["signTransaction"], + signAuthEntry: vi.fn() as WalletContextType["signAuthEntry"], } beforeEach(() => { diff --git a/app/src/hooks/useInvestorRequests.ts b/app/src/hooks/useInvestorRequests.ts index d82f0347..93b8cf50 100644 --- a/app/src/hooks/useInvestorRequests.ts +++ b/app/src/hooks/useInvestorRequests.ts @@ -29,6 +29,7 @@ export type InvestorRequest = { amount: Amount claimed: boolean priceableAt: bigint + closedAt: bigint } export type ArchivedRequest = { @@ -82,6 +83,7 @@ export function classifyRequest( amount: amount as Amount, claimed: read.value.claimed, priceableAt: epoch.priceable_at, + closedAt: epoch.closed_at, }, } } diff --git a/app/src/hooks/useNavPrice.test.ts b/app/src/hooks/useNavPrice.test.ts index d920ebc4..0afca360 100644 --- a/app/src/hooks/useNavPrice.test.ts +++ b/app/src/hooks/useNavPrice.test.ts @@ -1,3 +1,4 @@ +import { Buffer } from "buffer" import { type ContractRead, type NavReport, @@ -30,7 +31,9 @@ const unreadableState: ContractRead = { kind: "unreadable" } const report: NavReport = { nav_per_share: 1500000000000000000n, expires_at: 1800000000n, - timestamp: 1757900000n, + accepted_at: 1757900000n, + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "", } const value: ContractRead = { kind: "value", value: report } const notAttested: ContractRead = { @@ -75,7 +78,7 @@ describe("classifyNav", () => { { status: "valid", price: report.nav_per_share, - attestedAt: report.timestamp, + attestedAt: 1757900000n, } as NavClassification, ], [ diff --git a/app/src/hooks/useNavPrice.ts b/app/src/hooks/useNavPrice.ts index 5c9a4803..17fa20a5 100644 --- a/app/src/hooks/useNavPrice.ts +++ b/app/src/hooks/useNavPrice.ts @@ -34,7 +34,7 @@ export function classifyNav( ? { status: "valid", price: latest.value.nav_per_share as Price, - attestedAt: latest.value.timestamp, + attestedAt: latest.value.accepted_at, } : { status: "unreadable" } } diff --git a/app/src/hooks/useOracleState.test.ts b/app/src/hooks/useOracleState.test.ts index 8220bb6b..ecfbe479 100644 --- a/app/src/hooks/useOracleState.test.ts +++ b/app/src/hooks/useOracleState.test.ts @@ -34,7 +34,7 @@ describe("useOracleState", () => { vi.clearAllMocks() }) - it("reports the oracle as paused while the ripcord is raised", async () => { + it("reports the oracle as paused while the NAV is paused", async () => { oracleMock.state.mockResolvedValue({ result: { tag: "Paused", values: undefined }, }) diff --git a/app/src/hooks/usePendingTransaction.test.ts b/app/src/hooks/usePendingTransaction.test.ts index 4af8db72..4734b86a 100644 --- a/app/src/hooks/usePendingTransaction.test.ts +++ b/app/src/hooks/usePendingTransaction.test.ts @@ -9,6 +9,7 @@ const makeTransaction = ( ): UseContractTransaction> => ({ status: { status: "idle" }, submit, + prepare: vi.fn(), reset: vi.fn(), }) @@ -109,4 +110,22 @@ describe("usePendingTransaction", () => { expect(reset).toHaveBeenCalledTimes(1) }) + + it("prepares through the underlying transaction without recording a label", () => { + const prepare = vi.fn() + const transaction = makeTransaction(() => true) + const grant: Grant = { + role: "manager", + authority: "GAUTHORITY", + standing: "signs-alone", + } + const { result } = renderHook(() => + usePendingTransaction({ ...transaction, prepare }), + ) + + result.current.prepare(5n, grant) + + expect(prepare).toHaveBeenCalledWith(5n, grant) + expect(result.current.amountLabel).toBe("") + }) }) diff --git a/app/src/hooks/usePendingTransaction.ts b/app/src/hooks/usePendingTransaction.ts index f81d7f52..8fe3f539 100644 --- a/app/src/hooks/usePendingTransaction.ts +++ b/app/src/hooks/usePendingTransaction.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react" import { type Grant } from "./useAdminAuthority" import { + type Release, type TransactionStatus, type UseContractTransaction, } from "./useContractTransaction" @@ -9,6 +10,7 @@ export interface UsePendingTransaction { status: TransactionStatus amountLabel: string submit: (arg: TArg, amountLabel: string, grant?: Grant) => boolean + prepare: (arg: TArg, grant?: Grant) => Release retry: () => void reset: () => void } @@ -16,7 +18,7 @@ export interface UsePendingTransaction { export function usePendingTransaction( transaction: UseContractTransaction, ): UsePendingTransaction { - const { status, submit: submitTransaction, reset } = transaction + const { status, submit: submitTransaction, prepare, reset } = transaction const [amountLabel, setAmountLabel] = useState("") const pending = useRef<{ arg: TArg; grant: Grant | undefined } | null>(null) @@ -35,5 +37,25 @@ export function usePendingTransaction( submitTransaction(pending.current.arg, pending.current.grant) }, [submitTransaction]) - return { status, amountLabel, submit, retry, reset } + return { status, amountLabel, submit, prepare, retry, reset } } + +export type Sendable = Pick< + UsePendingTransaction, + "submit" | "prepare" +> + +export type Send = ( + flow: Sendable, + arg: TArg, + amountLabel: string, + grant?: Grant, +) => Release | undefined + +export const submitTo: Send = (flow, arg, amountLabel, grant) => { + flow.submit(arg, amountLabel, grant) + return undefined +} + +export const prepareFor: Send = (flow, arg, _amountLabel, grant) => + flow.prepare(arg, grant) diff --git a/app/src/hooks/useRequestDeposit.ts b/app/src/hooks/useRequestDeposit.ts index b035d216..ec2ff5f4 100644 --- a/app/src/hooks/useRequestDeposit.ts +++ b/app/src/hooks/useRequestDeposit.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" export type RequestDepositStatus = TransactionStatus @@ -11,6 +12,7 @@ export type RequestDepositStatus = TransactionStatus export interface UseRequestDeposit { status: RequestDepositStatus submit: (amount: Amount) => boolean + prepare: (amount: Amount) => Release reset: () => void } @@ -18,7 +20,7 @@ export function useRequestDeposit(): UseRequestDeposit { const call = useCallback( (signer: Signer, amount: Amount) => asyncVaultWriter(signer).then((vault) => - vault.request_deposit({ from: signer.publicKey, amount }), + vault.request_deposit({ holder: signer.publicKey, amount }), ), [], ) diff --git a/app/src/hooks/useRequestRedeem.test.ts b/app/src/hooks/useRequestRedeem.test.ts index 6493457e..06ad9952 100644 --- a/app/src/hooks/useRequestRedeem.test.ts +++ b/app/src/hooks/useRequestRedeem.test.ts @@ -40,7 +40,7 @@ describe("useRequestRedeem", () => { await act(() => result.current.submit(shares)) expect(vaultMock.request_redeem).toHaveBeenCalledWith({ - from: investorAddress, + holder: investorAddress, shares, }) }) diff --git a/app/src/hooks/useRequestRedeem.ts b/app/src/hooks/useRequestRedeem.ts index 80fac4cb..2f045997 100644 --- a/app/src/hooks/useRequestRedeem.ts +++ b/app/src/hooks/useRequestRedeem.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" export type RequestRedeemStatus = TransactionStatus @@ -11,6 +12,7 @@ export type RequestRedeemStatus = TransactionStatus export interface UseRequestRedeem { status: RequestRedeemStatus submit: (shares: Amount) => boolean + prepare: (shares: Amount) => Release reset: () => void } @@ -18,7 +20,7 @@ export function useRequestRedeem(): UseRequestRedeem { const call = useCallback( (signer: Signer, shares: Amount) => asyncVaultWriter(signer).then((vault) => - vault.request_redeem({ from: signer.publicKey, shares }), + vault.request_redeem({ holder: signer.publicKey, shares }), ), [], ) diff --git a/app/src/hooks/useSetupOpen.ts b/app/src/hooks/useSetupOpen.ts new file mode 100644 index 00000000..428b98d6 --- /dev/null +++ b/app/src/hooks/useSetupOpen.ts @@ -0,0 +1,40 @@ +import { type ContractRead, readContract } from "@stellar-scaffold/app-lib" +import { useQuery } from "@tanstack/react-query" +import { asyncVault, navOracle, shareToken } from "../config/clients" + +export type SetupState = "checking" | "open" | "closed" | "unreadable" + +export type TimelockedContract = "vault" | "token" | "oracle" + +export const setupOpenKey = (contract: TimelockedContract) => + [contract, "setupOpen"] as const + +export const classifySetup = (read: ContractRead): SetupState => { + if (read.kind !== "value") return "unreadable" + return read.value ? "open" : "closed" +} + +const READERS = { + vault: async () => (await asyncVault()).setup_open(), + token: async () => (await shareToken()).setup_open(), + oracle: async () => (await navOracle()).setup_open(), +} satisfies Record + +async function fetchSetupOpen( + contract: TimelockedContract, +): Promise { + const read = await readContract(READERS[contract]) + return classifySetup(read) +} + +export function useSetupOpen(contract: TimelockedContract): { + setup: SetupState +} { + const { data } = useQuery({ + queryKey: setupOpenKey(contract), + queryFn: () => fetchSetupOpen(contract), + staleTime: 30_000, + refetchInterval: 15_000, + }) + return { setup: data ?? "checking" } +} diff --git a/app/src/hooks/useSignPending.test.ts b/app/src/hooks/useSignPending.test.ts index cea8f2d4..c2165c16 100644 --- a/app/src/hooks/useSignPending.test.ts +++ b/app/src/hooks/useSignPending.test.ts @@ -20,17 +20,36 @@ const { accountSignersMock, submitSignedMock, transactionStatusMock, + authorizePendingMock, + prepareCoSignedMock, + latestLedgerMock, tokenMock, + vaultMock, + oracleMock, linkTarget, } = vi.hoisted(() => ({ accountSignersMock: vi.fn(), submitSignedMock: vi.fn(), transactionStatusMock: vi.fn(), - tokenMock: { identity_verifier: vi.fn() }, + authorizePendingMock: vi.fn(), + prepareCoSignedMock: vi.fn(), + latestLedgerMock: vi.fn(), + tokenMock: { + identity_verifier: vi.fn(), + pending_changes: vi.fn(), + get_admin: vi.fn(), + txFromXDR: vi.fn(), + }, + vaultMock: { pending_changes: vi.fn() }, + oracleMock: { pending_changes: vi.fn() }, linkTarget: { contractId: "", expiresAt: new Date(0) }, })) -vi.mock("../config/clients", () => ({ shareToken: async () => tokenMock })) +vi.mock("../config/clients", () => ({ + shareToken: async () => tokenMock, + asyncVault: async () => vaultMock, + navOracle: async () => oracleMock, +})) type Marker = { signedBy: string[] } @@ -41,12 +60,15 @@ const authority = "GAUTHORITY" const first = "GFIRST" const wallet = "GWALLET" const third = "GTHIRD" +const admin = "GTOKENADMIN" +const EXPIRATION_LEDGER = 5_000 const pendingOf = ( fn: string, args: unknown[], signedBy: string[], hash = "abc123", + authorization: PendingTransaction["authorization"] = null, ): PendingTransaction => ({ transaction: { signedBy } as unknown as Transaction, source: authority, @@ -56,8 +78,15 @@ const pendingOf = ( fn, args, hash, + authorization, }) +const TRANSFER_MARKERS: Record = { + legal_transfer: { signed: false, hash: "abc123" }, + legal_transfer_authorized: { signed: true, hash: "abc123" }, + legal_transfer_retimed: { signed: true, hash: "retimed" }, +} + vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => { const actual = await importOriginal() return { @@ -65,18 +94,39 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => { accountSigners: accountSignersMock, submitSigned: submitSignedMock, transactionStatus: transactionStatusMock, + authorizePending: authorizePendingMock, + prepareCoSigned: prepareCoSignedMock, + latestLedger: latestLedgerMock, decodePending: (xdr: string) => { const match = /^xdr:([a-z_]+):(.*)$/.exec(xdr) if (match === null) { throw new actual.InvalidPendingTransaction("not a transaction envelope") } - const [, fn = "", signed = ""] = match + const [, marker = "", signed = ""] = match + const transfer = TRANSFER_MARKERS[marker] + if (transfer !== undefined) { + const keys = signed === "" ? [] : signed.split(",") + return pendingOf( + "legal_transfer", + ["GFROM", "GTO", 25_000_000n, "ORDER-1", authority], + keys, + keys.includes("GTAMPERED") ? "tampered" : transfer.hash, + { + address: admin, + signed: transfer.signed, + expirationLedger: EXPIRATION_LEDGER, + }, + ) + } + const fn = marker const args = fn === "set_custodian" ? ["GCUSTODIAN", "GAUTHORITY"] : fn === "allow" ? ["GACCOUNT", true] - : [] + : fn === "apply_change" + ? [3] + : [] const keys = signed === "" ? [] : signed.split(",") const hash = keys.includes("GTAMPERED") ? "tampered" : "abc123" return pendingOf(fn, args, keys, hash) @@ -111,6 +161,11 @@ const walletSigns = vi.fn(async (xdr: string) => ({ signedTxXdr: `${xdr},${wallet}`, })) +const walletAuthorizes = vi.fn(async () => ({ + signedAuthEntry: "SIGNATURE", + signerAddress: admin, +})) + const renderSign = (xdr: string, address: string | undefined) => { const context: WalletContextType = { address, @@ -120,6 +175,8 @@ const renderSign = (xdr: string, address: string | undefined) => { updateBalances: async () => {}, signTransaction: walletSigns as unknown as WalletContextType["signTransaction"], + signAuthEntry: + walletAuthorizes as unknown as WalletContextType["signAuthEntry"], } const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -137,6 +194,12 @@ describe("useSignPending", () => { beforeEach(() => { vi.clearAllMocks() tokenMock.identity_verifier.mockResolvedValue({ result: "CLIVEVERIFIER" }) + tokenMock.get_admin.mockResolvedValue({ result: admin }) + tokenMock.txFromXDR.mockImplementation((xdr: string) => ({ xdr })) + latestLedgerMock.mockResolvedValue(EXPIRATION_LEDGER - 1) + vaultMock.pending_changes.mockResolvedValue({ result: [] }) + tokenMock.pending_changes.mockResolvedValue({ result: [] }) + oracleMock.pending_changes.mockResolvedValue({ result: [] }) linkTarget.contractId = addresses.async_vault linkTarget.expiresAt = new Date("2027-01-15T08:00:00Z") window.history.replaceState(null, "", "/sign") @@ -467,10 +530,353 @@ describe("useSignPending", () => { expect(accountSignersMock).not.toHaveBeenCalled() }) + it("names the pending change an apply link acts on once the vault lists it", async () => { + vaultMock.pending_changes.mockResolvedValue({ + result: [ + { id: 3, change: { tag: "Notice", values: [43_200n] }, eta: 1n }, + ], + }) + accountSignersMock.mockResolvedValue(signers(2)) + const { result } = renderSign("xdr:apply_change:", wallet) + + await waitFor(() => + expect(result.current.description?.args).toEqual([ + { label: "Change", value: "vault change #3: Notice 12h" }, + ]), + ) + }) + + it("names the share-token change an apply link acts on from the token's list", async () => { + linkTarget.contractId = addresses.share_token + vaultMock.pending_changes.mockResolvedValue({ + result: [ + { id: 3, change: { tag: "Notice", values: [43_200n] }, eta: 1n }, + ], + }) + tokenMock.pending_changes.mockResolvedValue({ + result: [ + { + id: 3, + change: { tag: "RevokeRole", values: ["manager", "GHOLDER"] }, + eta: 1n, + }, + ], + }) + accountSignersMock.mockResolvedValue(signers(2)) + const { result } = renderSign("xdr:apply_change:", wallet) + + await waitFor(() => + expect(result.current.description).toMatchObject({ + contract: "Share token", + action: "apply-token-change", + args: [ + { + label: "Change", + value: "share token change #3: Revoke role manager from GHOLDER", + }, + ], + }), + ) + }) + + it("names the oracle change an apply link acts on from the oracle's list", async () => { + linkTarget.contractId = addresses.nav_oracle + oracleMock.pending_changes.mockResolvedValue({ + result: [{ id: 3, change: { tag: "Admin", values: ["GNEXT"] }, eta: 1n }], + }) + accountSignersMock.mockResolvedValue(signers(2)) + const { result } = renderSign("xdr:apply_change:", wallet) + + await waitFor(() => + expect(result.current.description).toMatchObject({ + contract: "Oracle", + action: "apply-oracle-change", + args: [ + { label: "Change", value: "oracle change #3: Admin hand-over GNEXT" }, + ], + }), + ) + }) + it("refuses an operation the console does not propose", async () => { const { result } = renderSign("xdr:request_deposit:", wallet) await waitFor(() => expect(result.current.loading).toBe(false)) expect(result.current.pending).toBeNull() }) + + describe("a legal transfer the token admin co-signs", () => { + const unauthorized = "xdr:legal_transfer:" + const authorized = (...keys: string[]) => + `xdr:legal_transfer_authorized:${keys.join(",")}` + const retimed = "xdr:legal_transfer_retimed:" + + beforeEach(() => { + linkTarget.contractId = addresses.share_token + accountSignersMock.mockResolvedValue(signers(2)) + }) + + it("lets the token admin authorize and writes the re-simulated envelope", async () => { + authorizePendingMock.mockImplementation( + async (_assembled, _pending, signAuthEntry) => { + await signAuthEntry("PREIMAGE", { address: admin }) + return { kind: "ready", xdr: authorized(), pending: {} } + }, + ) + const { result } = renderSign(unauthorized, admin) + await waitFor(() => + expect(result.current.state).toBe("awaiting-authorization"), + ) + expect(result.current.canSign).toBe(true) + + act(() => result.current.sign()) + + await waitFor(() => expect(result.current.xdr).toBe(authorized())) + expect(tokenMock.txFromXDR).toHaveBeenCalledWith(unauthorized) + expect(authorizePendingMock).toHaveBeenCalledWith( + { xdr: unauthorized }, + expect.objectContaining({ fn: "legal_transfer" }), + expect.any(Function), + ) + expect(walletAuthorizes).toHaveBeenCalledWith("PREIMAGE", { + address: admin, + networkPassphrase, + }) + expect(walletSigns).not.toHaveBeenCalled() + expect(window.location.hash).toBe(`#${authorized()}`) + expect(result.current.status).toEqual({ status: "idle" }) + await waitFor(() => expect(result.current.state).toBe("not-signer")) + }) + + it("keeps every wallet but the token admin's from authorizing", async () => { + const { result } = renderSign(unauthorized, wallet) + await waitFor(() => + expect(result.current.state).toBe("awaiting-authorization"), + ) + + act(() => result.current.sign()) + + expect(result.current.canSign).toBe(false) + expect(authorizePendingMock).not.toHaveBeenCalled() + expect(walletSigns).not.toHaveBeenCalled() + }) + + it("refuses a link whose authorization is not the token admin's", async () => { + tokenMock.get_admin.mockResolvedValue({ result: "GSOMEONEELSE" }) + const { result } = renderSign(unauthorized, admin) + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.pending).toBeNull() + expect(result.current.description).toBeNull() + }) + + it("refuses a link while the token admin cannot be read", async () => { + tokenMock.get_admin.mockRejectedValue(new Error("rpc down")) + const { result } = renderSign(unauthorized, admin) + + expect(result.current.loading).toBe(true) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.pending).toBeNull() + }) + + it.each([ + [-3, { kind: "cannot-authorize" }], + [-4, { kind: "declined" }], + ])( + "tells a wallet that rejects with code %i apart", + async (code, failure) => { + authorizePendingMock.mockRejectedValue({ code, message: "no" }) + const { result } = renderSign(unauthorized, admin) + await waitFor(() => expect(result.current.canSign).toBe(true)) + + act(() => result.current.sign()) + + await waitFor(() => + expect(result.current.status).toEqual({ status: "failed", failure }), + ) + expect(result.current.xdr).toBe(unauthorized) + }, + ) + + it("reports the contract's refusal after the admin signs", async () => { + authorizePendingMock.mockResolvedValue({ kind: "refused", code: 300 }) + const { result } = renderSign(unauthorized, admin) + await waitFor(() => expect(result.current.canSign).toBe(true)) + + act(() => result.current.sign()) + + await waitFor(() => + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 300 }, + }), + ) + expect(result.current.xdr).toBe(unauthorized) + }) + + it("keeps an authorization valid while the next ledger is within its expiration", async () => { + latestLedgerMock.mockResolvedValue(EXPIRATION_LEDGER - 1) + accountSignersMock.mockResolvedValue(signers(3)) + const { result } = renderSign(authorized(), wallet) + + await waitFor(() => expect(latestLedgerMock).toHaveBeenCalled()) + await waitFor(() => expect(result.current.canSign).toBe(true)) + expect(result.current.state).toBe("valid") + }) + + it("marks an authorization expired once its expiration ledger has closed", async () => { + latestLedgerMock.mockResolvedValue(EXPIRATION_LEDGER) + const { result } = renderSign(authorized(), wallet) + + await waitFor(() => + expect(result.current.state).toBe("authorization-expired"), + ) + expect(result.current.canSign).toBe(false) + expect(result.current.canSubmit).toBe(false) + }) + + it("has the first signer re-simulate, restart the timeout and sign that envelope", async () => { + prepareCoSignedMock.mockResolvedValue({ + kind: "ready", + xdr: retimed, + pending: { + ...pendingOf("legal_transfer", [], [], "retimed"), + authorization: { + address: admin, + signed: true, + expirationLedger: EXPIRATION_LEDGER, + }, + }, + }) + accountSignersMock.mockResolvedValue(signers(3)) + const { result } = renderSign(authorized(), wallet) + await waitFor(() => expect(result.current.canSign).toBe(true)) + + act(() => result.current.sign()) + + await waitFor(() => + expect(result.current.xdr).toBe(`${retimed},${wallet}`), + ) + expect(prepareCoSignedMock).toHaveBeenCalledWith( + { xdr: authorized() }, + expect.objectContaining({ hash: "abc123" }), + ) + expect(walletSigns).toHaveBeenCalledWith(retimed, { + networkPassphrase, + address: wallet, + }) + expect(result.current.status).toEqual({ status: "idle" }) + }) + + it("refuses a signature over anything but the re-simulated envelope", async () => { + prepareCoSignedMock.mockResolvedValue({ + kind: "ready", + xdr: retimed, + pending: pendingOf("legal_transfer", [], [], "retimed"), + }) + walletSigns.mockResolvedValueOnce({ + signedTxXdr: authorized(wallet), + }) + const { result } = renderSign(authorized(), wallet) + await waitFor(() => expect(result.current.canSign).toBe(true)) + + act(() => result.current.sign()) + + await waitFor(() => + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "unsigned" }, + }), + ) + expect(result.current.xdr).toBe(authorized()) + expect(submitSignedMock).not.toHaveBeenCalled() + }) + + it("reports the contract's refusal from the first signer's simulation", async () => { + prepareCoSignedMock.mockResolvedValue({ kind: "refused", code: 304 }) + const { result } = renderSign(authorized(), wallet) + await waitFor(() => expect(result.current.canSign).toBe(true)) + + act(() => result.current.sign()) + + await waitFor(() => + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }), + ) + expect(walletSigns).not.toHaveBeenCalled() + }) + + const singleKey: AccountSigners = { + signers: [{ key: authority, weight: 1 }], + thresholds: { low: 0, med: 0, high: 0 }, + sequence: "41", + } + + it("waits for the single key of a zero-threshold source after the admin authorizes", async () => { + accountSignersMock.mockResolvedValue(singleKey) + const { result } = renderSign(authorized(), authority) + + await waitFor(() => expect(result.current.canSign).toBe(true)) + expect(result.current.state).toBe("valid") + expect(result.current.canSubmit).toBe(false) + }) + + it("is ready once the single key of a zero-threshold source signs", async () => { + accountSignersMock.mockResolvedValue(singleKey) + const { result } = renderSign(authorized(authority), wallet) + + await waitFor(() => expect(result.current.state).toBe("ready")) + expect(result.current.canSubmit).toBe(true) + }) + + it("submits as soon as the single key of a zero-threshold source signs", async () => { + accountSignersMock.mockResolvedValue(singleKey) + prepareCoSignedMock.mockResolvedValue({ + kind: "ready", + xdr: retimed, + pending: pendingOf("legal_transfer", [], [], "retimed"), + }) + walletSigns.mockResolvedValueOnce({ + signedTxXdr: `${retimed}${authority}`, + }) + submitSignedMock.mockResolvedValue({ + status: "success", + hash: "retimed", + result: undefined, + }) + const { result } = renderSign(authorized(), authority) + await waitFor(() => expect(result.current.canSign).toBe(true)) + + act(() => result.current.sign()) + + await waitFor(() => + expect(result.current.status).toEqual({ + status: "confirmed", + hash: "retimed", + result: undefined, + }), + ) + const submitted = submitSignedMock.mock.calls[0]?.[0] as Transaction + expect(marked(submitted).signedBy).toEqual([authority]) + }) + + it("lets a later signer sign the envelope as it is", async () => { + accountSignersMock.mockResolvedValue(signers(3)) + const { result } = renderSign(authorized(first), wallet) + await waitFor(() => expect(result.current.canSign).toBe(true)) + + act(() => result.current.sign()) + + await waitFor(() => + expect(result.current.xdr).toBe(authorized(first, wallet)), + ) + expect(prepareCoSignedMock).not.toHaveBeenCalled() + expect(walletSigns).toHaveBeenCalledWith(authorized(first), { + networkPassphrase, + address: wallet, + }) + }) + }) }) diff --git a/app/src/hooks/useSignPending.ts b/app/src/hooks/useSignPending.ts index 99bbccce..69e21718 100644 --- a/app/src/hooks/useSignPending.ts +++ b/app/src/hooks/useSignPending.ts @@ -1,26 +1,43 @@ import { type AccountSigners, accountSigners, + authorizePending, + type CoSignStep, decodePending, hasSigned, InvalidPendingTransaction, + latestLedger, networkPassphrase, + type PendingAuthorization, type PendingTransaction, + prepareCoSigned, + readContract, + requiredWeight, + type SignAuthEntry, signatureWeight, submitSigned, transactionStatus, } from "@stellar-scaffold/app-lib" -import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query" +import { + queryOptions, + skipToken, + useQuery, + useQueryClient, +} from "@tanstack/react-query" import { useCallback, useMemo, useRef, useState } from "react" import { addresses } from "../config/addresses" +import { shareToken } from "../config/clients" import { + type AuthorizationState, describePending, type PendingState, pendingState, + type SignChanges, type SignDescription, type SignStatus, } from "../pages/sign" import { type TransactionFailure } from "./useContractTransaction" +import { pendingChangesQuery } from "./useGovernanceState" import { identityVerifierQuery } from "./useIsAllowed" import { useWallet } from "./useWallet" @@ -29,6 +46,22 @@ export const accountSignersKey = (source: string) => export const transactionKey = (hash: string) => ["transaction", hash] as const +const tokenAdminQuery = queryOptions({ + queryKey: ["share-token", "admin"] as const, + queryFn: async () => { + const read = await readContract(async () => + (await shareToken()).get_admin(), + ) + if (read.kind !== "value") { + throw new Error(`share token get_admin read failed: ${read.kind}`) + } + return read.value ?? null + }, + retry: false, +}) + +const latestLedgerKey = ["latest-ledger"] as const + const signAddresses = (identityVerifier: string) => ({ vault: addresses.async_vault, oracle: addresses.nav_oracle, @@ -55,12 +88,20 @@ export interface UseSignPending { type Decoded = { pending: PendingTransaction; description: SignDescription } -const decode = (xdr: string, identityVerifier: string): Decoded | null => { +const decode = ( + xdr: string, + identityVerifier: string, + changes: SignChanges, +): Decoded | null => { try { const pending = decodePending(xdr) return { pending, - description: describePending(pending, signAddresses(identityVerifier)), + description: describePending( + pending, + signAddresses(identityVerifier), + changes, + ), } } catch (error) { if (error instanceof InvalidPendingTransaction) return null @@ -101,17 +142,43 @@ const countersigned = ( const NOBODY = { signedBy: [] as string[], weight: 0 } const USER_REJECTED = -4 +const UNSUPPORTED = -3 -const signFailure = (error: unknown): TransactionFailure => - typeof error === "object" && - error !== null && - "code" in error && - error.code === USER_REJECTED - ? { kind: "declined" } - : { kind: "unsigned" } +const signFailure = (error: unknown): TransactionFailure => { + const code = + typeof error === "object" && error !== null && "code" in error + ? error.code + : undefined + if (code === USER_REJECTED) return { kind: "declined" } + if (code === UNSUPPORTED) return { kind: "cannot-authorize" } + return { kind: "unsigned" } +} + +const authorizedBy = ( + decoded: Decoded | null, + admin: string | null | undefined, + adminFailed: boolean, +): Decoded | null | undefined => { + const authorization = decoded?.pending.authorization + if (decoded === null || authorization == null) return decoded + if (decoded.pending.contractId !== addresses.share_token) return null + if (admin === undefined) return adminFailed ? null : undefined + return admin === authorization.address ? decoded : null +} + +const authorizationState = ( + authorization: PendingAuthorization | null | undefined, + ledger: number | undefined, +): AuthorizationState => { + if (authorization == null) return "none" + if (!authorization.signed) return "awaiting" + if (ledger !== undefined && ledger >= authorization.expirationLedger) + return "expired" + return "authorized" +} export function useSignPending(initialXdr: string): UseSignPending { - const { address, signTransaction } = useWallet() + const { address, signTransaction, signAuthEntry } = useWallet() const queryClient = useQueryClient() const [xdr, setXdr] = useState(initialXdr) const [status, setStatus] = useState({ status: "idle" }) @@ -124,11 +191,32 @@ export function useSignPending(initialXdr: string): UseSignPending { }) const verifier = liveVerifier ?? (verifierFailed ? addresses.identity_verifier : undefined) + const { data: vaultChanges } = useQuery(pendingChangesQuery("vault")) + const { data: tokenChanges } = useQuery(pendingChangesQuery("token")) + const { data: oracleChanges } = useQuery(pendingChangesQuery("oracle")) const envelope = useMemo(() => isEnvelope(xdr), [xdr]) - const decoded = useMemo( - () => (verifier === undefined ? null : decode(xdr, verifier)), - [xdr, verifier], + const described = useMemo( + () => + verifier === undefined + ? null + : decode(xdr, verifier, { + vault: vaultChanges?.pending, + token: tokenChanges?.pending, + oracle: oracleChanges?.pending, + }), + [xdr, verifier, vaultChanges, tokenChanges, oracleChanges], ) + const authorization = described?.pending.authorization ?? null + const { data: admin, isError: adminFailed } = useQuery({ + ...tokenAdminQuery, + enabled: authorization !== null, + }) + const checked = authorizedBy(described, admin, adminFailed) + const decoded = checked ?? null + const { data: ledger } = useQuery({ + queryKey: latestLedgerKey, + queryFn: decoded?.pending.authorization?.signed ? latestLedger : skipToken, + }) const source = decoded?.pending.source const { data: signers } = useQuery({ queryKey: accountSignersKey(source ?? ""), @@ -159,9 +247,15 @@ export function useSignPending(initialXdr: string): UseSignPending { sequence: decoded.pending.sequence, accountSequence: signers?.sequence ?? null, weight: collected.weight, - threshold: signers?.thresholds.med ?? Number.POSITIVE_INFINITY, + threshold: signers + ? requiredWeight(signers) + : Number.POSITIVE_INFINITY, walletIsSigner, walletHasSigned, + authorization: authorizationState( + decoded.pending.authorization, + ledger, + ), }) const hash = decoded?.pending.hash const { data: outcome } = useQuery({ @@ -178,7 +272,11 @@ export function useSignPending(initialXdr: string): UseSignPending { outcome === "success" ? "executed" : sequenceState - const canSign = state === "valid" && walletIsSigner === true + const walletIsAdmin = + address !== undefined && decoded?.pending.authorization?.address === address + const canSign = + (state === "valid" && walletIsSigner === true) || + (state === "awaiting-authorization" && walletIsAdmin) const canSubmit = state === "ready" const apply = useCallback((next: SignStatus) => { @@ -222,13 +320,63 @@ export function useSignPending(initialXdr: string): UseSignPending { [apply], ) - const sign = useCallback(() => { - if (!canSign || address === undefined || !signers || decoded === null) - return - guarded(async () => { - const signed = await signTransaction(xdr, { + const replaceXdr = useCallback((next: string) => { + window.history.replaceState(window.history.state, "", `#${next}`) + setXdr(next) + }, []) + + const authorize = useCallback( + async (pending: PendingTransaction, wallet: string) => { + const signAs: SignAuthEntry = (entry, opts) => + signAuthEntry(entry, { ...opts, networkPassphrase, address: wallet }) + const step = await authorizePending( + (await shareToken()).txFromXDR(xdr), + pending, + signAs, + ).catch((error: unknown) => signFailure(error)) + if (step.kind === "refused") { + apply({ + status: "failed", + failure: { kind: "contract-error", code: step.code }, + }) + } else if (step.kind === "ready") { + replaceXdr(step.xdr) + } else { + apply({ status: "failed", failure: step }) + } + }, + [xdr, signAuthEntry, apply, replaceXdr], + ) + + const envelopeToSign = useCallback( + async ( + pending: PendingTransaction, + signedBefore: string[], + ): Promise => { + if (pending.authorization === null || signedBefore.length > 0) + return { kind: "ready", xdr, pending } + return prepareCoSigned((await shareToken()).txFromXDR(xdr), pending) + }, + [xdr], + ) + + const countersign = useCallback( + async ( + pending: PendingTransaction, + wallet: string, + signers: AccountSigners, + ) => { + const prepared = await envelopeToSign(pending, collected.signedBy) + if (prepared.kind === "refused") { + apply({ + status: "failed", + failure: { kind: "contract-error", code: prepared.code }, + }) + return + } + const signed = await signTransaction(prepared.xdr, { networkPassphrase, - address, + address: wallet, }).catch((error: unknown) => signFailure(error)) if ("kind" in signed) { apply({ status: "failed", failure: signed }) @@ -236,8 +384,8 @@ export function useSignPending(initialXdr: string): UseSignPending { } const next = countersigned( signed.signedTxXdr, - decoded.pending, - address, + prepared.pending, + wallet, collected.signedBy, signers, ) @@ -245,26 +393,37 @@ export function useSignPending(initialXdr: string): UseSignPending { apply({ status: "failed", failure: { kind: "unsigned" } }) return } - window.history.replaceState( - window.history.state, - "", - `#${signed.signedTxXdr}`, - ) - setXdr(signed.signedTxXdr) + replaceXdr(signed.signedTxXdr) const { weight } = signatureWeight(next.transaction, signers) - if (weight >= signers.thresholds.med) await run(next) - }) + if (weight >= requiredWeight(signers)) await run(next) + }, + [ + envelopeToSign, + collected.signedBy, + apply, + signTransaction, + replaceXdr, + run, + ], + ) + + const sign = useCallback(() => { + if (!canSign || address === undefined || !signers || decoded === null) + return + guarded(() => + state === "awaiting-authorization" + ? authorize(decoded.pending, address) + : countersign(decoded.pending, address, signers), + ) }, [ canSign, address, signers, decoded, - collected.signedBy, + state, guarded, - apply, - signTransaction, - xdr, - run, + authorize, + countersign, ]) const submit = useCallback(() => { @@ -279,7 +438,7 @@ export function useSignPending(initialXdr: string): UseSignPending { return { xdr, - loading: envelope && verifier === undefined, + loading: envelope && (verifier === undefined || checked === undefined), pending: decoded?.pending ?? null, description: decoded?.description ?? null, signers, diff --git a/app/src/hooks/useWindDownActive.test.ts b/app/src/hooks/useWindDownActive.test.ts new file mode 100644 index 00000000..4d999cef --- /dev/null +++ b/app/src/hooks/useWindDownActive.test.ts @@ -0,0 +1,42 @@ +import { type WindDownInfo } from "@stellar-scaffold/app-lib" +import { waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { renderWithWallet } from "./testSupport" +import { useWindDownActive } from "./useWindDownActive" + +const { windDownMock } = vi.hoisted(() => ({ + windDownMock: vi.fn(async (): Promise<{ result: WindDownInfo | null }> => ({ + result: null, + })), +})) + +vi.mock("../config/clients", () => ({ + asyncVault: async () => ({ wind_down: windDownMock }), +})) + +const info = (tag: "Proposed" | "Active"): WindDownInfo => ({ + active_at: 1_700_000_000n, + round: 0, + status: { tag, values: undefined }, +}) + +describe("useWindDownActive", () => { + it.each<[string, WindDownInfo | null, boolean]>([ + ["no proposal", null, false], + ["a proposal not yet active", info("Proposed"), false], + ["an active wind-down", info("Active"), true], + ])("reads %s as active=%s", async (_label, value, expected) => { + windDownMock.mockResolvedValueOnce({ result: value }) + const { result } = renderWithWallet(useWindDownActive) + + await waitFor(() => expect(result.current.windingDown).toBe(expected)) + }) + + it("reports an unreadable wind-down as unknown", async () => { + windDownMock.mockRejectedValueOnce(new Error("rpc down")) + const { result } = renderWithWallet(useWindDownActive) + + await waitFor(() => expect(windDownMock).toHaveBeenCalled()) + expect(result.current.windingDown).toBeNull() + }) +}) diff --git a/app/src/hooks/useWindDownActive.ts b/app/src/hooks/useWindDownActive.ts new file mode 100644 index 00000000..94b3a754 --- /dev/null +++ b/app/src/hooks/useWindDownActive.ts @@ -0,0 +1,21 @@ +import { readContract } from "@stellar-scaffold/app-lib" +import { useQuery } from "@tanstack/react-query" +import { asyncVault } from "../config/clients" + +export const windDownActiveKey = ["vault", "wind-down-active"] as const + +async function fetchWindDownActive(): Promise { + const read = await readContract(async () => (await asyncVault()).wind_down()) + if (read.kind !== "value") return null + return read.value?.status.tag === "Active" +} + +export function useWindDownActive(): { windingDown: boolean | null } { + const { data } = useQuery({ + queryKey: windDownActiveKey, + queryFn: fetchWindDownActive, + staleTime: 30_000, + }) + + return { windingDown: data ?? null } +} diff --git a/app/src/hooks/useWindDownClaimable.test.ts b/app/src/hooks/useWindDownClaimable.test.ts new file mode 100644 index 00000000..9f928149 --- /dev/null +++ b/app/src/hooks/useWindDownClaimable.test.ts @@ -0,0 +1,31 @@ +import { waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { investorAddress, renderWithWallet } from "./testSupport" +import { useWindDownClaimable } from "./useWindDownClaimable" + +const { claimableMock } = vi.hoisted(() => ({ + claimableMock: vi.fn(async (_args: { holder: string }) => ({ + result: 25_0000000n, + })), +})) + +vi.mock("../config/clients", () => ({ + asyncVault: async () => ({ wind_down_claimable: claimableMock }), +})) + +describe("useWindDownClaimable", () => { + it("reads what the wind-down would pay the connected wallet now", async () => { + const { result } = renderWithWallet(useWindDownClaimable) + + await waitFor(() => expect(result.current.claimable).toBe(25_0000000n)) + expect(claimableMock).toHaveBeenCalledWith({ holder: investorAddress }) + }) + + it("reports an unreadable claimable amount as unknown, never as zero", async () => { + claimableMock.mockRejectedValueOnce(new Error("rpc down")) + const { result } = renderWithWallet(useWindDownClaimable) + + await waitFor(() => expect(claimableMock).toHaveBeenCalled()) + expect(result.current.claimable).toBeNull() + }) +}) diff --git a/app/src/hooks/useWindDownClaimable.ts b/app/src/hooks/useWindDownClaimable.ts new file mode 100644 index 00000000..4663dadd --- /dev/null +++ b/app/src/hooks/useWindDownClaimable.ts @@ -0,0 +1,24 @@ +import { type Amount, readContract } from "@stellar-scaffold/app-lib" +import { skipToken, useQuery } from "@tanstack/react-query" +import { asyncVault } from "../config/clients" +import { useWallet } from "./useWallet" + +export const windDownClaimableKey = ["vault", "wind-down-claimable"] as const + +async function fetchClaimable(holder: string): Promise { + const read = await readContract(async () => + (await asyncVault()).wind_down_claimable({ holder }), + ) + return read.kind === "value" ? (read.value as Amount) : null +} + +export function useWindDownClaimable(): { claimable: Amount | null } { + const { address } = useWallet() + const { data } = useQuery({ + queryKey: [...windDownClaimableKey, address], + queryFn: address === undefined ? skipToken : () => fetchClaimable(address), + staleTime: 30_000, + }) + + return { claimable: data ?? null } +} diff --git a/app/src/pages/SignPage.test.tsx b/app/src/pages/SignPage.test.tsx index 42d26b24..b91e585a 100644 --- a/app/src/pages/SignPage.test.tsx +++ b/app/src/pages/SignPage.test.tsx @@ -29,9 +29,12 @@ const { accountSignersMock, submitSignedMock, transactionStatusMock } = transactionStatusMock: vi.fn(), })) +const tokenAdmin = "GTOKENADMINACCOUNT" + vi.mock("../config/clients", () => ({ shareToken: async () => ({ identity_verifier: async () => ({ result: "CLIVEVERIFIER" }), + get_admin: async () => ({ result: tokenAdmin }), }), })) @@ -54,6 +57,22 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => { submitSigned: submitSignedMock, transactionStatus: transactionStatusMock, decodePending: (xdr: string): PendingTransaction => { + if (xdr === "xdr:transfer") + return { + transaction: { signedBy: [] } as unknown as Transaction, + source: authority, + sequence: "42", + expiresAt: new Date("2027-01-15T08:00:00Z"), + contractId: addresses.share_token, + fn: "legal_transfer", + args: [first, second, 25_000_000n, "ORDER-1", authority], + hash: "abc123", + authorization: { + address: tokenAdmin, + signed: false, + expirationLedger: 0, + }, + } const match = /^xdr:([^;]*)(;expired)?$/.exec(xdr) if (match === null) { throw new actual.InvalidPendingTransaction("not a transaction envelope") @@ -73,6 +92,7 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => { fn: "set_deposit_cap", args: [1_500_000_000n, authority], hash: "abc123", + authorization: null, } }, signatureWeight: (transaction: Transaction, signers: AccountSigners) => { @@ -122,6 +142,7 @@ const renderSign = (hash: string, address: string | undefined) => { updateBalances: async () => {}, signTransaction: walletSigns as unknown as WalletContextType["signTransaction"], + signAuthEntry: vi.fn() as unknown as WalletContextType["signAuthEntry"], } const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -335,4 +356,32 @@ describe("SignPage", () => { ), ).toBeTruthy() }) + + it("asks the token admin to authorize a legal transfer before anyone signs", async () => { + renderSign("#xdr:transfer", tokenAdmin) + + expect( + await screen.findByRole("heading", { name: "Make a legal transfer" }), + ).toBeTruthy() + expect(screen.getByText("ORDER-1")).toBeTruthy() + expect(screen.getByText(short(tokenAdmin))).toBeTruthy() + expect(screen.getByText("Pending")).toBeTruthy() + expect(status().textContent).toBe("Awaiting the admin's authorization") + await waitFor(() => expect(button("Authorize").disabled).toBe(false)) + noButton("Sign") + expect( + screen.getByText( + "The token admin authorizes first. Then compliance's signers sign.", + ), + ).toBeTruthy() + }) + + it("keeps Authorize disabled for a wallet that is not the token admin", async () => { + renderSign("#xdr:transfer", second) + + expect( + await screen.findByRole("heading", { name: "Make a legal transfer" }), + ).toBeTruthy() + await waitFor(() => expect(button("Authorize").disabled).toBe(true)) + }) }) diff --git a/app/src/pages/SignPage.tsx b/app/src/pages/SignPage.tsx index c3bb13d6..01482a8e 100644 --- a/app/src/pages/SignPage.tsx +++ b/app/src/pages/SignPage.tsx @@ -1,5 +1,7 @@ import { type AccountSigners, + type PendingAuthorization, + requiredWeight, shortAddress, uniformWeights, } from "@stellar-scaffold/app-lib" @@ -109,7 +111,7 @@ const Signatures: React.FC = ({

    {describeCollected(weight, signers)}

    - +
      {signers.signers.map(({ key, weight: own }) => { const signed = signedBy.includes(key) @@ -141,6 +143,7 @@ type DetailsProps = { contractId: string expiresAt: Date | null args: SignArg[] + authorization: PendingAuthorization | null } const Details: React.FC = ({ @@ -148,6 +151,7 @@ const Details: React.FC = ({ contractId, expiresAt, args, + authorization, }) => (
      {shortAddress(source)} @@ -158,6 +162,16 @@ const Details: React.FC = ({ {arg.value} ))} + {authorization !== null && ( + <> + {shortAddress(authorization.address)} + + {authorization.signed + ? `Valid until ledger ${authorization.expirationLedger}` + : "Pending"} + + + )}
      ) @@ -214,6 +228,7 @@ const PendingSign: React.FC<{ xdr: string }> = ({ xdr }) => { contractId={pending.contractId} expiresAt={pending.expiresAt} args={description.args} + authorization={pending.authorization} />
      {action && ( diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index 5a8758c5..d4d98648 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -1,3 +1,4 @@ +import { Buffer } from "buffer" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { formatDate, @@ -9,7 +10,7 @@ import { } from "@stellar-scaffold/app-lib" import type * as AppLib from "@stellar-scaffold/app-lib" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import type React from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { type AddressRow } from "../components/vault/AboutVault" @@ -36,14 +37,19 @@ const { cancelRedeemMock, claimRedeemMock, readEpochRequestsMock, + claimWindDownMock, } = vi.hoisted(() => ({ mockVaultId: "CMOCKVAULTADDRESS1234567890", mockGovernanceAddress: "GGOVERNANCEADDRESS1234567890", mockShares: { balance: 500_0000000n }, mockDeposit: { balance: 3200_0000000n }, - mockVault: { paused: false }, + mockVault: { + paused: false, + windingDown: false, + windDownClaimable: 0n, + }, mockFigures: { uncovered: 0n as bigint | null }, - mockIdentity: { allowed: true }, + mockIdentity: { allowed: true, frozen: false }, mockSymbols: { token: "USDC", shareToken: "vUSDC", @@ -54,7 +60,12 @@ const { currentEpoch: 2n, epochs: new Map< bigint, - { status: { tag: string }; share_price: bigint; priceable_at: bigint } + { + status: { tag: string } + share_price: bigint + priceable_at: bigint + closed_at?: bigint + } >([ [ 1n, @@ -82,6 +93,7 @@ const { cancelRedeemMock: vi.fn(), claimRedeemMock: vi.fn(), readEpochRequestsMock: vi.fn(), + claimWindDownMock: vi.fn(), })) vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ @@ -92,7 +104,7 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ const defaultCancelDepositImpl = async ({ epoch_id, }: { - from: string + holder: string epoch_id: bigint }) => { const request = mockRequests.deposits.get(epoch_id) @@ -117,7 +129,7 @@ const defaultCancelDepositImpl = async ({ const defaultRequestDepositImpl = async ({ amount, }: { - from: string + holder: string amount: bigint }) => { const epochId = mockRequests.currentEpoch @@ -138,7 +150,7 @@ const defaultRequestDepositImpl = async ({ const defaultRequestRedeemImpl = async ({ shares, }: { - from: string + holder: string shares: bigint }) => { const epochId = mockRequests.currentEpoch @@ -159,7 +171,7 @@ const defaultRequestRedeemImpl = async ({ const defaultCancelRedeemImpl = async ({ epoch_id, }: { - from: string + holder: string epoch_id: bigint }) => { const request = mockRequests.redeems.get(epoch_id) @@ -186,7 +198,7 @@ const WAD_SCALE = 1_000000000000000000n const defaultClaimDepositImpl = async ({ epoch_id, }: { - caller: string + holder: string epoch_id: bigint }) => { const request = mockRequests.deposits.get(epoch_id) @@ -215,7 +227,7 @@ const defaultClaimDepositImpl = async ({ const defaultClaimRedeemImpl = async ({ epoch_id, }: { - caller: string + holder: string epoch_id: bigint }) => { const request = mockRequests.redeems.get(epoch_id) @@ -286,8 +298,11 @@ beforeEach(() => { mockShares.balance = 500_0000000n mockDeposit.balance = 3200_0000000n mockVault.paused = false + mockVault.windingDown = false + mockVault.windDownClaimable = 0n mockFigures.uncovered = 0n mockIdentity.allowed = true + mockIdentity.frozen = false resetMockRequests() }) @@ -295,8 +310,11 @@ afterEach(() => { mockShares.balance = 500_0000000n mockDeposit.balance = 3200_0000000n mockVault.paused = false + mockVault.windingDown = false + mockVault.windDownClaimable = 0n mockFigures.uncovered = 0n mockIdentity.allowed = true + mockIdentity.frozen = false resetMockRequests() }) @@ -332,6 +350,19 @@ vi.mock("../config/clients", () => { return { result: mockRequests.currentEpoch } }, paused: async () => ({ result: mockVault.paused }), + wind_down: async () => ({ + result: mockVault.windingDown + ? { + active_at: 1_700_000_000n, + round: 0, + status: { tag: "Active", values: undefined }, + } + : null, + }), + wind_down_claimable: async () => ({ + result: mockVault.windDownClaimable, + }), + claim_wind_down: claimWindDownMock, request_deposit: requestDepositMock, cancel_deposit: cancelDepositMock, claim_deposit: claimDepositMock, @@ -345,7 +376,9 @@ vi.mock("../config/clients", () => { result: { nav_per_share: 1500000000000000000n, expires_at: 1800000000n, - timestamp: 1757900000n, + accepted_at: 1757900000n, + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "", }, }), } @@ -357,6 +390,7 @@ vi.mock("../config/clients", () => { symbol: async () => ({ result: mockSymbols.shareToken }), name: async () => ({ result: mockSymbols.vaultName }), identity_verifier: async () => ({ result: "CVERIFIER" }), + is_frozen: async () => ({ result: mockIdentity.frozen }), } const depositAsset = { symbol: async () => ({ result: mockSymbols.token }), @@ -382,6 +416,7 @@ const connectedWallet: WalletContextType = { isPending: false, updateBalances: async () => {}, signTransaction: vi.fn() as WalletContextType["signTransaction"], + signAuthEntry: vi.fn() as WalletContextType["signAuthEntry"], } const wrongNetworkWallet: WalletContextType = { @@ -406,6 +441,9 @@ const renderVaultPreview = ( return render(, { wrapper }) } +const settlePreparation = () => + act(() => new Promise((resolve) => setTimeout(resolve, 700))) + describe("VaultPreview", () => { it("renders the action panel in the side column", () => { renderVaultPreview() @@ -443,7 +481,7 @@ describe("VaultPreview", () => { expect( await screen.findByText( - "A share claim is ready the moment it prices. A USDC claim waits until the vault's reserve can cover it in full. You may hold one open request per side, and only share claims need an allowlisted address.", + "A share claim is ready the moment it prices. A USDC claim waits until the vault's reserve can cover it in full. You may hold one open request per side, and subscribing needs an allowlisted address.", ), ).toBeTruthy() }) @@ -675,11 +713,73 @@ describe("VaultPreview", () => { await waitFor(() => expect(requestRedeemMock).toHaveBeenCalledTimes(1)) expect(requestRedeemMock).toHaveBeenCalledWith({ - from: investorAddress, + holder: investorAddress, shares: 150_0000000n, }) }) + it("prepares the subscription only once the amount fits the balance", async () => { + renderVaultPreview(connectedWallet) + const input = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + + fireEvent.change(input, { target: { value: "5000" } }) + await settlePreparation() + + expect(requestDepositMock).not.toHaveBeenCalled() + + fireEvent.change(input, { target: { value: "100" } }) + + await waitFor(() => expect(requestDepositMock).toHaveBeenCalledTimes(1), { + timeout: 2000, + }) + expect(requestDepositMock).toHaveBeenCalledWith({ + holder: investorAddress, + amount: 100_0000000n, + }) + }) + + it("does not prepare while the wallet is on the wrong network", async () => { + renderVaultPreview(wrongNetworkWallet) + const input = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + + fireEvent.change(input, { target: { value: "100" } }) + await settlePreparation() + + expect(requestDepositMock).not.toHaveBeenCalled() + }) + + it("prepares the hook of whichever side the investor switches to", async () => { + renderVaultPreview(connectedWallet) + const subscribe = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + fireEvent.change(subscribe, { target: { value: "100" } }) + + await waitFor(() => expect(requestDepositMock).toHaveBeenCalledTimes(1), { + timeout: 2000, + }) + expect(requestRedeemMock).not.toHaveBeenCalled() + + fireEvent.click(await screen.findByRole("tab", { name: "Redeem" })) + const redeem = await screen.findByRole("textbox", { + name: "Amount to redeem", + }) + fireEvent.change(redeem, { target: { value: "100" } }) + + await waitFor(() => expect(requestRedeemMock).toHaveBeenCalledTimes(1), { + timeout: 2000, + }) + expect(requestRedeemMock).toHaveBeenCalledWith({ + holder: investorAddress, + shares: 100_0000000n, + }) + expect(requestDepositMock).toHaveBeenCalledTimes(1) + }) + it("reflects a confirmed redemption request in the request list without a manual refresh", async () => { renderVaultPreview(connectedWallet) fireEvent.click(await screen.findByRole("tab", { name: "Redeem" })) @@ -803,11 +903,39 @@ describe("VaultPreview", () => { await waitFor(() => expect(requestDepositMock).toHaveBeenCalledTimes(1)) expect(requestDepositMock).toHaveBeenCalledWith({ - from: investorAddress, + holder: investorAddress, amount: 150_0000000n, }) }) + it("simulates the subscription in the background once a valid amount settles", async () => { + renderVaultPreview(connectedWallet) + const input = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + fireEvent.change(input, { target: { value: "1" } }) + fireEvent.change(input, { target: { value: "15" } }) + fireEvent.change(input, { target: { value: "150" } }) + + await waitFor(() => expect(requestDepositMock).toHaveBeenCalledTimes(1)) + expect(requestDepositMock).toHaveBeenCalledWith({ + holder: investorAddress, + amount: 150_0000000n, + }) + }) + + it("does not simulate an amount that cannot be submitted", async () => { + renderVaultPreview(connectedWallet) + const input = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + fireEvent.change(input, { target: { value: "0" } }) + + await new Promise((resolve) => setTimeout(resolve, 600)) + + expect(requestDepositMock).not.toHaveBeenCalled() + }) + it("reflects a confirmed subscription in the request list without a manual refresh", async () => { renderVaultPreview(connectedWallet) const input = await screen.findByRole("textbox", { @@ -848,7 +976,7 @@ describe("VaultPreview", () => { resolveSend = resolve }) requestDepositMock.mockImplementationOnce( - async ({ amount }: { from: string; amount: bigint }) => ({ + async ({ amount }: { holder: string; amount: bigint }) => ({ simulation: undefined, signAndSend: async ({ watcher, @@ -897,7 +1025,7 @@ describe("VaultPreview", () => { resolveSend = resolve }) requestDepositMock.mockImplementationOnce( - async ({ amount }: { from: string; amount: bigint }) => ({ + async ({ amount }: { holder: string; amount: bigint }) => ({ simulation: undefined, signAndSend: async ({ watcher, @@ -969,7 +1097,7 @@ describe("VaultPreview", () => { await waitFor(() => expect(requestDepositMock).toHaveBeenCalledTimes(2)) expect(requestDepositMock).toHaveBeenLastCalledWith({ - from: investorAddress, + holder: investorAddress, amount: 150_0000000n, }) }) @@ -1000,7 +1128,7 @@ describe("VaultPreview", () => { await waitFor(() => expect(requestRedeemMock).toHaveBeenCalledTimes(2)) expect(requestRedeemMock).toHaveBeenLastCalledWith({ - from: investorAddress, + holder: investorAddress, shares: 150_0000000n, }) }) @@ -1097,7 +1225,7 @@ describe("VaultPreview", () => { await waitFor(() => expect(cancelDepositMock).toHaveBeenCalledTimes(1)) expect(cancelDepositMock).toHaveBeenCalledWith({ - from: investorAddress, + holder: investorAddress, epoch_id: mockRequests.currentEpoch, }) }) @@ -1188,6 +1316,84 @@ describe("VaultPreview", () => { expect(await screen.findByRole("tab", { name: "Waiting 0" })).toBeTruthy() }) + const sealedEpoch = 9n + + const setSealedSubscription = (closedAt: bigint) => { + mockRequests.currentEpoch = sealedEpoch + 1n + mockRequests.epochs.set(sealedEpoch, { + status: { tag: "Pending" }, + share_price: 0n, + priceable_at: closedAt + 3600n, + closed_at: closedAt, + }) + mockRequests.epochs.set(sealedEpoch + 1n, { + status: { tag: "Open" }, + share_price: 0n, + priceable_at: 0n, + }) + mockRequests.deposits.set(sealedEpoch, { + amount: 150_0000000n, + claimed: false, + }) + } + + it("hides Cancel on a sealed subscription once a price attested after its close is readable", async () => { + setSealedSubscription(attestedAt - 60n) + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: "Waiting 1" })) + + expect(await screen.findByText("Pending")).toBeTruthy() + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull() + }) + + it("offers Cancel on a sealed subscription while the vault is paused, since no price is readable", async () => { + setSealedSubscription(attestedAt - 60n) + mockVault.paused = true + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: "Waiting 1" })) + + expect(await screen.findByRole("button", { name: "Cancel" })).toBeTruthy() + }) + + it("offers Cancel on a sealed subscription priced only before its close", async () => { + setSealedSubscription(attestedAt + 60n) + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: "Waiting 1" })) + + expect(await screen.findByRole("button", { name: "Cancel" })).toBeTruthy() + }) + + it("offers Cancel on a sealed subscription during a wind-down, even with a readable price", async () => { + setSealedSubscription(attestedAt - 60n) + mockVault.windingDown = true + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: "Waiting 1" })) + + expect(await screen.findByRole("button", { name: "Cancel" })).toBeTruthy() + }) + + it("shows a frozen holder's open subscription as held, with no Cancel", async () => { + mockRequests.deposits.set(mockRequests.currentEpoch, { + amount: 150_0000000n, + claimed: false, + }) + mockIdentity.frozen = true + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: "Blocked 1" })) + + expect(await screen.findByText("Held")).toBeTruthy() + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull() + fireEvent.click( + screen.getByRole("button", { name: "Why this request is held" }), + ) + expect(screen.getByText(/held until the freeze is lifted/)).toBeTruthy() + }) + it("offers a Cancel action on a waiting redemption", async () => { mockRequests.redeems.set(mockRequests.currentEpoch, { shares: 150_0000000n, @@ -1212,7 +1418,7 @@ describe("VaultPreview", () => { await waitFor(() => expect(cancelRedeemMock).toHaveBeenCalledTimes(1)) expect(cancelRedeemMock).toHaveBeenCalledWith({ - from: investorAddress, + holder: investorAddress, epoch_id: mockRequests.currentEpoch, }) }) @@ -1304,7 +1510,7 @@ describe("VaultPreview", () => { await waitFor(() => expect(claimDepositMock).toHaveBeenCalledTimes(1)) expect(claimDepositMock).toHaveBeenCalledWith({ - caller: investorAddress, + holder: investorAddress, epoch_id: readyDepositEpoch, }) }) @@ -1412,28 +1618,98 @@ describe("VaultPreview", () => { expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() }) - it("blocks claiming a ready deposit when the investor is no longer allowlisted", async () => { + it("lets a delisted investor claim a ready deposit, saying the shares queue as a redemption", async () => { setReadyDeposit() mockIdentity.allowed = false renderVaultPreview(connectedWallet) + fireEvent.click( + await screen.findByRole("tab", { name: "Ready to claim 1" }), + ) + + expect(await screen.findByRole("button", { name: "Claim" })).toBeTruthy() + expect(screen.getByText(/queued as a redemption/)).toBeTruthy() + }) + + it("shows a frozen investor's ready deposit as held, with no Claim", async () => { + setReadyDeposit() + mockIdentity.frozen = true + renderVaultPreview(connectedWallet) + fireEvent.click(await screen.findByRole("tab", { name: "Blocked 1" })) + expect(await screen.findByText("Held")).toBeTruthy() expect(screen.queryByRole("button", { name: "Claim" })).toBeNull() + }) + + it("offers a wind-down claim under Ready to claim and pays it through claim_wind_down", async () => { + mockVault.windingDown = true + mockVault.windDownClaimable = 25_0000000n + claimWindDownMock.mockImplementation(async () => ({ + simulation: undefined, + signAndSend: async ({ + watcher, + }: { + watcher: { onSubmitted: () => void } + }) => { + watcher.onSubmitted() + mockVault.windDownClaimable = 0n + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 25_0000000n, + } + }, + })) + renderVaultPreview(connectedWallet) fireEvent.click( - await screen.findByRole("button", { - name: "Why you cannot claim this yet", - }), + await screen.findByRole("tab", { name: "Ready to claim 1" }), ) + expect(screen.getByText("Wind-down claim")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Claim" })) expect( - screen.getByText( - "This address is not on the vault's allowlist, so it cannot receive shares right now.", - ), + await screen.findByRole("heading", { name: "USDC claimed" }), + ).toBeTruthy() + expect(claimWindDownMock).toHaveBeenCalledWith({ + holder: investorAddress, + }) + expect( + await screen.findByRole("tab", { name: "Ready to claim 0" }), ).toBeTruthy() }) + it("shows a frozen holder's wind-down claim as held at zero", async () => { + mockVault.windingDown = true + mockIdentity.frozen = true + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: "Blocked 1" })) + + expect(await screen.findByText("Wind-down claim")).toBeTruthy() + expect(screen.getByText("Held")).toBeTruthy() + expect(screen.getByText("0.00 USDC")).toBeTruthy() + expect(screen.queryByRole("button", { name: "Claim" })).toBeNull() + }) + + it("tells an investor cancelling a redemption in a wind-down that the shares move into the wind-down claim", async () => { + mockVault.windingDown = true + mockRequests.redeems.set(mockRequests.currentEpoch, { + shares: 150_0000000n, + claimed: false, + }) + cancelRedeemMock.mockImplementation(async () => ({ + simulation: undefined, + signAndSend: () => new Promise(() => {}), + })) + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: "Waiting 1" })) + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })) + + expect(await screen.findByText(/into your wind-down claim/)).toBeTruthy() + }) + const readyRedeemEpoch = 7n const setReadyRedeem = () => { @@ -1464,7 +1740,7 @@ describe("VaultPreview", () => { await waitFor(() => expect(claimRedeemMock).toHaveBeenCalledTimes(1)) expect(claimRedeemMock).toHaveBeenCalledWith({ - caller: investorAddress, + holder: investorAddress, epoch_id: readyRedeemEpoch, }) }) diff --git a/app/src/pages/VaultPreview.tsx b/app/src/pages/VaultPreview.tsx index a519ac84..6447424c 100644 --- a/app/src/pages/VaultPreview.tsx +++ b/app/src/pages/VaultPreview.tsx @@ -27,7 +27,10 @@ import { useCancelDeposit } from "../hooks/useCancelDeposit" import { useCancelRedeem } from "../hooks/useCancelRedeem" import { useClaimDeposit } from "../hooks/useClaimDeposit" import { useClaimRedeem } from "../hooks/useClaimRedeem" +import { useClaimWindDown } from "../hooks/useClaimWindDown" +import { type Release } from "../hooks/useContractTransaction" import { useDepositBalance } from "../hooks/useDepositBalance" +import { useHolderFrozen } from "../hooks/useHolderFrozen" import { type InvestorRequest, useInvestorRequests, @@ -43,6 +46,8 @@ import { useVaultAuthorities } from "../hooks/useVaultAuthorities" import { useVaultFigures } from "../hooks/useVaultFigures" import { useVaultPaused } from "../hooks/useVaultPaused" import { useWallet } from "../hooks/useWallet" +import { useWindDownActive } from "../hooks/useWindDownActive" +import { useWindDownClaimable } from "../hooks/useWindDownClaimable" import typeStyles from "../styles/type.module.css" import { deriveAccess, @@ -62,11 +67,14 @@ import { } from "./vaultMetrics" import styles from "./VaultPreview.module.css" import { - allowlistRefusalFor, + outlookFor, owedAmount, toRequestEntriesByStage, + toWindDownEntry, } from "./vaultRequests" +const PREPARE_DELAY_MS = 400 + const sections: { id: string; label: string }[] = [ { id: "requests", label: "Requests" }, { id: "position", label: "Position" }, @@ -77,7 +85,7 @@ function vaultSummary(token: string | undefined): string[] { const claimWord = token ?? "redemption" return [ "Shares in this vault are a claim on an off-chain asset priced by an oracle. Subscribing or redeeming opens a request that becomes claimable once the oracle prices it.", - `A share claim is ready the moment it prices. A ${claimWord} claim waits until the vault's reserve can cover it in full. You may hold one open request per side, and only share claims need an allowlisted address.`, + `A share claim is ready the moment it prices. A ${claimWord} claim waits until the vault's reserve can cover it in full. You may hold one open request per side, and subscribing needs an allowlisted address.`, ] } @@ -103,12 +111,16 @@ const VaultPreview: React.FC = () => { const shareTokenSymbol = symbols?.shareToken ?? "" const { requests } = useInvestorRequests() const { pause } = useVaultPaused() + const { frozen } = useHolderFrozen() + const { windingDown } = useWindDownActive() + const { claimable } = useWindDownClaimable() const requestDeposit = usePendingTransaction(useRequestDeposit()) const cancelDepositTx = usePendingTransaction(useCancelDeposit()) const claimDepositTx = usePendingTransaction(useClaimDeposit()) const requestRedeem = usePendingTransaction(useRequestRedeem()) const cancelRedeemTx = usePendingTransaction(useCancelRedeem()) const claimRedeemTx = usePendingTransaction(useClaimRedeem()) + const claimWindDownTx = usePendingTransaction(useClaimWindDown()) const { state, appNetwork, walletNetwork } = networkStatus( address, networkPassphrase, @@ -167,11 +179,35 @@ const VaultPreview: React.FC = () => { ? toRequestEntriesByStage( requests, { token: tokenSymbol, shareToken: shareTokenSymbol }, - allowlistRefusalFor(allowance), - cancelRequest, - claimRequest, + { + outlookFor: outlookFor({ + allowance, + frozen, + windingDown, + pause, + nav, + }), + onCancel: cancelRequest, + onClaim: claimRequest, + }, ) : undefined + const windDownClaim = toWindDownEntry({ + windingDown, + frozen, + claimable, + shares: position.status === "held" ? position.shares : null, + tokens: { token: tokenSymbol, shareToken: shareTokenSymbol }, + onClaim: () => { + if (claimable !== null) + claimWindDownTx.submit( + undefined, + formatExact(claimable, AMOUNT_DECIMALS), + ) + }, + }) + if (entriesByStage !== undefined && windDownClaim !== undefined) + entriesByStage[windDownClaim.stage].unshift(windDownClaim.entry) const requestGroups: [RequestGroup, ...RequestGroup[]] = [ { id: "ready", @@ -244,6 +280,30 @@ const VaultPreview: React.FC = () => { requestRedeem.submit(amount, amountLabel) } + const settledAmount = + block === undefined ? parseUnits(actionAmount, AMOUNT_DECIMALS) : null + const preparable = + settledAmount !== null && + settledAmount > 0n && + (balance === null || settledAmount <= balance) + ? settledAmount + : null + const prepareDeposit = requestDeposit.prepare + const prepareRedeem = requestRedeem.prepare + + React.useEffect(() => { + if (preparable === null) return + const prepare = isSubscribe ? prepareDeposit : prepareRedeem + let release: Release | undefined + const timer = setTimeout(() => { + release = prepare(preparable) + }, PREPARE_DELAY_MS) + return () => { + clearTimeout(timer) + release?.() + } + }, [preparable, isSubscribe, prepareDeposit, prepareRedeem]) + React.useEffect(() => { if (requestDeposit.status.status === "confirmed") setActionAmount("") }, [requestDeposit.status]) @@ -288,6 +348,7 @@ const VaultPreview: React.FC = () => { amount: cancelRedeemTx.amountLabel, ticker: shareTokenSymbol, assetTicker: tokenSymbol, + windingDown: windingDown === true, onClose: cancelRedeemTx.reset, onRetry: cancelRedeemTx.retry, }, @@ -300,6 +361,15 @@ const VaultPreview: React.FC = () => { onClose: claimDepositTx.reset, onRetry: claimDepositTx.retry, }, + { + action: "claim-wind-down", + status: claimWindDownTx.status, + amount: claimWindDownTx.amountLabel, + ticker: tokenSymbol, + shareTicker: shareTokenSymbol, + onClose: claimWindDownTx.reset, + onRetry: claimWindDownTx.retry, + }, { action: "claim-redeem", status: claimRedeemTx.status, diff --git a/app/src/pages/actions.ts b/app/src/pages/actions.ts index ab25ce01..3e0739ad 100644 --- a/app/src/pages/actions.ts +++ b/app/src/pages/actions.ts @@ -19,6 +19,13 @@ export const parseAccount = (text: string): string | undefined => { : undefined } +const HASH = /^[0-9a-f]{64}$/i + +export const parseHash = (text: string): string | undefined => { + const trimmed = text.trim() + return HASH.test(trimmed) ? trimmed.toLowerCase() : undefined +} + export type Condition = { label: string met: boolean | null @@ -59,6 +66,7 @@ export type RailField = ActionField | SelectField | ConfirmField export type RailAction = { id: string + slot?: string label: string conditions: Condition[] outcome: string | null @@ -111,6 +119,7 @@ export const grantFor = ( export type Spec = { id: Id + slot?: string label: string conditions: Condition[] outcome: string | null @@ -125,6 +134,7 @@ export const toAction = ( grant: Grant, ): RailAction & { id: Id } => ({ id: spec.id, + ...(spec.slot === undefined ? {} : { slot: spec.slot }), label: spec.label, conditions: spec.conditions, outcome: spec.outcome, diff --git a/app/src/pages/compliance.test.ts b/app/src/pages/compliance.test.ts index 27b5cf88..efa66fc5 100644 --- a/app/src/pages/compliance.test.ts +++ b/app/src/pages/compliance.test.ts @@ -86,7 +86,7 @@ describe("toComplianceActions", () => { "unfreeze-address", "freeze-shares", "unfreeze-shares", - "force-transfer", + "legal-transfer", ]) expect(ids([grantOf("share-token admin")])).toEqual([]) }) @@ -287,13 +287,17 @@ describe("toComplianceActions", () => { ).toBe(false) }) - it("forces a transfer within the balance, unfreezing the shortfall", () => { - const inputs = { - "force-transfer-destination": DESTINATION, - "force-transfer-amount": "95", - } - const action = actionOf(stateOf(HOLDER), "force-transfer", inputs) + const transfer = (overrides: Record = {}) => ({ + "legal-transfer-destination": DESTINATION, + "legal-transfer-amount": "95", + "legal-transfer-reference": "ORDER-1", + ...overrides, + }) + + it("makes a legal transfer within the balance, unfreezing the shortfall", () => { + const action = actionOf(stateOf(HOLDER), "legal-transfer", transfer()) + expect(action.label).toBe("Legal transfer") expect(action.enabled).toBe(true) expect(action.outcome).toBe( "Shares 100.00 → 5.00. Frozen shares 10.00 → 5.00.", @@ -302,33 +306,67 @@ describe("toComplianceActions", () => { from: HOLDER, to: DESTINATION, amount: 95n * UNIT, + reference: "ORDER-1", }) expect(action.note).toBeUndefined() }) + it("refuses an empty reference and one over 128 bytes", () => { + const empty = actionOf( + stateOf(HOLDER), + "legal-transfer", + transfer({ "legal-transfer-reference": "" }), + ) + const overlong = actionOf( + stateOf(HOLDER), + "legal-transfer", + transfer({ "legal-transfer-reference": "é".repeat(65) }), + ) + const longest = actionOf( + stateOf(HOLDER), + "legal-transfer", + transfer({ "legal-transfer-reference": "a".repeat(128) }), + ) + + expect(unmet(empty)).toEqual([["Reference of 1 to 128 bytes", null]]) + expect(empty.enabled).toBe(false) + expect(empty.arg).toBeUndefined() + expect(unmet(overlong)).toEqual([["Reference of 1 to 128 bytes", false]]) + expect(overlong.fields?.find(({ label }) => label === "Reference")).toEqual( + expect.objectContaining({ invalid: true }), + ) + expect(overlong.enabled).toBe(false) + expect(longest.enabled).toBe(true) + }) + it("refuses a transfer above the balance and waits for a destination", () => { - const over = actionOf(stateOf(HOLDER), "force-transfer", { - "force-transfer-destination": DESTINATION, - "force-transfer-amount": "100.0000001", - }) - const missing = actionOf(stateOf(HOLDER), "force-transfer", { - "force-transfer-amount": "1", - }) + const over = actionOf( + stateOf(HOLDER), + "legal-transfer", + transfer({ "legal-transfer-amount": "100.0000001" }), + ) + const missing = actionOf( + stateOf(HOLDER), + "legal-transfer", + transfer({ "legal-transfer-destination": "" }), + ) expect(unmet(over)).toEqual([["Amount ≤ shares", false]]) expect(over.enabled).toBe(false) expect(unmet(missing)).toEqual([ ["Destination entered", null], ["Destination differs from the address", null], + ["Neither account is the vault", null], ]) expect(missing.enabled).toBe(false) }) it("refuses a destination equal to the target and shows no outcome", () => { - const action = actionOf(stateOf(HOLDER), "force-transfer", { - "force-transfer-destination": HOLDER, - "force-transfer-amount": "5", - }) + const action = actionOf( + stateOf(HOLDER), + "legal-transfer", + transfer({ "legal-transfer-destination": HOLDER }), + ) expect(unmet(action)).toEqual([ ["Destination differs from the address", false], @@ -341,30 +379,29 @@ describe("toComplianceActions", () => { it("notes a destination that is not allowlisted", () => { const action = actionOf( stateOf(HOLDER, {}, "not-allowed"), - "force-transfer", - { - "force-transfer-destination": DESTINATION, - "force-transfer-amount": "1", - }, + "legal-transfer", + transfer({ "legal-transfer-amount": "1" }), ) expect(action.enabled).toBe(true) expect(action.note).toBe( - "The destination is not allowlisted and cannot move them.", + "The destination is not allowlisted, so the share token will refuse the transfer.", ) }) - it("asks to confirm a transfer out of the vault", () => { - const action = actionOf(stateOf(VAULT), "force-transfer", { - "force-transfer-destination": DESTINATION, - "force-transfer-amount": "1", - }) + it("refuses the vault as either account", () => { + const out = actionOf(stateOf(VAULT), "legal-transfer", transfer()) + const into = actionOf( + stateOf(HOLDER), + "legal-transfer", + transfer({ "legal-transfer-destination": VAULT }), + ) - expect(action.enabled).toBe(false) - expect(action.fields?.at(-1)).toMatchObject({ - kind: "confirm", - label: "Drains the vault's redemption escrow.", - }) + expect(unmet(out)).toEqual([["Neither account is the vault", false]]) + expect(out.enabled).toBe(false) + expect(unmet(into)).toEqual([["Neither account is the vault", false]]) + expect(into.enabled).toBe(false) + expect(into.arg).toBeUndefined() }) }) diff --git a/app/src/pages/compliance.ts b/app/src/pages/compliance.ts index 0941efcb..9e5e1801 100644 --- a/app/src/pages/compliance.ts +++ b/app/src/pages/compliance.ts @@ -11,7 +11,7 @@ import { type Grant } from "../hooks/useAdminAuthority" import { type TargetReads, type TargetState } from "../hooks/useComplianceState" import { type AllowanceChange, - type ForcedTransfer, + type LegalTransferOrder, type SharesChange, } from "../hooks/useComplianceTransactions" import { type Allowance } from "../hooks/useIsAllowed" @@ -38,7 +38,7 @@ type AddressActionId = AllowActionId | FreezeActionId type SharesActionId = "freeze-shares" | "unfreeze-shares" export type ComplianceActionId = - AddressActionId | SharesActionId | "force-transfer" + AddressActionId | SharesActionId | "legal-transfer" export const COMPLIANCE_ACTION_IDS: ComplianceActionId[] = [ "allow-address", @@ -47,14 +47,14 @@ export const COMPLIANCE_ACTION_IDS: ComplianceActionId[] = [ "unfreeze-address", "freeze-shares", "unfreeze-shares", - "force-transfer", + "legal-transfer", ] export type ComplianceAction = | Built | Built | Built - | Built<"force-transfer", ForcedTransfer | undefined> + | Built<"legal-transfer", LegalTransferOrder | undefined> export type ComplianceState = { verifier: string | null @@ -65,7 +65,9 @@ export type ComplianceState = { adminUnknown: boolean } -const DESTINATION_KEY = "force-transfer-destination" +const DESTINATION_KEY = "legal-transfer-destination" +const REFERENCE_KEY = "legal-transfer-reference" +const MAX_REFERENCE_BYTES = 128 export const destinationOf = (inputs: Inputs): string | undefined => parseAccount(inputs[DESTINATION_KEY] ?? "") @@ -309,11 +311,43 @@ const transferOutcome = ( return `${shares} Frozen shares ${exactAmount(reads.frozenShares)} → ${exactAmount(unfrozen)}.` } -function forceTransferSpec( +const referenceEntry = (inputs: Inputs) => { + const text = inputs[REFERENCE_KEY] ?? "" + const bytes = new TextEncoder().encode(text).length + const fits = bytes >= 1 && bytes <= MAX_REFERENCE_BYTES + const condition: Condition = { + label: "Reference of 1 to 128 bytes", + met: text === "" ? null : fits, + } + const field: RailField = { + key: REFERENCE_KEY, + label: "Reference", + placeholder: "Order reference", + value: text, + invalid: text !== "" && !fits, + text: true, + } + return { reference: fits ? text : undefined, condition, field } +} + +const notVault = ( + vault: string, + from: string | undefined, + to: string | undefined, +): Condition => { + if (from === vault || to === vault) + return { label: "Neither account is the vault", met: false } + return { + label: "Neither account is the vault", + met: from === undefined || to === undefined ? null : true, + } +} + +function legalTransferSpec( state: ComplianceState, inputs: Inputs, -): BuiltSpec<"force-transfer", ForcedTransfer | undefined> { - const id = "force-transfer" +): BuiltSpec<"legal-transfer", LegalTransferOrder | undefined> { + const id = "legal-transfer" const { target } = state const reads = readsOf(state) const shares = reads?.shares ?? null @@ -321,6 +355,7 @@ function forceTransferSpec( const destination = destinationOf(inputs) const entry = amountEntry(`${id}-amount`, inputs, shares) const { amount } = entry + const { reference, ...referenced } = referenceEntry(inputs) const entered = targetEntered(target) const destinationEntered: Condition = { label: "Destination entered", @@ -333,42 +368,34 @@ function forceTransferSpec( ? null : destination !== target, } + const outsideVault = notVault(state.vault, target, destination) const bound = atMost("Amount ≤ shares", amount, shares) - const confirm = vaultConfirmation( - id, - "Drains the vault's redemption escrow.", - state, - inputs, - `:${destinationText}:${entry.text}`, - ) - const required = [ - entered, - destinationEntered, - differs, - positive(entry), - ...(confirm ? [confirm.condition] : []), - ] const valid = target !== undefined && destination !== undefined && differs.met === true && + outsideVault.met === true && amount !== undefined && - amount > 0n + amount > 0n && + reference !== undefined return { id, - label: "Force a transfer", + label: "Legal transfer", conditions: [ entered, destinationEntered, differs, + outsideVault, positive(entry), bound, - ...(confirm ? [confirm.condition] : []), + referenced.condition, ], outcome: !valid || bound.met !== true ? null : transferOutcome(reads, amount), ...(destination !== undefined && state.destination === "not-allowed" - ? { note: "The destination is not allowlisted and cannot move them." } + ? { + note: "The destination is not allowlisted, so the share token will refuse the transfer.", + } : {}), fields: [ { @@ -380,11 +407,19 @@ function forceTransferSpec( text: true, }, entry.field, - ...(confirm ? [confirm.field] : []), + referenced.field, ], - ...(confirm === undefined ? {} : { danger: true }), - ready: allMet(required), - arg: valid ? { from: target, to: destination, amount } : undefined, + ready: allMet([ + entered, + destinationEntered, + differs, + outsideVault, + positive(entry), + referenced.condition, + ]), + arg: valid + ? { from: target, to: destination, amount, reference } + : undefined, next: valid ? `${exactAmount(amount)} shares from ${shortAddress(target)} to ${shortAddress(destination)}` : "", @@ -442,7 +477,7 @@ export function toComplianceActions( ), build(sharesSpec("freeze-shares", state, inputs), officer), build(sharesSpec("unfreeze-shares", state, inputs), officer), - build(forceTransferSpec(state, inputs), officer), + build(legalTransferSpec(state, inputs), officer), ) return actions } diff --git a/app/src/pages/configuration.test.ts b/app/src/pages/configuration.test.ts index d07515d2..b1b7dd72 100644 --- a/app/src/pages/configuration.test.ts +++ b/app/src/pages/configuration.test.ts @@ -37,12 +37,16 @@ const ready = ( custodian: { kind: "set", address: CUSTODIAN }, notice: DAY, windDownDelay: 7n * DAY, + minEpochDuration: { kind: "unset" }, upgradeDelay: 3n * DAY, + setupOpen: false, + tokenSetupOpen: true, + oracleSetupOpen: true, oracle: { freshness: DAY, cooldown: 3_600n, maxUpBps: 1_000, - maxDownBps: null, + maxDownBps: 500, min: PRICE_ONE / 2n, max: 2n * PRICE_ONE, }, @@ -82,7 +86,13 @@ describe("toConfigurationActions", () => { it.each<[Grant, ConfigurationAction["id"][]]>([ [ governance, - ["set-deposit-cap", "set-custodian", "set-notice", "set-wind-down-delay"], + [ + "set-deposit-cap", + "propose-custodian", + "set-notice", + "set-wind-down-delay", + "propose-min-epoch-duration", + ], ], [oracleAdmin, ["set-oracle-config"]], [tokenAdmin, ["set-compliance", "set-identity-verifier"]], @@ -195,7 +205,7 @@ describe("toConfigurationActions", () => { expect(uncapped.enabled).toBe(false) }) - it("holds a deposit cap and an oracle duration at their contract width", () => { + it("holds a deposit cap and the oracle limits at their contract edges", () => { const cap = (text: string) => find("set-deposit-cap", [governance], { depositCap: text }) const oracle = (inputs: ConfigurationInputs) => @@ -214,28 +224,31 @@ describe("toConfigurationActions", () => { ), ).toBe(false) expect( - condition( - oracle({ freshness: "18446744073709551615" }), - "Freshness window ≤ 2^64−1 s", - ), + condition(oracle({ freshness: "35d" }), "Freshness window ≤ 35d"), ).toBe(true) expect( - condition( - oracle({ cooldown: "18446744073709551615" }), - "Cooldown ≤ 2^64−1 s", - ), + condition(oracle({ cooldown: "1d" }), "Cooldown ≤ freshness window"), + ).toBe(true) + expect( + condition(oracle({ maxFall: "0.01" }), "Largest fall 0.01% to 100%"), + ).toBe(true) + expect( + condition(oracle({ maxFall: "100" }), "Largest fall 0.01% to 100%"), + ).toBe(true) + expect( + condition(oracle({ ceiling: "1000000" }), "Price ceiling ≤ 1,000,000"), ).toBe(true) }) it("takes an account or a contract as custodian and says where deploys go", () => { for (const address of [ACCOUNT, CONTRACT]) { - const custodian = find("set-custodian", [governance], { + const custodian = find("propose-custodian", [governance], { custodian: address, }) expect(custodian.enabled).toBe(true) expect(custodian.arg).toBe(address) } - const custodian = find("set-custodian", [governance], { + const custodian = find("propose-custodian", [governance], { custodian: ACCOUNT, }) expect(custodian.outcome).toBe( @@ -243,8 +256,186 @@ describe("toConfigurationActions", () => { ) }) + it("proposes the custodian after setup and says how long it waits", () => { + const custodian = find("propose-custodian", [governance], { + custodian: ACCOUNT, + }) + + expect(custodian.label).toBe("Set the custodian") + expect(custodian.conditions).toContainEqual({ + label: "Waits for the timelock", + met: true, + detail: "3d", + }) + }) + + it("sets the custodian at once while setup is open", () => { + const custodian = find( + "set-custodian", + [governance], + { custodian: ACCOUNT }, + ready({ setupOpen: true }), + ) + + expect(custodian.enabled).toBe(true) + expect(custodian.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Setup is open", + }) + }) + + it("holds the custodian while the setup state is unknown", () => { + const custodian = find( + "set-custodian", + [governance], + { custodian: ACCOUNT }, + ready({ setupOpen: null }), + ) + + expect(condition(custodian, "Setup state read")).toBeNull() + expect(custodian.enabled).toBe(false) + }) + + it("proposes a lower notice after setup and keeps raising it direct", () => { + const lower = find("propose-notice", [governance], { notice: "12h" }) + const higher = find("set-notice", [governance], { notice: "2d" }) + const same = find("set-notice", [governance], { notice: "1d" }) + + expect(lower.enabled).toBe(true) + expect(lower.arg).toBe(43_200n) + expect(lower.label).toBe("Set the notice") + expect(lower.conditions).toContainEqual({ + label: "Waits for the timelock", + met: true, + detail: "3d", + }) + expect(higher.enabled).toBe(true) + expect(higher.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Raising is instant", + }) + expect(condition(same, "Differs from current")).toBe(false) + }) + + it("sets a lower notice at once while setup is open", () => { + const notice = find( + "set-notice", + [governance], + { notice: "12h" }, + ready({ setupOpen: true }), + ) + + expect(notice.enabled).toBe(true) + expect(condition(notice, "Applies at once")).toBe(true) + }) + + it("proposes a lower wind-down delay after setup and refuses one under 7 days", () => { + const state = ready({ windDownDelay: 14n * DAY }) + const lower = find( + "propose-wind-down-delay", + [governance], + { windDownDelay: "8d" }, + state, + ) + const short = find( + "propose-wind-down-delay", + [governance], + { windDownDelay: "6d" }, + state, + ) + const raised = find( + "set-wind-down-delay", + [governance], + { windDownDelay: "20d" }, + state, + ) + + expect(lower.enabled).toBe(true) + expect(condition(short, "Wind-down delay ≥ 7d")).toBe(false) + expect(short.enabled).toBe(false) + expect(raised.enabled).toBe(true) + }) + + it("sets the minimum epoch duration at once while setup is open", () => { + const duration = find( + "set-min-epoch-duration", + [governance], + { minEpochDuration: "7d" }, + ready({ setupOpen: true }), + ) + + expect(duration).toMatchObject({ + slot: "set-min-epoch-duration", + label: "Set the minimum epoch duration", + arg: 7n * DAY, + next: "7d", + enabled: true, + outcome: "Anyone may seal an epoch once it has been open this long.", + }) + expect(duration.fields?.[0]?.current).toBe("—") + expect(duration.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Setup is open", + }) + }) + + it("proposes the minimum epoch duration after setup, even when lowering it", () => { + const state = ready({ minEpochDuration: { kind: "set", secs: 7n * DAY } }) + const raised = find( + "propose-min-epoch-duration", + [governance], + { minEpochDuration: "14d" }, + state, + ) + const lowered = find( + "propose-min-epoch-duration", + [governance], + { minEpochDuration: "2d" }, + state, + ) + + expect(raised.enabled).toBe(true) + expect(raised.arg).toBe(14n * DAY) + expect(raised.fields?.[0]?.current).toBe("7d") + expect(raised.conditions).toContainEqual({ + label: "Waits for the timelock", + met: true, + detail: "3d", + }) + expect(lowered.enabled).toBe(true) + }) + + it.each<[string, string, boolean]>([ + ["23h 59m 59s", "Minimum epoch duration ≥ 1d", false], + ["1d", "Minimum epoch duration ≥ 1d", true], + ["30d", "Minimum epoch duration ≤ 30d", true], + ["30d 1s", "Minimum epoch duration ≤ 30d", false], + ])( + "holds a minimum epoch duration of %s to its bounds", + (text, label, met) => { + const duration = find("propose-min-epoch-duration", [governance], { + minEpochDuration: text, + }) + + expect(condition(duration, label)).toBe(met) + expect(duration.enabled).toBe(met) + }, + ) + + it("keeps the deposit cap direct after setup", () => { + const cap = find("set-deposit-cap", [governance], { depositCap: "5" }) + + expect(cap.enabled).toBe(true) + expect(cap.conditions.map(({ label }) => label)).not.toContain( + "Waits for the timelock", + ) + }) + it("lets the first custodian be set when none is set yet", () => { - const unset = ready({ custodian: { kind: "unset" } }) + const unset = ready({ custodian: { kind: "unset" }, setupOpen: true }) const blank = find("set-custodian", [governance], {}, unset) expect(blank.enabled).toBe(false) @@ -266,7 +457,7 @@ describe("toConfigurationActions", () => { "set-custodian", [governance], { custodian: ACCOUNT }, - ready({ custodian: { kind: "unreadable" } }), + ready({ custodian: { kind: "unreadable" }, setupOpen: true }), ) expect(condition(custodian, "Differs from current")).toBeNull() @@ -274,7 +465,7 @@ describe("toConfigurationActions", () => { }) it("flags an address that is not a valid key", () => { - const custodian = find("set-custodian", [governance], { + const custodian = find("propose-custodian", [governance], { custodian: "GNOPE", }) @@ -300,6 +491,137 @@ describe("toConfigurationActions", () => { ).toBe(true) }) + it("proposes the compliance contract and identity verifier once the token's own setup closes", () => { + const closed = ready({ setupOpen: true, tokenSetupOpen: false }) + const compliance = find( + "propose-compliance", + [tokenAdmin], + { compliance: CONTRACT }, + closed, + ) + const verifier = find( + "propose-identity-verifier", + [tokenAdmin], + { identityVerifier: CONTRACT }, + closed, + ) + const waits = { label: "Waits for the timelock", met: true, detail: "3d" } + + expect(compliance).toMatchObject({ + slot: "set-compliance", + label: "Set the compliance contract", + arg: CONTRACT, + enabled: true, + }) + expect(compliance.conditions).toContainEqual(waits) + expect(verifier).toMatchObject({ + slot: "set-identity-verifier", + label: "Set the identity verifier", + arg: CONTRACT, + enabled: true, + }) + expect(verifier.conditions).toContainEqual(waits) + }) + + it("sets the compliance contract at once while the token's setup is open, even after the vault's closes", () => { + const compliance = find("set-compliance", [tokenAdmin], { + compliance: CONTRACT, + }) + + expect(compliance.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Setup is open", + }) + }) + + it("holds the token setters while the token's setup state is unknown", () => { + const verifier = find( + "set-identity-verifier", + [tokenAdmin], + { identityVerifier: CONTRACT }, + ready({ tokenSetupOpen: null }), + ) + + expect(condition(verifier, "Setup state read")).toBeNull() + expect(verifier.enabled).toBe(false) + }) + + it("proposes new oracle limits once the oracle's own setup closes", () => { + const closed = ready({ setupOpen: true, oracleSetupOpen: false }) + const config = find( + "propose-oracle-config", + [oracleAdmin], + { maxRise: "5" }, + closed, + ) + + expect(config).toMatchObject({ + slot: "set-oracle-config", + label: "Set the oracle limits", + enabled: true, + }) + expect(config.arg?.max_up_bps).toBe(500) + expect(config.conditions).toContainEqual({ + label: "Waits for the timelock", + met: true, + detail: "3d", + }) + }) + + it("sets a shorter freshness window at once after setup, and nothing else with it", () => { + const closed = ready({ oracleSetupOpen: false }) + const shorter = find( + "set-oracle-config", + [oracleAdmin], + { freshness: "12h" }, + closed, + ) + const longer = find( + "propose-oracle-config", + [oracleAdmin], + { freshness: "2d" }, + closed, + ) + const withRise = find( + "propose-oracle-config", + [oracleAdmin], + { freshness: "12h", maxRise: "5" }, + closed, + ) + + expect(shorter.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Shortening the freshness window is instant", + }) + expect(shorter.enabled).toBe(true) + expect(longer.enabled).toBe(true) + expect(withRise.enabled).toBe(true) + }) + + it("sets the oracle limits at once while the oracle's setup is open, even after the vault's closes", () => { + const config = find("set-oracle-config", [oracleAdmin], { maxRise: "5" }) + + expect(config.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Setup is open", + }) + }) + + it("holds the oracle limits while the oracle's setup state is unknown", () => { + const config = find( + "set-oracle-config", + [oracleAdmin], + { maxRise: "5" }, + ready({ oracleSetupOpen: null }), + ) + + expect(condition(config, "Setup state read")).toBeNull() + expect(config.enabled).toBe(false) + }) + it("prefills every oracle limit and sends them all when one changes", () => { const config = find("set-oracle-config", [oracleAdmin], { maxRise: "5" }) @@ -307,7 +629,7 @@ describe("toConfigurationActions", () => { ["Freshness window", "1d"], ["Cooldown", "1h"], ["Largest rise", "5"], - ["Largest fall", ""], + ["Largest fall", "5"], ["Price floor", "0.50"], ["Price ceiling", "2.00"], ]) @@ -316,25 +638,36 @@ describe("toConfigurationActions", () => { freshness_duration: DAY, cooldown_secs: 3_600n, max_up_bps: 500, - max_down_bps: null, + max_down_bps: 500, min_answer: PRICE_ONE / 2n, max_answer: 2n * PRICE_ONE, }) }) + it("requires a largest fall", () => { + const config = find("set-oracle-config", [oracleAdmin], { maxFall: "" }) + + expect(config.arg).toBeUndefined() + expect(config.enabled).toBe(false) + expect(config.fields?.[3]).toMatchObject({ + label: "Largest fall", + placeholder: "10", + next: null, + }) + }) + it.each<[ConfigurationInputs, string]>([ - [{ freshness: "0" }, "Freshness window > 0"], - [{ freshness: "18446744073709551616" }, "Freshness window ≤ 2^64−1 s"], - [{ cooldown: "18446744073709551616" }, "Cooldown ≤ 2^64−1 s"], - [{ maxRise: "0" }, "Largest rise > 0"], - [{ maxRise: "100.01" }, "Largest rise ≤ 100%"], - [{ maxFall: "101%" }, "Largest fall ≤ 100%"], + [{ freshness: "0" }, "Freshness window ≥ 1s"], + [{ freshness: "35d 1s" }, "Freshness window ≤ 35d"], + [{ cooldown: "0" }, "Cooldown ≥ 1s"], + [{ cooldown: "1d 1s" }, "Cooldown ≤ freshness window"], + [{ maxRise: "0" }, "Largest rise 0.01% to 100%"], + [{ maxRise: "100.01" }, "Largest rise 0.01% to 100%"], + [{ maxFall: "0" }, "Largest fall 0.01% to 100%"], + [{ maxFall: "100.01%" }, "Largest fall 0.01% to 100%"], [{ floor: "0" }, "Price floor > 0"], [{ ceiling: "0.4" }, "Price ceiling ≥ floor"], - [ - { ceiling: "17014118346046924" }, - "Price ceiling ≤ 17,014,118,346,046,923", - ], + [{ ceiling: "1000000.01" }, "Price ceiling ≤ 1,000,000"], ])("names the oracle limit %o breaks", (inputs, label) => { const config = find("set-oracle-config", [oracleAdmin], inputs) @@ -386,7 +719,7 @@ describe("toConfigurationGroups", () => { expect( groups[1]?.strips.map((strip) => strip.map((tile) => tile.value)), ).toEqual([ - ["1d", "1h", "10%", "No limit"], + ["1d", "1h", "10%", "5%"], ["0.5000", "2.0000"], ]) expect(groups[2]?.strips).toEqual([]) diff --git a/app/src/pages/configuration.ts b/app/src/pages/configuration.ts index 515e73ec..c391a6c9 100644 --- a/app/src/pages/configuration.ts +++ b/app/src/pages/configuration.ts @@ -37,6 +37,7 @@ import { oracleLimitRows, parseDuration, } from "./cycle" +import { type Route, routeCondition, routeOf } from "./timelock" import { toCustodianRow } from "./vaultMetrics" export type FieldKey = @@ -44,6 +45,7 @@ export type FieldKey = | "custodian" | "notice" | "windDownDelay" + | "minEpochDuration" | "freshness" | "cooldown" | "maxRise" @@ -61,9 +63,25 @@ type Setter = Id extends string export type ConfigurationAction = | Setter<"set-deposit-cap", bigint | null> - | Setter<"set-custodian" | "set-compliance" | "set-identity-verifier", string> - | Setter<"set-notice" | "set-wind-down-delay", bigint> - | Setter<"set-oracle-config", OracleConfig> + | Setter< + | "set-custodian" + | "propose-custodian" + | "set-compliance" + | "propose-compliance" + | "set-identity-verifier" + | "propose-identity-verifier", + string + > + | Setter< + | "set-notice" + | "propose-notice" + | "set-wind-down-delay" + | "propose-wind-down-delay" + | "set-min-epoch-duration" + | "propose-min-epoch-duration", + bigint + > + | Setter<"set-oracle-config" | "propose-oracle-config", OracleConfig> export type ConfigurationGroup = { title: string @@ -75,14 +93,20 @@ export const EFFECTS = { custodian: "Treasury deploys to the new address from now on.", notice: "Applies to epochs closed from now on.", windDownDelay: "A proposed wind-down keeps its activation time.", + minEpochDuration: "Anyone may seal an epoch once it has been open this long.", } as const const MAX_NOTICE = 30n * DAY +const MIN_WIND_DOWN_DELAY = 7n * DAY const MAX_WIND_DOWN_DELAY = 90n * DAY +const MIN_EPOCH_DURATION = DAY +const MAX_EPOCH_DURATION = 30n * DAY +const MIN_SECS = 1n +const MAX_FRESHNESS = 35n * DAY +const MIN_BPS = 1 const MAX_BPS = 10_000 -const MAX_U64 = (1n << 64n) - 1n const MAX_I128 = (1n << 127n) - 1n -const MAX_CEILING = MAX_I128 / 10_000n +const MAX_CEILING = 1_000_000n * 10n ** BigInt(PRICE_DECIMALS) const PERCENT = /^(\d+)(?:\.(\d{1,2}))?\s*%?$/ type Kind = { @@ -139,20 +163,13 @@ const PRICE: Kind = { placeholder: "0.00", } -const RISE: Kind = { +const MOVE: Kind = { parse: parseBps, show: formatPercent, prefill: (bps) => String(bps / 100), placeholder: "10", } -const FALL: Kind = { - parse: orNone(parseBps), - show: (bps) => (bps === null ? "No limit" : formatPercent(bps)), - prefill: (bps) => (bps === null ? "" : String(bps / 100)), - placeholder: "No limit", -} - type Parsed = { arg: T | undefined; field: ActionField } function parsed( @@ -241,6 +258,14 @@ const upgradeBound = ( : notice <= upgradeDelay, }) +const lowering = ( + arg: bigint | undefined, + current: bigint | undefined, +): boolean | undefined => + arg === undefined || current === undefined ? undefined : arg < current + +const RAISED = "Raising is instant" + function vaultSpecs(state: ConfigurationValues, inputs: ConfigurationInputs) { const cap = capOf(state) const capField = parsed("depositCap", "Deposit cap", CAP, cap, inputs) @@ -256,6 +281,27 @@ function vaultSpecs(state: ConfigurationValues, inputs: ConfigurationInputs) { delay, inputs, ) + const minEpoch = state.minEpochDuration + const minEpochSecs = minEpoch?.kind === "set" ? minEpoch.secs : undefined + const minEpochField = parsed( + "minEpochDuration", + "Minimum epoch duration", + DURATION, + minEpochSecs, + inputs, + ) + const setupRoute = routeOf(state.setupOpen, true) + const noticeRoute = routeOf( + state.setupOpen, + lowering(noticeField.arg, notice), + ) + const delayRoute = routeOf(state.setupOpen, lowering(delayField.arg, delay)) + const waits = (route: Route) => + routeCondition( + route, + state.upgradeDelay, + state.setupOpen === true ? undefined : RAISED, + ) return [ single("set-deposit-cap", "Set the deposit cap", capField, cap, [ bound("Deposit cap ≥ 0", capField.arg, (value) => (value ?? 0n) >= 0n), @@ -265,66 +311,129 @@ function vaultSpecs(state: ConfigurationValues, inputs: ConfigurationInputs) { (value) => (value ?? 0n) <= MAX_I128, ), ]), - single( - "set-custodian", - "Set the custodian", - parsed("custodian", "Custodian", ANY_ADDRESS, custodian, inputs), - state.custodian.kind === "unset" ? UNSET : custodian, - [], - EFFECTS.custodian, - ), - single( - "set-notice", - "Set the notice", - noticeField, - notice, - [ - bound("Notice ≤ 30d", noticeField.arg, (value) => value <= MAX_NOTICE), - upgradeBound(noticeField.arg, state.upgradeDelay), - ], - EFFECTS.notice, - ), - single( - "set-wind-down-delay", - "Set the wind-down delay", - delayField, - delay, - [ - bound( - "Wind-down delay ≤ 90d", - delayField.arg, - (value) => value <= MAX_WIND_DOWN_DELAY, - ), - ], - EFFECTS.windDownDelay, - ), + { + ...single( + setupRoute === "timelock" ? "propose-custodian" : "set-custodian", + "Set the custodian", + parsed("custodian", "Custodian", ANY_ADDRESS, custodian, inputs), + state.custodian.kind === "unset" ? UNSET : custodian, + [routeCondition(setupRoute, state.upgradeDelay)], + EFFECTS.custodian, + ), + slot: "set-custodian", + }, + { + ...single( + noticeRoute === "timelock" ? "propose-notice" : "set-notice", + "Set the notice", + noticeField, + notice, + [ + bound( + "Notice ≤ 30d", + noticeField.arg, + (value) => value <= MAX_NOTICE, + ), + upgradeBound(noticeField.arg, state.upgradeDelay), + waits(noticeRoute), + ], + EFFECTS.notice, + ), + slot: "set-notice", + }, + { + ...single( + delayRoute === "timelock" + ? "propose-wind-down-delay" + : "set-wind-down-delay", + "Set the wind-down delay", + delayField, + delay, + [ + bound( + "Wind-down delay ≥ 7d", + delayField.arg, + (value) => value >= MIN_WIND_DOWN_DELAY, + ), + bound( + "Wind-down delay ≤ 90d", + delayField.arg, + (value) => value <= MAX_WIND_DOWN_DELAY, + ), + waits(delayRoute), + ], + EFFECTS.windDownDelay, + ), + slot: "set-wind-down-delay", + }, + { + ...single( + setupRoute === "timelock" + ? "propose-min-epoch-duration" + : "set-min-epoch-duration", + "Set the minimum epoch duration", + minEpochField, + minEpoch?.kind === "unset" ? UNSET : minEpochSecs, + [ + bound( + `Minimum epoch duration ≥ ${formatDuration(MIN_EPOCH_DURATION)}`, + minEpochField.arg, + (value) => value >= MIN_EPOCH_DURATION, + ), + bound( + `Minimum epoch duration ≤ ${formatDuration(MAX_EPOCH_DURATION)}`, + minEpochField.arg, + (value) => value <= MAX_EPOCH_DURATION, + ), + routeCondition(setupRoute, state.upgradeDelay), + ], + EFFECTS.minEpochDuration, + ), + slot: "set-min-epoch-duration", + }, ] as const } function tokenSpecs(state: ConfigurationValues, inputs: ConfigurationInputs) { const compliance = known(state.compliance) const verifier = known(state.identityVerifier) + const route = routeOf(state.tokenSetupOpen, true) + const waits = [routeCondition(route, state.upgradeDelay)] return [ - single( - "set-compliance", - "Set the compliance contract", - parsed("compliance", "Compliance contract", CONTRACT, compliance, inputs), - compliance, - [], - ), - single( - "set-identity-verifier", - "Set the identity verifier", - parsed( - "identityVerifier", - "Identity verifier", - CONTRACT, + { + ...single( + route === "timelock" ? "propose-compliance" : "set-compliance", + "Set the compliance contract", + parsed( + "compliance", + "Compliance contract", + CONTRACT, + compliance, + inputs, + ), + compliance, + waits, + ), + slot: "set-compliance", + }, + { + ...single( + route === "timelock" + ? "propose-identity-verifier" + : "set-identity-verifier", + "Set the identity verifier", + parsed( + "identityVerifier", + "Identity verifier", + CONTRACT, + verifier, + inputs, + ), verifier, - inputs, + waits, ), - verifier, - [], - ), + slot: "set-identity-verifier", + }, ] as const } @@ -340,6 +449,15 @@ const toConfig = (limits: OracleLimits): OracleConfig => ({ const sameConfig = (a: OracleConfig, b: OracleConfig): boolean => (Object.keys(a) as (keyof OracleConfig)[]).every((key) => a[key] === b[key]) +const shortensFreshnessOnly = ( + current: OracleConfig, + next: OracleConfig, +): boolean => + sameConfig( + { ...next, freshness_duration: current.freshness_duration }, + current, + ) && next.freshness_duration <= current.freshness_duration + type ParsedConfig = { [K in keyof OracleConfig]: Parsed } function oracleFields( @@ -367,8 +485,8 @@ function oracleFields( DURATION, ), cooldown_secs: field("cooldown_secs", "cooldown", "Cooldown", DURATION), - max_up_bps: field("max_up_bps", "maxRise", "Largest rise", RISE), - max_down_bps: field("max_down_bps", "maxFall", "Largest fall", FALL), + max_up_bps: field("max_up_bps", "maxRise", "Largest rise", MOVE), + max_down_bps: field("max_down_bps", "maxFall", "Largest fall", MOVE), min_answer: field("min_answer", "floor", "Price floor", PRICE), max_answer: field("max_answer", "ceiling", "Price ceiling", PRICE), } @@ -398,36 +516,40 @@ const configOf = ({ max_answer: ceiling.arg, } +const withinBps = (value: number): boolean => + value >= MIN_BPS && value <= MAX_BPS + const oracleBounds = (fields: ParsedConfig): Condition[] => { + const freshness = fields.freshness_duration.arg + const cooldown = fields.cooldown_secs.arg const floor = fields.min_answer.arg const ceiling = fields.max_answer.arg + const bpsRange = `${formatPercent(MIN_BPS)} to ${formatPercent(MAX_BPS)}` return [ bound( - "Freshness window > 0", - fields.freshness_duration.arg, - (value) => value > 0n, - ), - bound( - "Freshness window ≤ 2^64−1 s", - fields.freshness_duration.arg, - (value) => value <= MAX_U64, + `Freshness window ≥ ${formatDuration(MIN_SECS)}`, + freshness, + (value) => value >= MIN_SECS, ), bound( - "Cooldown ≤ 2^64−1 s", - fields.cooldown_secs.arg, - (value) => value <= MAX_U64, + `Freshness window ≤ ${formatDuration(MAX_FRESHNESS)}`, + freshness, + (value) => value <= MAX_FRESHNESS, ), - bound("Largest rise > 0", fields.max_up_bps.arg, (value) => value > 0), bound( - "Largest rise ≤ 100%", - fields.max_up_bps.arg, - (value) => value <= MAX_BPS, - ), - bound( - "Largest fall ≤ 100%", - fields.max_down_bps.arg, - (value) => value === null || value <= MAX_BPS, + `Cooldown ≥ ${formatDuration(MIN_SECS)}`, + cooldown, + (value) => value >= MIN_SECS, ), + { + label: "Cooldown ≤ freshness window", + met: + freshness === undefined || cooldown === undefined + ? null + : cooldown <= freshness, + }, + bound(`Largest rise ${bpsRange}`, fields.max_up_bps.arg, withinBps), + bound(`Largest fall ${bpsRange}`, fields.max_down_bps.arg, withinBps), bound("Price floor > 0", floor, (value) => value > 0n), { label: "Price ceiling ≥ floor", @@ -445,17 +567,31 @@ const oracleBounds = (fields: ParsedConfig): Condition[] => { function oracleSpec( state: ConfigurationValues, inputs: ConfigurationInputs, -): Spec<"set-oracle-config"> & { arg: OracleConfig | undefined; next: string } { +): Spec<"set-oracle-config" | "propose-oracle-config"> & { + arg: OracleConfig | undefined + next: string +} { const current = state.oracle === null ? undefined : toConfig(state.oracle) const fields = oracleFields(current, inputs) const arg = configOf(fields) - const changed = - arg === undefined || current === undefined - ? undefined - : !sameConfig(arg, current) - const conditions = [...oracleBounds(fields), differs(changed)] + const unread = arg === undefined || current === undefined + const changed = unread ? undefined : !sameConfig(arg, current) + const waits = unread ? undefined : !shortensFreshnessOnly(current, arg) + const route = routeOf(state.oracleSetupOpen, waits) + const conditions = [ + ...oracleBounds(fields), + routeCondition( + route, + state.upgradeDelay, + state.oracleSetupOpen === true + ? undefined + : "Shortening the freshness window is instant", + ), + differs(changed), + ] return { - id: "set-oracle-config", + id: route === "timelock" ? "propose-oracle-config" : "set-oracle-config", + slot: "set-oracle-config", label: "Set the oracle limits", conditions, outcome: null, @@ -481,12 +617,13 @@ export function toConfigurationActions( const actions: ConfigurationAction[] = [] const governance = grantFor(grants, ROLES.governance, wallet) if (governance !== undefined) { - const [cap, custodian, notice, delay] = vaultSpecs(state, inputs) + const [cap, custodian, notice, delay, minEpoch] = vaultSpecs(state, inputs) actions.push( build(cap, governance), build(custodian, governance), build(notice, governance), build(delay, governance), + build(minEpoch, governance), ) } const oracleAdmin = grantFor(grants, ROLES.oracleAdmin, wallet) diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 1f8a70ff..5d55f12e 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -71,6 +71,8 @@ const epoch: CycleEpoch = { awaiting: sealed, awaitingKnown: true, noticeSeconds: 3_600n, + minDuration: { kind: "set", secs: 86_400n }, + openedAt: 1_700_000_000n, } const oracle: CycleOracle = { @@ -78,13 +80,14 @@ const oracle: CycleOracle = { price: price(1n), attestedAt: 1_700_050_000n, expiresAt: 1_700_136_000n, - ripcord: false, + proof: { hash: "ab".repeat(32), uri: "https://example.com/nav.pdf" }, + navPaused: false, recorded: true, limits: { freshness: 86_400n, cooldown: 3_600n, maxUpBps: 500, - maxDownBps: null, + maxDownBps: 1_000, min: price(1n) / 2n, max: price(2n), }, @@ -166,8 +169,8 @@ describe("toCycleRows", () => { ["Status", "Round", "Owed"], ]) expect(groups.map((group) => group.rows.map((row) => row.label))).toEqual([ - ["Vault", "Notice period", "Sealed at", "Notice"], - ["Ripcord"], + ["Vault", "Anyone can seal", "Notice period", "Sealed at", "Notice"], + ["NAV paused", "Proof hash", "Proof URI"], ["Committed", "Liquid reserve"], ["Activates at", "Delay", "Supply"], ]) @@ -240,6 +243,24 @@ describe("toCycleRows", () => { expect(value ?? "Unavailable").toBe(expected) }) + it.each<[string, Partial, string | null]>([ + ["no minimum", { minDuration: { kind: "unset" } }, "Manager only"], + [ + "a minimum still running", + { openedAt: NOW - 3_600n }, + "From 17 Nov, 01:00 UTC", + ], + ["a minimum that has run", { openedAt: NOW - 86_400n }, "Now"], + ["an unreadable minimum", { minDuration: null }, null], + ])( + "tells from when anyone may seal with %s", + (_name, overrides, expected) => { + const groups = toCycleRows(ready({ epoch: { ...epoch, ...overrides } })) + + expect(valueOf(groups, "Epoch", "Anyone can seal")).toBe(expected) + }, + ) + it("shows no sealed epoch awaiting a price when none is sealed", () => { const groups = toCycleRows(ready({ epoch: { ...epoch, awaiting: null } })) @@ -258,11 +279,11 @@ describe("toCycleRows", () => { it.each<[string, CycleOracle["state"], string]>([ ["a valid price", "valid", "Valid"], ["a stale price", "stale", "Stale"], - ["a raised ripcord", "paused", "Paused"], + ["a paused NAV", "paused", "Paused"], ])("notes %s under the share price", (_name, state, expected) => { const groups = toCycleRows( ready({ - oracle: { ...oracle, state, ripcord: state === "paused" }, + oracle: { ...oracle, state, navPaused: state === "paused" }, }), ) @@ -303,15 +324,77 @@ describe("toCycleRows", () => { ) }) - it("shows the ripcord, the share price and the oracle limits in app units", () => { + it("shows the latest proof, its hash shortened and its URI as a link", () => { + const rows = groupOf(toCycleRows(ready({})), "Price")?.rows + + expect(rows?.find((row) => row.label === "Proof hash")).toEqual({ + label: "Proof hash", + value: "abab...abab", + }) + expect(rows?.find((row) => row.label === "Proof URI")).toEqual({ + label: "Proof URI", + value: "https://example.com/nav.pdf", + href: "https://example.com/nav.pdf", + }) + }) + + it("shows a proof URI that is not a web address as text, never as a link", () => { + const rows = groupOf( + toCycleRows( + ready({ + oracle: { + ...oracle, + proof: { hash: "ab".repeat(32), uri: "javascript:alert(1)" }, + }, + }), + ), + "Price", + )?.rows + + expect(rows?.find((row) => row.label === "Proof URI")).toEqual({ + label: "Proof URI", + value: "javascript:alert(1)", + }) + }) + + it("marks the proof URI as not applicable when the attestation gave none", () => { + const groups = toCycleRows( + ready({ + oracle: { ...oracle, proof: { hash: "ab".repeat(32), uri: "" } }, + }), + ) + + expect(valueOf(groups, "Price", "Proof URI")).toBe("—") + }) + + it("marks the proof as not applicable when nothing was attested", () => { + const groups = toCycleRows( + ready({ + oracle: { + ...oracle, + state: "never", + recorded: false, + price: null, + attestedAt: null, + expiresAt: null, + proof: null, + }, + }), + ) + + expect(valueOf(groups, "Price", "Proof hash")).toBe("—") + expect(valueOf(groups, "Price", "Proof URI")).toBe("—") + }) + + it("shows the NAV pause, the share price and the oracle limits in app units", () => { const groups = toCycleRows(ready({})) expect(tileOf(groups, "Price", "Share price")?.value).toBe("1.0000") - expect(valueOf(groups, "Price", "Ripcord")).toBe("Not raised") + expect(valueOf(groups, "Price", "NAV paused")).toBe("Not paused") expect(valueOf(groups, "Price", "Cooldown")).toBe("1h") expect(valueOf(groups, "Price", "Freshness window")).toBe("1d") expect(valueOf(groups, "Price", "Largest rise")).toBe("5%") - expect(valueOf(groups, "Price", "Largest fall")).toBe("No limit") + expect(valueOf(groups, "Price", "Largest fall")).toBe("10%") expect(valueOf(groups, "Price", "Price floor")).toBe("0.5000") }) @@ -619,6 +702,31 @@ describe("toActivityList", () => { ]) }) + it("describes a legal transfer with its accounts and reference", () => { + const rows = activityOf([ + event( + "legal_transfer", + ["GFROMACCOUNT1234567890", "GTOACCOUNT1234567890"], + { amount: amount(5n), reference: "COURT-ORDER-17" }, + 10, + ), + ]) + + expect(rows.map((row) => [row.title, row.detail])).toEqual([ + [ + "Legal transfer", + [ + { figure: "5.00" }, + " shares · ", + { figure: "GFRO...7890" }, + " → ", + { figure: "GTOA...7890" }, + " · COURT-ORDER-17", + ], + ], + ]) + }) + it("passes on events that are still being read or could not be", () => { expect(toActivityList({ status: "checking" })).toEqual({ status: "checking", @@ -936,18 +1044,27 @@ describe("toCycleActions", () => { const attester = grant("oracle attester") +const PROOF_HASH = "AB".repeat(32) + const attestOf = ( input: string, overrides: Partial = {}, grants: Grant[] = [attester], + proof: { proofHash?: string; proofUri?: string } = { proofHash: PROOF_HASH }, ) => actionOf( - toCycleActions(ready(overrides), grants, WALLET, { price: input }), + toCycleActions(ready(overrides), grants, WALLET, { + price: input, + ...proof, + }), "attest", ) -const withLimits = (limits: Partial>) => - ({ ...oracle, limits: { ...oracle.limits!, ...limits } }) as CycleOracle +const proofConditions = { + "Proof hash of 64 hex characters": true, + "Proof hash not all zeros": true, + "Proof URI within 256 bytes": true, +} describe("toCycleActions, attesting", () => { it("offers the attestation only to the oracle attester, in the Price group", () => { @@ -962,7 +1079,7 @@ describe("toCycleActions, attesting", () => { const attest = attestOf("1.04") expect(attest?.label).toBe("Attest price") - expect(attest?.field).toEqual({ + expect(attest?.fields?.[0]).toEqual({ label: "Share price", placeholder: "0.0000", value: "1.04", @@ -977,11 +1094,12 @@ describe("toCycleActions, attesting", () => { "Within the band": null, "Cooldown elapsed": true, "Within the allowed move": null, + ...proofConditions, }) expect(attest?.outcome).toBeNull() expect(attest?.price).toBeNull() expect(attest?.enabled).toBe(false) - expect(attest?.field?.invalid).toBe(false) + expect(attest?.fields?.[0]?.invalid).toBe(false) }) it.each(["abc", "1.2.3", "1.0000000000000000001"])( @@ -989,7 +1107,7 @@ describe("toCycleActions, attesting", () => { (input) => { const attest = attestOf(input) - expect(attest?.field?.invalid).toBe(true) + expect(attest?.fields?.[0]?.invalid).toBe(true) expect(conditionsOf(attest)["Within the band"]).toBeNull() expect(attest?.enabled).toBe(false) }, @@ -1002,6 +1120,7 @@ describe("toCycleActions, attesting", () => { "Within the band": true, "Cooldown elapsed": true, "Within the allowed move": true, + ...proofConditions, }) expect(attest?.price).toBe(1_040_000_000_000_000_000n) expect(attest?.freshness).toBe(86_400n) @@ -1070,47 +1189,27 @@ describe("toCycleActions, attesting", () => { ).toBe(false) }) - it("lets any fall through when the oracle sets no largest fall", () => { - expect(conditionsOf(attestOf("0.5"))["Within the allowed move"]).toBe(true) - }) - - it("caps a fall at the largest allowed when the oracle sets one", () => { - const limited = { oracle: withLimits({ maxDownBps: 1_000 }) } - - expect( - conditionsOf(attestOf("0.9", limited))["Within the allowed move"], - ).toBe(true) + it("caps a fall at the largest allowed, letting an equal move through", () => { + expect(conditionsOf(attestOf("0.9"))["Within the allowed move"]).toBe(true) expect( - conditionsOf(attestOf("0.899999999999999999", limited))[ - "Within the allowed move" - ], + conditionsOf(attestOf("0.899999999999999999"))["Within the allowed move"], ).toBe(false) }) - it("shows the ceiling when a rise exceeds the cap and no fall cap exists", () => { - const move = attestOf("1.06")?.conditions.find( - (condition) => condition.label === "Within the allowed move", - ) - - expect(move).toEqual({ - label: "Within the allowed move", - met: false, - detail: "≤ 1.0500", - }) - }) - - it("shows the allowed range when both caps exist and the move exceeds one", () => { - const limited = { oracle: withLimits({ maxDownBps: 1_000 }) } - const move = attestOf("0.8", limited)?.conditions.find( - (condition) => condition.label === "Within the allowed move", - ) + it.each(["1.06", "0.8"])( + "shows the allowed range when %s exceeds a cap", + (input) => { + const move = attestOf(input)?.conditions.find( + (condition) => condition.label === "Within the allowed move", + ) - expect(move).toEqual({ - label: "Within the allowed move", - met: false, - detail: "0.9000 – 1.0500", - }) - }) + expect(move).toEqual({ + label: "Within the allowed move", + met: false, + detail: "0.9000 – 1.0500", + }) + }, + ) it("skips the cooldown and the move cap for the first attestation", () => { const attest = attestOf("1.9", { @@ -1127,17 +1226,18 @@ describe("toCycleActions, attesting", () => { "Within the band": true, "Cooldown elapsed": true, "Within the allowed move": true, + ...proofConditions, }) expect(attest?.enabled).toBe(true) }) - it("skips the cooldown and the move cap with the ripcord raised and no stored record", () => { + it("skips the cooldown and the move cap with the NAV paused and no stored record", () => { const attest = attestOf("1.9", { ledgerTime: oracle.attestedAt! - 1n, oracle: { ...oracle, state: "paused", - ripcord: true, + navPaused: true, recorded: false, price: null, attestedAt: null, @@ -1148,6 +1248,7 @@ describe("toCycleActions, attesting", () => { "Within the band": true, "Cooldown elapsed": true, "Within the allowed move": true, + ...proofConditions, }) expect(attest?.enabled).toBe(true) }) @@ -1185,13 +1286,13 @@ describe("toCycleActions, attesting", () => { expect(attest?.enabled).toBe(false) }) - it("notes a raised ripcord without making it a condition", () => { + it("notes a paused NAV without making it a condition", () => { const attest = attestOf("1.04", { - oracle: { ...oracle, state: "paused", ripcord: true }, + oracle: { ...oracle, state: "paused", navPaused: true }, }) - expect(attest?.note).toBe("Ripcord raised — attesting does not lift it.") - expect(Object.keys(conditionsOf(attest))).toHaveLength(3) + expect(attest?.note).toBe("NAV paused — attesting does not unpause it.") + expect(Object.keys(conditionsOf(attest))).toHaveLength(6) expect(attest?.enabled).toBe(true) }) @@ -1202,6 +1303,102 @@ describe("toCycleActions, attesting", () => { expect(attest?.enabled).toBe(true) expect(attest?.grant).toEqual(shared) }) + + it("asks for the proof hash and an optional proof URI in text fields", () => { + const attest = attestOf("1.04", {}, [attester], { + proofHash: PROOF_HASH, + proofUri: "https://example.com/nav.pdf", + }) + + expect(attest?.fields?.slice(1)).toEqual([ + { + key: "proof-hash", + label: "Proof hash", + placeholder: "Proof hash, 64 hex characters", + value: PROOF_HASH, + invalid: false, + text: true, + }, + { + key: "proof-uri", + label: "Proof URI", + placeholder: "Proof URI (optional)", + value: "https://example.com/nav.pdf", + invalid: false, + text: true, + }, + ]) + expect(attest?.proofHash).toBe("ab".repeat(32)) + expect(attest?.proofUri).toBe("https://example.com/nav.pdf") + }) + + it("holds the attestation until a proof hash is typed", () => { + const attest = attestOf("1.04", {}, [attester], {}) + + expect(conditionsOf(attest)["Proof hash of 64 hex characters"]).toBeNull() + expect(conditionsOf(attest)["Proof hash not all zeros"]).toBeNull() + expect(attest?.proofHash).toBeNull() + expect(attest?.enabled).toBe(false) + }) + + it.each(["ab".repeat(31), "ab".repeat(33), "zz".repeat(32)])( + "refuses %j as a proof hash, marking the field", + (proofHash) => { + const attest = attestOf("1.04", {}, [attester], { proofHash }) + + expect(conditionsOf(attest)["Proof hash of 64 hex characters"]).toBe( + false, + ) + expect(attest?.fields?.[1]?.invalid).toBe(true) + expect(attest?.enabled).toBe(false) + }, + ) + + it("refuses an all-zero proof hash", () => { + const attest = attestOf("1.04", {}, [attester], { + proofHash: "0".repeat(64), + }) + + expect(conditionsOf(attest)["Proof hash of 64 hex characters"]).toBe(true) + expect(conditionsOf(attest)["Proof hash not all zeros"]).toBe(false) + expect(attest?.fields?.[1]?.invalid).toBe(true) + expect(attest?.enabled).toBe(false) + }) + + it("accepts a proof URI of exactly 256 bytes and refuses one byte more, counting UTF-8 bytes", () => { + const fits = attestOf("1.04", {}, [attester], { + proofHash: PROOF_HASH, + proofUri: "é".repeat(128), + }) + const over = attestOf("1.04", {}, [attester], { + proofHash: PROOF_HASH, + proofUri: `${"é".repeat(128)}a`, + }) + + expect(conditionsOf(fits)["Proof URI within 256 bytes"]).toBe(true) + expect(fits?.enabled).toBe(true) + expect( + over?.conditions.find( + (condition) => condition.label === "Proof URI within 256 bytes", + ), + ).toEqual({ + label: "Proof URI within 256 bytes", + met: false, + detail: "257 bytes", + }) + expect(over?.fields?.[2]?.invalid).toBe(true) + expect(over?.enabled).toBe(false) + }) + + it("enables an attestation without a proof URI, sending it empty", () => { + const attest = attestOf("1.04", {}, [attester], { + proofHash: PROOF_HASH, + proofUri: " ", + }) + + expect(attest?.proofUri).toBe("") + expect(attest?.enabled).toBe(true) + }) }) const deployOf = ( diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 6c6c1c2a..38961eae 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -39,6 +39,7 @@ import { allMet, type Condition, grantFor, + parseHash, type RailAction, } from "./actions" import { toCustodianRow } from "./vaultMetrics" @@ -62,12 +63,12 @@ const GROUP_LAYOUT: { "Deposits requested", "Redemptions requested", ], - rows: ["Vault", "Notice period", "Sealed at", "Notice"], + rows: ["Vault", "Anyone can seal", "Notice period", "Sealed at", "Notice"], }, { title: "Price", tiles: ["Share price", "Attested", "Valid until"], - rows: ["Ripcord"], + rows: ["NAV paused", "Proof hash", "Proof URI"], limits: [ "Freshness window", "Cooldown", @@ -193,6 +194,14 @@ function epochGroup(state: ReadyState): CycleGroup { ? "Elapsed" : `Elapses ${formatTimestamp(record.priceableAt)}` } + const anyoneCanSeal = (): string | null => { + const { minDuration, openedAt } = epoch + if (minDuration?.kind === "unset") return "Manager only" + if (minDuration === null || openedAt === null || ledgerTime === null) + return null + const from = openedAt + minDuration.secs + return ledgerTime >= from ? "Now" : `From ${formatTimestamp(from)}` + } return { title: "Epoch", @@ -224,6 +233,7 @@ function epochGroup(state: ReadyState): CycleGroup { ? "Paused" : "Accepting requests", ), + row("Anyone can seal", anyoneCanSeal()), row( "Notice period", epoch.noticeSeconds === null @@ -250,17 +260,23 @@ export const oracleLimitRows = (limits: OracleLimits | null): FigureRow[] => [ row("Freshness window", limits && formatDuration(limits.freshness)), row("Cooldown", limits && formatDuration(limits.cooldown)), row("Largest rise", limits && formatPercent(limits.maxUpBps)), - row( - "Largest fall", - limits && - (limits.maxDownBps === null - ? "No limit" - : formatPercent(limits.maxDownBps)), - ), + row("Largest fall", limits && formatPercent(limits.maxDownBps)), row("Price floor", limits && formatPriceValue(limits.min)), row("Price ceiling", limits && formatPriceValue(limits.max)), ] +const WEB_ADDRESS = /^https?:\/\//i + +function proofUriRow(oracle: CycleOracle): FigureRow { + const label = "Proof URI" + if (oracle.state === "unreadable") return row(label, null) + const uri = oracle.proof?.uri ?? "" + if (uri === "") return row(label, NOT_APPLICABLE) + return WEB_ADDRESS.test(uri) + ? { label, value: uri, href: uri } + : row(label, uri) +} + function priceGroup(oracle: CycleOracle): CycleGroup { const { limits } = oracle const timestamp = (value: bigint | null): string | null => @@ -281,13 +297,20 @@ function priceGroup(oracle: CycleOracle): CycleGroup { ], rows: [ row( - "Ripcord", - oracle.ripcord === null + "NAV paused", + oracle.navPaused === null ? null - : oracle.ripcord - ? "Raised" - : "Not raised", + : oracle.navPaused + ? "Paused" + : "Not paused", ), + row( + "Proof hash", + oracle.state === "unreadable" + ? null + : orNotApplicable(oracle.proof, (proof) => shortAddress(proof.hash)), + ), + proofUriRow(oracle), ], limits: oracleLimitRows(limits), } @@ -390,6 +413,8 @@ export type AttestAction = ActionBase & { id: "attest" price: Price | null freshness: bigint | null + proofHash: string | null + proofUri: string } export type TreasuryAction = ActionBase & { @@ -407,6 +432,8 @@ export type CycleAction = export type ActionInputs = { price?: string + proofHash?: string + proofUri?: string deploy?: string fund?: string walletBalance?: Amount | null @@ -528,32 +555,55 @@ const withinAllowedMove = ( if (oracle.recorded === false) return { label, met: true } if (limits === null || previous === null) return { label, met: false } const ceiling = previous + capped(previous, limits.maxUpBps) - const floor = - limits.maxDownBps === null - ? null - : previous - capped(previous, limits.maxDownBps) - const met = price <= ceiling && (floor === null || price >= floor) + const floor = previous - capped(previous, limits.maxDownBps) + const met = price >= floor && price <= ceiling if (met) return { label, met } const top = formatPriceValue(ceiling) return { label, met, - detail: floor === null ? `≤ ${top}` : `${formatPriceValue(floor)} – ${top}`, + detail: `${formatPriceValue(floor)} – ${top}`, } } +const MAX_PROOF_URI_BYTES = 256 +const ALL_ZEROS = /^0+$/ + +const proofUriFits = (bytes: number): Condition => { + const label = `Proof URI within ${MAX_PROOF_URI_BYTES} bytes` + return bytes <= MAX_PROOF_URI_BYTES + ? { label, met: true } + : { label, met: false, detail: `${bytes} bytes` } +} + function toAttestAction( state: ReadyState, grant: Grant, - input: string, + inputs: ActionInputs, ): AttestAction { const { oracle, ledgerTime } = state + const input = inputs.price ?? "" + const hashInput = inputs.proofHash ?? "" + const uriInput = inputs.proofUri ?? "" const price = parseUnits(input, PRICE_DECIMALS) const freshness = oracle.limits?.freshness ?? null + const hash = parseHash(hashInput) + const proofHash = hash === undefined || ALL_ZEROS.test(hash) ? null : hash + const proofUri = uriInput.trim() + const uriBytes = new TextEncoder().encode(proofUri).length const conditions = [ withinBand(oracle, price), cooldownElapsed(oracle, ledgerTime), withinAllowedMove(oracle, price), + { + label: "Proof hash of 64 hex characters", + met: hashInput.trim() === "" ? null : hash !== undefined, + }, + { + label: "Proof hash not all zeros", + met: hash === undefined ? null : proofHash !== null, + }, + proofUriFits(uriBytes), ] return { id: "attest", @@ -564,17 +614,37 @@ function toAttestAction( price === null || freshness === null ? null : `Records ${formatSignedPrice(price)} as the share price, valid for ${formatDuration(freshness)}.`, - ...(oracle.ripcord === true - ? { note: "Ripcord raised — attesting does not lift it." } + ...(oracle.navPaused === true + ? { note: "NAV paused — attesting does not unpause it." } : {}), - field: { - label: "Share price", - placeholder: "0.0000", - value: input, - invalid: input.trim() !== "" && price === null, - }, + fields: [ + { + label: "Share price", + placeholder: "0.0000", + value: input, + invalid: input.trim() !== "" && price === null, + }, + { + key: "proof-hash", + label: "Proof hash", + placeholder: "Proof hash, 64 hex characters", + value: hashInput, + invalid: hashInput.trim() !== "" && proofHash === null, + text: true, + }, + { + key: "proof-uri", + label: "Proof URI", + placeholder: "Proof URI (optional)", + value: uriInput, + invalid: uriBytes > MAX_PROOF_URI_BYTES, + text: true, + }, + ], price, freshness, + proofHash, + proofUri, enabled: price !== null && freshness !== null && allMet(conditions), grant, } @@ -811,7 +881,7 @@ export function toCycleActions( if (manager !== undefined) actions.push(toCloseAction(state, manager)) actions.push(toFulfillAction(state)) if (attester !== undefined) - actions.push(toAttestAction(state, attester, inputs.price ?? "")) + actions.push(toAttestAction(state, attester, inputs)) if (treasury !== undefined) actions.push(toDeployAction(state, treasury, inputs.deploy ?? "")) actions.push( @@ -965,12 +1035,41 @@ function describeDepositClaimed(event: ChainEvent): Described | null { } } +function describeLegalTransfer(event: ChainEvent): Described | null { + const from = topicAt(event, 0) + const to = topicAt(event, 1) + const amount = field(event.data, "amount") + const reference = + typeof event.data === "object" && event.data !== null + ? (event.data as Record).reference + : undefined + if ( + typeof from !== "string" || + typeof to !== "string" || + amount === null || + typeof reference !== "string" + ) + return null + return { + title: "Legal transfer", + detail: [ + figure(amountText(amount)), + " shares · ", + figure(shortAddress(from)), + " → ", + figure(shortAddress(to)), + ` · ${reference}`, + ], + } +} + const DESCRIBERS: Record Described | null> = { epoch_closed: describeEpochClosed, epoch_fulfilled: describeEpochFulfilled, nav_attested: describeNavAttested, deposit_requested: describeDepositRequested, deposit_claimed: describeDepositClaimed, + legal_transfer: describeLegalTransfer, } const PARTIAL_ACTIVITY_NOTE = "Only part of the last 7 days is shown." diff --git a/app/src/pages/emergency.test.ts b/app/src/pages/emergency.test.ts index 96708e99..27b70ff8 100644 --- a/app/src/pages/emergency.test.ts +++ b/app/src/pages/emergency.test.ts @@ -28,9 +28,15 @@ const everyRole = [ grantOf("share-token admin"), ] -const calm: EmergencyState = { vault: "open", token: "open", oracle: "valid" } +const calm: EmergencyState = { + vault: "open", + setup: "closed", + token: "open", + oracle: "valid", +} const alarmed: EmergencyState = { vault: "paused", + setup: "closed", token: "paused", oracle: "paused", } @@ -50,6 +56,7 @@ describe("toEmergencyActions", () => { it("holds no action back on a state still being read, showing the condition as unknown", () => { const reading: EmergencyState = { vault: "checking", + setup: "checking", token: "checking", oracle: "checking", } @@ -67,6 +74,7 @@ describe("toEmergencyActions", () => { it("decides each action from its own state only", () => { const state: EmergencyState = { vault: "checking", + setup: "checking", token: "paused", oracle: "checking", } @@ -84,8 +92,8 @@ describe("toEmergencyActions", () => { expect(ids(calm, everyRole)).toEqual([ "pause-vault", "unpause-vault", - "raise-ripcord", - "lower-ripcord", + "pause-nav", + "unpause-nav", "clear-latest", "pause-token", "unpause-token", @@ -95,32 +103,32 @@ describe("toEmergencyActions", () => { it.each<[string, string[]]>([ ["vault guardian", ["pause-vault"]], ["vault governance", ["unpause-vault"]], - ["oracle guardian", ["raise-ripcord"]], - ["oracle admin", ["raise-ripcord", "lower-ripcord", "clear-latest"]], + ["oracle guardian", ["pause-nav"]], + ["oracle admin", ["pause-nav", "unpause-nav", "clear-latest"]], ["share-token admin", ["pause-token", "unpause-token"]], ])("offers %s only what its guard permits", (role, expected) => { expect(ids(calm, [grantOf(role)])).toEqual(expected) }) - it("raises through raise_ripcord for the oracle guardian and set_ripcord for the oracle admin", () => { + it("pauses through pause_nav for the oracle guardian and set_nav_paused for the oracle admin", () => { expect( - actionOf(calm, [grantOf("oracle guardian")], "raise-ripcord"), + actionOf(calm, [grantOf("oracle guardian")], "pause-nav"), ).toMatchObject({ viaAdmin: false }) expect( - actionOf(calm, [grantOf("oracle admin")], "raise-ripcord"), + actionOf(calm, [grantOf("oracle admin")], "pause-nav"), ).toMatchObject({ viaAdmin: true }) - expect(actionOf(calm, everyRole, "raise-ripcord")).toMatchObject({ + expect(actionOf(calm, everyRole, "pause-nav")).toMatchObject({ viaAdmin: false, }) }) - it("raises through the oracle admin when the guardian grant needs more signatures", () => { + it("pauses through the oracle admin when the guardian grant needs more signatures", () => { const grants = [ grantOf("oracle guardian", { standing: { needs: 2 } }), grantOf("oracle admin"), ] - expect(actionOf(calm, grants, "raise-ripcord")).toMatchObject({ + expect(actionOf(calm, grants, "pause-nav")).toMatchObject({ viaAdmin: true, enabled: true, }) @@ -134,13 +142,13 @@ describe("toEmergencyActions", () => { expect(enabled).toEqual([ "pause-vault", - "raise-ripcord", - "lower-ripcord", + "pause-nav", + "unpause-nav", "pause-token", ]) }) - it("enables unpausing, lowering and clearing once everything is paused", () => { + it("enables unpausing the vault, unpausing the NAV and clearing once everything is paused", () => { const actions = toEmergencyActions(alarmed, everyRole, wallet) const enabled = actions .filter((action) => action.enabled) @@ -148,8 +156,8 @@ describe("toEmergencyActions", () => { expect(enabled).toEqual([ "unpause-vault", - "raise-ripcord", - "lower-ripcord", + "pause-nav", + "unpause-nav", "clear-latest", "unpause-token", ]) @@ -163,9 +171,9 @@ describe("toEmergencyActions", () => { { label: "Vault is paused", met: false }, ]) expect(actionOf(calm, everyRole, "clear-latest").conditions).toEqual([ - { label: "Ripcord raised", met: false }, + { label: "NAV paused", met: false }, ]) - expect(actionOf(calm, everyRole, "lower-ripcord").conditions).toEqual([]) + expect(actionOf(calm, everyRole, "unpause-nav").conditions).toEqual([]) expect(actionOf(alarmed, everyRole, "unpause-token").conditions).toEqual([ { label: "Share token is paused", met: true }, ]) @@ -174,6 +182,7 @@ describe("toEmergencyActions", () => { it("leaves a precondition undecided when its state is unreadable", () => { const state: EmergencyState = { vault: "unreadable", + setup: "unreadable", token: "unreadable", oracle: "unreadable", } @@ -185,13 +194,13 @@ describe("toEmergencyActions", () => { expect(actions.every((action) => action.enabled)).toBe(true) }) - it("keeps raising the ripcord available once raised, noting it already is", () => { - const raise = actionOf(alarmed, everyRole, "raise-ripcord") + it("keeps pausing the NAV available once paused, noting it already is", () => { + const pause = actionOf(alarmed, everyRole, "pause-nav") - expect(raise.enabled).toBe(true) - expect(raise.conditions).toEqual([]) - expect(raise.note).toBe("Ripcord already raised.") - expect(actionOf(calm, everyRole, "raise-ripcord").note).toBeUndefined() + expect(pause.enabled).toBe(true) + expect(pause.conditions).toEqual([]) + expect(pause.note).toBe("NAV already paused.") + expect(actionOf(calm, everyRole, "pause-nav").note).toBeUndefined() }) it("says what each action blocks and what stays open", () => { @@ -199,13 +208,13 @@ describe("toEmergencyActions", () => { "Stops new deposits, pricing and upgrades. Redemptions, claims, cancels and closing epochs stay open.", ) expect(actionOf(calm, everyRole, "pause-token").outcome).toBe( - "Stops holder transfers and returns of escrowed shares. Minting, burning and forced transfers stay open.", + "Stops holder transfers only. Minting, burning, the vault's escrow moves and legal transfers continue.", ) expect(actionOf(alarmed, everyRole, "unpause-token").outcome).toBe( - "Resumes holder transfers and returns of escrowed shares.", + "Resumes holder transfers.", ) - expect(actionOf(calm, everyRole, "raise-ripcord").outcome).toBe( - "Stops pricing: no epoch can be fulfilled until it is lowered. Attesting stays open.", + expect(actionOf(calm, everyRole, "pause-nav").outcome).toBe( + "Stops pricing: no epoch can be fulfilled until it is unpaused. Attesting stays open. Revoking an attester is instant; granting a replacement waits for the timelock once the oracle's setup is closed.", ) }) @@ -220,8 +229,8 @@ describe("toEmergencyActions", () => { expect(tones).toEqual({ "pause-vault": "danger", "unpause-vault": undefined, - "raise-ripcord": "danger", - "lower-ripcord": undefined, + "pause-nav": "danger", + "unpause-nav": undefined, "clear-latest": "danger", "pause-token": "danger", "unpause-token": undefined, @@ -241,39 +250,73 @@ describe("toEmergencyTiles", () => { it("shows pending tiles while reading", () => { const tiles = toEmergencyTiles({ vault: "checking", + setup: "checking", token: "checking", oracle: "checking", }) expect(tiles.map(({ label, pending }) => [label, pending])).toEqual([ ["Vault", true], + ["Setup", true], ["Share token", true], ["Oracle", true], ]) }) - it("states each contract's current pause and the ripcord", () => { + it("states each contract's current pause and the NAV pause", () => { expect(toEmergencyTiles(calm)).toEqual([ { label: "Vault", value: "Open" }, + { + label: "Setup", + value: "Closed", + note: "Changes wait for the timelock", + }, { label: "Share token", value: "Open" }, - { label: "Oracle", value: "Valid", note: "Ripcord not raised" }, + { label: "Oracle", value: "Valid", note: "NAV not paused" }, ]) expect(toEmergencyTiles(alarmed)).toEqual([ { label: "Vault", value: "Paused" }, + { + label: "Setup", + value: "Closed", + note: "Changes wait for the timelock", + }, { label: "Share token", value: "Paused" }, - { label: "Oracle", value: "Paused", note: "Ripcord raised" }, + { label: "Oracle", value: "Paused", note: "NAV paused" }, ]) }) - it("shows a stale oracle as stale with the ripcord down, and an unreadable state as unknown", () => { + it("says setup is open beside the vault, and what that means", () => { + expect(toEmergencyTiles({ ...calm, setup: "open" })[1]).toEqual({ + label: "Setup", + value: "Open", + note: "Changes apply at once; no deposits yet", + }) + expect(toEmergencyTiles({ ...calm, setup: "unreadable" })[1]).toEqual({ + label: "Setup", + value: null, + }) + }) + + it("shows a stale oracle as stale with the NAV not paused, and an unreadable state as unknown", () => { expect( - toEmergencyTiles({ vault: "unreadable", token: "open", oracle: "stale" }), + toEmergencyTiles({ + vault: "unreadable", + setup: "closed", + token: "open", + oracle: "stale", + }), ).toEqual([ { label: "Vault", value: null }, + { + label: "Setup", + value: "Closed", + note: "Changes wait for the timelock", + }, { label: "Share token", value: "Open" }, - { label: "Oracle", value: "Stale", note: "Ripcord not raised" }, + { label: "Oracle", value: "Stale", note: "NAV not paused" }, ]) - expect(toEmergencyTiles({ ...calm, oracle: "unreadable" })[2]).toEqual({ + expect(toEmergencyTiles({ ...calm, oracle: "unreadable" })[3]).toEqual({ label: "Oracle", value: null, }) diff --git a/app/src/pages/emergency.ts b/app/src/pages/emergency.ts index fa225e43..69950fe2 100644 --- a/app/src/pages/emergency.ts +++ b/app/src/pages/emergency.ts @@ -1,6 +1,7 @@ import { type Metric } from "../components/vault/MetricsStrip" import { type Grant } from "../hooks/useAdminAuthority" import { type OracleStatus } from "../hooks/useOracleState" +import { type SetupState } from "../hooks/useSetupOpen" import { type PauseState } from "../hooks/useVaultPaused" import { type Condition, @@ -13,6 +14,7 @@ import { export type EmergencyState = { vault: PauseState + setup: SetupState token: PauseState oracle: OracleStatus } @@ -20,13 +22,13 @@ export type EmergencyState = { type PlainId = | "pause-vault" | "unpause-vault" - | "lower-ripcord" + | "unpause-nav" | "clear-latest" | "pause-token" | "unpause-token" export type EmergencyAction = RailAction & - ({ id: PlainId } | { id: "raise-ripcord"; viaAdmin: boolean }) + ({ id: PlainId } | { id: "pause-nav"; viaAdmin: boolean }) const PAUSE_LABELS = { open: "Open", paused: "Paused" } as const @@ -48,8 +50,8 @@ const pauseIs = ( met: isKnown(state) ? state === wanted : null, }) -const ripcordRaised = (oracle: OracleStatus): Condition => ({ - label: "Ripcord raised", +const navPaused = (oracle: OracleStatus): Condition => ({ + label: "NAV paused", met: isKnown(oracle) ? oracle === "paused" : null, }) @@ -75,8 +77,8 @@ const oracleAdminSpecs = ( oracle: OracleStatus, ): [Spec, Spec] => [ { - id: "lower-ripcord", - label: "Lower the ripcord", + id: "unpause-nav", + label: "Unpause the NAV", conditions: [], outcome: "Resumes pricing: epochs can be fulfilled again while the price is valid.", @@ -84,9 +86,9 @@ const oracleAdminSpecs = ( { id: "clear-latest", label: "Clear the latest price", - conditions: [ripcordRaised(oracle)], + conditions: [navPaused(oracle)], outcome: - "Removes the recorded price, so the next attestation is not limited by its distance from the last. Pricing stays stopped until the ripcord is lowered.", + "Removes the recorded price, so the next attestation is not limited by its distance from the last. Pricing stays stopped until the NAV is unpaused.", danger: true, }, ] @@ -97,18 +99,18 @@ const tokenSpecs = (token: PauseState): [Spec, Spec] => [ label: "Pause the share token", conditions: [pauseIs("Share token is open", token, "open")], outcome: - "Stops holder transfers and returns of escrowed shares. Minting, burning and forced transfers stay open.", + "Stops holder transfers only. Minting, burning, the vault's escrow moves and legal transfers continue.", danger: true, }, { id: "unpause-token", label: "Unpause the share token", conditions: [pauseIs("Share token is paused", token, "paused")], - outcome: "Resumes holder transfers and returns of escrowed shares.", + outcome: "Resumes holder transfers.", }, ] -const raiseGrant = ( +const pauseGrant = ( guardian: Grant | undefined, admin: Grant | undefined, ): Grant | undefined => { @@ -117,13 +119,13 @@ const raiseGrant = ( return guardian ?? admin } -const raiseSpec = (oracle: OracleStatus): Spec<"raise-ripcord"> => ({ - id: "raise-ripcord", - label: "Raise the ripcord", +const pauseSpec = (oracle: OracleStatus): Spec<"pause-nav"> => ({ + id: "pause-nav", + label: "Pause the NAV", conditions: [], outcome: - "Stops pricing: no epoch can be fulfilled until it is lowered. Attesting stays open.", - ...(oracle === "paused" ? { note: "Ripcord already raised." } : {}), + "Stops pricing: no epoch can be fulfilled until it is unpaused. Attesting stays open. Revoking an attester is instant; granting a replacement waits for the timelock once the oracle's setup is closed.", + ...(oracle === "paused" ? { note: "NAV already paused." } : {}), danger: true, }) @@ -135,24 +137,24 @@ export function toEmergencyActions( if (grants.length === 0) return [] const held = (role: string) => grantFor(grants, role, wallet) const [pauseVault, unpauseVault] = vaultSpecs(state.vault) - const [lower, clear] = oracleAdminSpecs(state.oracle) + const [unpause, clear] = oracleAdminSpecs(state.oracle) const [pauseToken, unpauseToken] = tokenSpecs(state.token) const oracleAdmin = held(ROLES.oracleAdmin) const tokenAdmin = held(ROLES.tokenAdmin) - const raiser = raiseGrant(held(ROLES.oracleGuardian), oracleAdmin) + const pauser = pauseGrant(held(ROLES.oracleGuardian), oracleAdmin) const actions: EmergencyAction[] = [] const guardian = held(ROLES.vaultGuardian) if (guardian !== undefined) actions.push(toAction(pauseVault, guardian)) const governance = held(ROLES.governance) if (governance !== undefined) actions.push(toAction(unpauseVault, governance)) - if (raiser !== undefined) + if (pauser !== undefined) actions.push({ - ...toAction(raiseSpec(state.oracle), raiser), - viaAdmin: raiser.role === ROLES.oracleAdmin, + ...toAction(pauseSpec(state.oracle), pauser), + viaAdmin: pauser.role === ROLES.oracleAdmin, }) if (oracleAdmin !== undefined) - actions.push(toAction(lower, oracleAdmin), toAction(clear, oracleAdmin)) + actions.push(toAction(unpause, oracleAdmin), toAction(clear, oracleAdmin)) if (tokenAdmin !== undefined) actions.push( toAction(pauseToken, tokenAdmin), @@ -173,12 +175,33 @@ const oracleTile = (oracle: OracleStatus): Metric => { return { label, value: ORACLE_LABELS[oracle], - note: oracle === "paused" ? "Ripcord raised" : "Ripcord not raised", + note: oracle === "paused" ? "NAV paused" : "NAV not paused", } } +const SETUP_TILES: Record<"open" | "closed", Metric> = { + open: { + label: "Setup", + value: "Open", + note: "Changes apply at once; no deposits yet", + }, + closed: { + label: "Setup", + value: "Closed", + note: "Changes wait for the timelock", + }, +} + +const setupTile = (setup: SetupState): Metric => { + if (setup === "checking") + return { label: "Setup", value: null, pending: true } + if (setup === "unreadable") return { label: "Setup", value: null } + return SETUP_TILES[setup] +} + export const toEmergencyTiles = (state: EmergencyState): Metric[] => [ pauseTile("Vault", state.vault), + setupTile(state.setup), pauseTile("Share token", state.token), oracleTile(state.oracle), ] diff --git a/app/src/pages/governance.test.ts b/app/src/pages/governance.test.ts index 7bd3cfae..38494ff8 100644 --- a/app/src/pages/governance.test.ts +++ b/app/src/pages/governance.test.ts @@ -1,3 +1,4 @@ +import { Buffer } from "buffer" import { type PendingTransfer } from "@stellar-scaffold/app-lib" import { describe, expect, it } from "vitest" import { type Metric } from "../components/vault/MetricsStrip" @@ -12,6 +13,7 @@ import { toGovernanceActions, toGovernanceGroups, } from "./governance" +import { type PendingChange } from "./pendingChanges" const WALLET = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" const ACCOUNT = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" @@ -53,19 +55,22 @@ const DAY = 86_400n type ReadyUpgrades = Extract +const upgradesWindDown: ReadyUpgrades["windDown"] = { + phase: "none", + activeAt: null, + round: null, + delay: 3n * DAY, + owed: null, + supply: null, +} + const upgrades = (overrides: Partial = {}): Upgrades => ({ status: "ready", - proposal: null, + pending: [], + setupOpen: true, delay: 7n * DAY, notice: DAY, - windDown: { - phase: "none", - activeAt: null, - round: null, - delay: 3n * DAY, - owed: null, - supply: null, - }, + windDown: upgradesWindDown, now: CLOSE_TIME, paused: "open", ...overrides, @@ -87,7 +92,7 @@ const state = ( token: pending.token ?? null, }, }, - upgrades: upgrades(), + upgrades: { vault: upgrades(), token: upgrades(), oracle: upgrades() }, }) const find = ( @@ -129,7 +134,12 @@ describe("toGovernanceActions", () => { ], [ oracleAdmin, - ["grant-oracle-role", "revoke-oracle-role", "hand-over-oracle-admin"], + [ + "grant-oracle-role", + "revoke-oracle-role", + "hand-over-oracle-admin", + "propose-oracle-upgrade", + ], ], [ tokenAdmin, @@ -461,6 +471,7 @@ describe("admin handover", () => { "Expiry has a unit, e.g. 7d", "Expiry ≥ 1m", "Expiry within the network limit", + "Applies at once", "Confirmed", ]) expect(condition(draft, "Expiry within the network limit")).toBe(true) @@ -508,43 +519,239 @@ describe("admin handover", () => { ), ).toEqual([]) }) + + it.each([ + ["vault", "vault pending admin"], + ["oracle", "oracle pending admin"], + ["token", "share-token pending admin"], + ] as const)( + "blocks accepting the %s admin while the vault winds down", + (contract, role) => { + const id = `accept-${contract}-admin` as const + const open = state({}, { [contract]: transfer() }) + const windingDown = (phase: "proposed" | "active"): GovernanceState => { + const shared = upgrades({ windDown: { ...upgradesWindDown, phase } }) + return { + ...open, + upgrades: { vault: shared, token: shared, oracle: shared }, + } + } + const accept = (current: GovernanceState) => + find(id, [grantOf(role)], {}, current) + + expect(condition(accept(open), "Wind-down not active")).toBe(true) + expect(accept(open).enabled).toBe(true) + expect(accept(windingDown("proposed")).enabled).toBe(true) + expect( + condition(accept(windingDown("active")), "Wind-down not active"), + ).toBe(false) + expect(accept(windingDown("active")).enabled).toBe(false) + }, + ) }) -describe("upgrades and wind-down", () => { +describe("vault timelock", () => { const HASH = "ab".repeat(32) const withUpgrades = (overrides: Partial) => ({ ...state(), - upgrades: upgrades(overrides), + upgrades: { + vault: upgrades(overrides), + token: upgrades(), + oracle: upgrades(), + }, }) - const codeProposal = { - kind: "code" as const, - hash: HASH, + const afterSetup = (overrides: Partial = {}) => + withUpgrades({ setupOpen: false, ...overrides }) + const custodianChange = { + id: 1, + change: { tag: "Custodian", values: [ACCOUNT] }, eta: CLOSE_TIME + DAY, } + const codeChange = { + id: 2, + change: { tag: "Upgrade", values: [Buffer.from(HASH, "hex")] }, + eta: CLOSE_TIME + DAY, + } + const delayChange = (secs: bigint) => ({ + id: 4, + change: { tag: "UpgradeDelay", values: [secs] }, + eta: CLOSE_TIME, + }) + const waits = { label: "Waits for the timelock", met: true, detail: "7d" } + const ids = (current: GovernanceState, grant = governance) => + toGovernanceActions(current, [grant], WALLET, {}).map(({ id }) => id) - it("proposes code by its 64-character hash while nothing is pending", () => { - const short = find("propose-code-upgrade", [governance], { - "propose-code-upgrade-hash": "abc", + it("grants a vault role through the timelock after setup and at once while setup is open", () => { + const inputs = { + "grant-vault-role-role": "treasury", + "grant-vault-role-account": ACCOUNT, + } + const proposed = find( + "propose-grant-role", + [governance], + inputs, + afterSetup(), + ) + const direct = find("grant-vault-role", [governance], inputs) + + expect(proposed.label).toBe("Grant a vault role") + expect(proposed.slot).toBe("grant-vault-role") + expect(proposed.arg).toEqual({ role: "treasury", account: ACCOUNT }) + expect(proposed.conditions).toContainEqual(waits) + expect(proposed.enabled).toBe(true) + expect(direct.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Setup is open", }) - const valid = find("propose-code-upgrade", [governance], { - "propose-code-upgrade-hash": ` ${HASH.toUpperCase()} `, + }) + + it("revokes manager through the timelock after setup and other vault roles at once", () => { + const revoke = (role: string, holder: string) => + toGovernanceActions( + { ...afterSetup(), holders: holders({ [`vault ${role}`]: [holder] }) }, + [governance], + WALLET, + { + "revoke-vault-role-role": role, + "revoke-vault-role-account": holder, + }, + ).find(({ slot }) => slot === "revoke-vault-role") + const manager = revoke("manager", MANAGER) + const treasury = revoke("treasury", ACCOUNT) + + expect(manager?.id).toBe("propose-revoke-role") + expect(manager?.arg).toEqual({ role: "manager", account: MANAGER }) + expect(manager?.conditions).toContainEqual(waits) + expect(treasury?.id).toBe("revoke-vault-role") + expect(treasury?.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Revoking this role is instant", }) - const blocked = find( + expect(treasury?.enabled).toBe(true) + }) + + it("keeps the oracle and share-token role changes and hand-overs direct after setup", () => { + const closed = afterSetup() + + expect(ids(closed, oracleAdmin)).toEqual([ + "grant-oracle-role", + "revoke-oracle-role", + "hand-over-oracle-admin", + "propose-oracle-upgrade", + ]) + expect(ids(closed, tokenAdmin)).toEqual([ + "grant-token-role", + "revoke-token-role", + "hand-over-token-admin", + ]) + }) + + it("hands the vault admin over through the timelock after setup, once confirmed", () => { + const inputs = { "hand-over-vault-admin-account": ACCOUNT } + const draft = find("propose-admin", [governance], inputs, afterSetup()) + const confirm = fieldOf(draft, "confirm") + const confirmed = find( + "propose-admin", + [governance], + { ...inputs, [confirm?.key ?? ""]: confirm?.value }, + afterSetup(), + ) + + expect(draft.slot).toBe("hand-over-vault-admin") + expect(draft.label).toBe("Hand over vault admin") + expect(draft.fields?.map(({ label }) => label)).toEqual([ + "New admin", + "Hands control of the vault to GABA...EJXA.", + ]) + expect(draft.enabled).toBe(false) + expect(confirmed.conditions).toContainEqual(waits) + expect(confirmed.enabled).toBe(true) + expect(confirmed.arg).toBe(ACCOUNT) + expect(confirmed.outcome).toBe( + "GABA...EJXA can accept the admin once the change applies.", + ) + }) + + it("cancels a pending vault hand-over at once after setup", () => { + const current = { + ...state( + {}, + { vault: { address: ACCOUNT, liveUntilLedger: 5_000, expired: false } }, + ), + upgrades: { + vault: upgrades({ setupOpen: false }), + token: upgrades(), + oracle: upgrades(), + }, + } + + expect(ids(current)).toContain("cancel-vault-handover") + }) + + it("holds a timelocked vault action while the setup state is unknown", () => { + const grant = find( + "grant-vault-role", + [governance], + { "grant-vault-role-account": ACCOUNT }, + withUpgrades({ setupOpen: null }), + ) + + expect(condition(grant, "Setup state read")).toBeNull() + expect(grant.enabled).toBe(false) + }) + + it("proposes code by its 64-character hash after setup, one code change at a time", () => { + const input = { "propose-code-upgrade-hash": ` ${HASH.toUpperCase()} ` } + const short = find( "propose-code-upgrade", [governance], - { "propose-code-upgrade-hash": HASH }, - withUpgrades({ proposal: codeProposal }), + { "propose-code-upgrade-hash": "abc" }, + afterSetup(), ) + const valid = find( + "propose-code-upgrade", + [governance], + input, + afterSetup(), + ) + const second = find( + "propose-code-upgrade", + [governance], + input, + afterSetup({ pending: [delayChange(14n * DAY)] }), + ) + const duringSetup = find("propose-code-upgrade", [governance], input) expect(condition(short, "Hash of 64 hex characters")).toBe(false) expect(valid.enabled).toBe(true) expect(valid.arg).toBe(HASH) expect(valid.next).toBe(`Code ${HASH}`) + expect(valid.conditions).toContainEqual(waits) expect(valid.outcome).toBe( `Can apply after ${formatTimestamp(CLOSE_TIME + 7n * DAY)}.`, ) - expect(condition(blocked, "No pending upgrade")).toBe(false) - expect(blocked.enabled).toBe(false) + expect(condition(second, "No pending code change")).toBe(false) + expect(second.enabled).toBe(false) + expect(condition(duringSetup, "Setup closed")).toBe(false) + expect(duringSetup.enabled).toBe(false) + }) + + it("refuses a proposal once 16 changes are pending", () => { + const full = Array.from({ length: 16 }, (_, id) => ({ + ...custodianChange, + id, + })) + const grant = find( + "propose-grant-role", + [governance], + { "grant-vault-role-account": ACCOUNT }, + afterSetup({ pending: full }), + ) + + expect(condition(grant, "Under 16 pending changes")).toBe(false) + expect(grant.enabled).toBe(false) }) it.each<[string, string, boolean]>([ @@ -552,17 +759,23 @@ describe("upgrades and wind-down", () => { ["91d", "Delay ≤ 90d", false], ["14d", "Delay ≥ notice 1d", true], ])("bounds a proposed delay of %s by %s", (text, label, met) => { - const delay = find("propose-upgrade-delay", [governance], { - "propose-upgrade-delay-secs": text, - }) + const delay = find( + "propose-upgrade-delay", + [governance], + { "propose-upgrade-delay-secs": text }, + afterSetup(), + ) expect(condition(delay, label)).toBe(met) }) it("lets the current upgrade delay be proposed again", () => { - const delay = find("propose-upgrade-delay", [governance], { - "propose-upgrade-delay-secs": "7d", - }) + const delay = find( + "propose-upgrade-delay", + [governance], + { "propose-upgrade-delay-secs": "7d" }, + afterSetup(), + ) expect(delay.enabled).toBe(true) expect(delay.conditions.map(({ label }) => label)).not.toContain( @@ -571,14 +784,17 @@ describe("upgrades and wind-down", () => { }) it("proposes a new upgrade delay at or above the notice", () => { - const delay = find("propose-upgrade-delay", [governance], { - "propose-upgrade-delay-secs": "14d", - }) + const delay = find( + "propose-upgrade-delay", + [governance], + { "propose-upgrade-delay-secs": "14d" }, + afterSetup(), + ) const belowNotice = find( "propose-upgrade-delay", [governance], { "propose-upgrade-delay-secs": "8d" }, - withUpgrades({ notice: 10n * DAY }), + afterSetup({ notice: 10n * DAY }), ) expect(delay.enabled).toBe(true) @@ -587,77 +803,74 @@ describe("upgrades and wind-down", () => { expect(condition(belowNotice, "Delay ≥ notice 10d")).toBe(false) }) - it("applies a code upgrade after its ETA, unpaused and confirmed", () => { - const early = find( - "apply-upgrade", + it("offers Apply and Cancel for every pending change, by id", () => { + const actions = toGovernanceActions( + afterSetup({ pending: [custodianChange, codeChange] }), [governance], + WALLET, {}, - withUpgrades({ - proposal: codeProposal, - }), - ) - const due = withUpgrades({ proposal: codeProposal, now: CLOSE_TIME + DAY }) - const unconfirmed = find("apply-upgrade", [governance], {}, due) - const confirm = fieldOf(unconfirmed, "confirm") - const confirmed = find( - "apply-upgrade", - [governance], - { [confirm?.key ?? ""]: confirm?.value }, - due, - ) - const paused = find( - "apply-upgrade", - [governance], - { [confirm?.key ?? ""]: confirm?.value }, - withUpgrades({ - proposal: codeProposal, - now: CLOSE_TIME + DAY, - paused: "paused", - }), ) - expect(condition(early, "ETA reached")).toBe(false) - expect(confirm?.label).toBe(`Replaces the vault's code with ${HASH}.`) - expect(unconfirmed.tone).toBe("danger") - expect(unconfirmed.enabled).toBe(false) - expect(confirmed.enabled).toBe(true) - expect(confirmed.next).toBe(`Code ${HASH}`) - expect(condition(paused, "Vault not paused")).toBe(false) + expect( + actions + .filter(({ id }) => id === "apply-change" || id === "cancel-change") + .map(({ slot, arg }) => [slot, arg]), + ).toEqual([ + ["apply-change-1", 1], + ["cancel-change-1", 1], + ["apply-change-2", 2], + ["cancel-change-2", 2], + ]) + expect(ids(afterSetup())).not.toContain("apply-change") }) - it("re-checks a delay proposal against the notice when applying", () => { - const proposal = { kind: "delay" as const, secs: 8n * DAY, eta: CLOSE_TIME } - const apply = find( - "apply-upgrade", - [governance], - {}, - withUpgrades({ proposal, notice: 10n * DAY }), - ) - const fine = find( - "apply-upgrade", - [governance], - {}, - withUpgrades({ proposal }), - ) + it("applies a change after its ETA, unpaused and not winding down", () => { + const apply = (overrides: Partial) => + toGovernanceActions( + afterSetup({ pending: [custodianChange], ...overrides }), + [governance], + WALLET, + {}, + ).find(({ id }) => id === "apply-change")! + const due = { now: CLOSE_TIME + DAY } - expect(condition(apply, "Delay ≥ notice 10d")).toBe(false) - expect(fine.fields).toEqual([]) - expect(fine.enabled).toBe(true) - expect(fine.outcome).toBe( - `Upgrade delay becomes ${formatDuration(8n * DAY)}.`, - ) + expect(condition(apply({}), "ETA reached")).toBe(false) + expect(apply(due).enabled).toBe(true) + expect(apply(due).outcome).toBe("Custodian becomes GABA...EJXA.") + expect( + condition(apply({ ...due, paused: "paused" }), "Vault not paused"), + ).toBe(false) + expect( + condition( + apply({ + ...due, + windDown: { + phase: "active", + activeAt: CLOSE_TIME, + round: 0, + delay: 3n * DAY, + owed: null, + supply: null, + }, + }), + "Wind-down not active", + ), + ).toBe(false) }) - it("offers apply and cancel only while an upgrade is pending", () => { - const ids = (current: GovernanceState) => - toGovernanceActions(current, [governance], WALLET, {}).map( - (action) => action.id, - ) + it("re-checks an upgrade delay against the notice when applying", () => { + const apply = (notice: bigint) => + toGovernanceActions( + afterSetup({ pending: [delayChange(8n * DAY)], notice }), + [governance], + WALLET, + {}, + ).find(({ id }) => id === "apply-change")! - expect(ids(state())).not.toContain("apply-upgrade") - expect(ids(state())).not.toContain("cancel-upgrade") - expect(ids(withUpgrades({ proposal: codeProposal }))).toEqual( - expect.arrayContaining(["apply-upgrade", "cancel-upgrade"]), + expect(condition(apply(10n * DAY), "Delay ≥ notice 10d")).toBe(false) + expect(apply(DAY).enabled).toBe(true) + expect(apply(DAY).outcome).toBe( + `Upgrade delay becomes ${formatDuration(8n * DAY)}.`, ) }) @@ -678,10 +891,6 @@ describe("upgrades and wind-down", () => { supply: null, }, }) - const ids = (current: GovernanceState) => - toGovernanceActions(current, [governance], WALLET, {}).map( - (action) => action.id, - ) expect(confirm?.label).toBe("Starts closing the vault.") expect(draft.enabled).toBe(false) @@ -706,23 +915,10 @@ describe("upgrades and wind-down", () => { expect(ids(state())).not.toContain("cancel-wind-down") }) - it("keeps a pending code upgrade short in its tile but exposes the full hash", () => { - const [vault] = toGovernanceGroups(withUpgrades({ proposal: codeProposal })) - - expect( - vault?.metrics.find(({ label }) => label === "Pending upgrade"), - ).toEqual({ - label: "Pending upgrade", - value: "Code abab...abab", - title: `Code ${HASH}`, - note: `ETA ${formatTimestamp(CLOSE_TIME + DAY)}`, - }) - }) - - it("shows the pending upgrade and the proposed wind-down in the vault group", () => { - const [vault] = toGovernanceGroups( - withUpgrades({ - proposal: { kind: "delay", secs: 14n * DAY, eta: CLOSE_TIME + DAY }, + it("lists the pending changes in their own group and the proposed wind-down in the vault group", () => { + const groups = toGovernanceGroups( + afterSetup({ + pending: [custodianChange], windDown: { phase: "proposed", activeAt: CLOSE_TIME + 2n * DAY, @@ -734,21 +930,499 @@ describe("upgrades and wind-down", () => { }), ) - expect(vault?.metrics.slice(-2)).toEqual([ + expect( + groups.find(({ title }) => title === "Vault pending changes"), + ).toEqual({ + title: "Vault pending changes", + metrics: [ + { + label: "#1 Custodian", + value: "GABA...EJXA", + title: ACCOUNT, + note: `ETA ${formatTimestamp(CLOSE_TIME + DAY)}`, + }, + ], + }) + expect(groups[0]?.metrics.at(-1)).toEqual({ + label: "Wind-down", + value: "Proposed", + note: `Activates ${formatTimestamp(CLOSE_TIME + 2n * DAY)}`, + }) + expect(toGovernanceGroups(state()).map(({ title }) => title)).not.toContain( + "Vault pending changes", + ) + }) +}) + +describe("share-token timelock", () => { + const COMPLIANCE = VAULT + const tokenUpgrades = ( + token: Partial, + vault: Partial = {}, + ): GovernanceState => ({ + ...state(), + upgrades: { + vault: upgrades(vault), + token: upgrades(token), + oracle: upgrades(), + }, + }) + const tokenClosed = (overrides: Partial = {}) => + tokenUpgrades({ setupOpen: false, ...overrides }) + const complianceChange = { + id: 1, + change: { tag: "Compliance", values: [COMPLIANCE] }, + eta: CLOSE_TIME + DAY, + } + const waits = { label: "Waits for the timelock", met: true, detail: "7d" } + const actions = (current: GovernanceState, grant = tokenAdmin) => + toGovernanceActions(current, [grant], WALLET, {}) + + it("grants a share-token role through the timelock once the token's own setup closes", () => { + const inputs = { + "grant-token-role-role": "compliance", + "grant-token-role-account": ACCOUNT, + } + const proposed = find( + "propose-token-grant-role", + [tokenAdmin], + inputs, + tokenUpgrades({ setupOpen: false }, { setupOpen: true }), + ) + const direct = find( + "grant-token-role", + [tokenAdmin], + inputs, + tokenUpgrades({ setupOpen: true }, { setupOpen: false }), + ) + + expect(proposed.slot).toBe("grant-token-role") + expect(proposed.label).toBe("Grant a share-token role") + expect(proposed.arg).toEqual({ role: "compliance", account: ACCOUNT }) + expect(proposed.conditions).toContainEqual(waits) + expect(proposed.enabled).toBe(true) + expect(direct.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Setup is open", + }) + }) + + it("revokes the token manager through the timelock and compliance at once", () => { + const revoke = (role: string, holder: string) => + toGovernanceActions( + { + ...tokenClosed(), + holders: holders({ [`share-token ${role}`]: [holder] }), + }, + [tokenAdmin], + WALLET, + { + "revoke-token-role-role": role, + "revoke-token-role-account": holder, + }, + ).find(({ slot }) => slot === "revoke-token-role") + const manager = revoke("manager", ACCOUNT) + const compliance = revoke("compliance", ACCOUNT) + + expect(manager?.id).toBe("propose-token-revoke-role") + expect(manager?.conditions).toContainEqual(waits) + expect(compliance?.id).toBe("revoke-token-role") + expect(compliance?.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Revoking this role is instant", + }) + }) + + it("hands the token admin over through the timelock, once confirmed, and cancels an offer at once", () => { + const inputs = { "hand-over-token-admin-account": ACCOUNT } + const draft = find( + "propose-token-admin", + [tokenAdmin], + inputs, + tokenClosed(), + ) + const confirm = fieldOf(draft, "confirm") + const confirmed = find( + "propose-token-admin", + [tokenAdmin], + { ...inputs, [confirm?.key ?? ""]: confirm?.value }, + tokenClosed(), + ) + const offered = { + ...state( + {}, + { token: { address: ACCOUNT, liveUntilLedger: 5_000, expired: false } }, + ), + upgrades: tokenClosed().upgrades, + } + + expect(draft.slot).toBe("hand-over-token-admin") + expect(draft.label).toBe("Hand over share-token admin") + expect(confirm?.label).toBe( + "Hands control of the share token to GABA...EJXA.", + ) + expect(draft.enabled).toBe(false) + expect(confirmed.conditions).toContainEqual(waits) + expect(confirmed.enabled).toBe(true) + expect(confirmed.arg).toBe(ACCOUNT) + expect(actions(offered).map(({ id }) => id)).toContain( + "cancel-token-handover", + ) + }) + + it("holds a timelocked token action while the token's setup state is unknown", () => { + const grant = find( + "grant-token-role", + [tokenAdmin], + { "grant-token-role-account": ACCOUNT }, + tokenUpgrades({ setupOpen: null }), + ) + + expect(condition(grant, "Setup state read")).toBeNull() + expect(grant.enabled).toBe(false) + }) + + it("counts the token's own queue against the 16-change limit", () => { + const full = Array.from({ length: 16 }, (_, id) => ({ + ...complianceChange, + id, + })) + const grant = (current: GovernanceState) => + find( + "propose-token-grant-role", + [tokenAdmin], + { "grant-token-role-account": ACCOUNT }, + current, + ) + + expect( + condition( + grant(tokenClosed({ pending: full })), + "Under 16 pending changes", + ), + ).toBe(false) + expect( + condition( + grant(tokenUpgrades({ setupOpen: false }, { pending: full })), + "Under 16 pending changes", + ), + ).toBe(true) + }) + + it("offers Apply and Cancel for every pending token change under the token's ids", () => { + const current = tokenUpgrades( + { setupOpen: false, pending: [complianceChange] }, { - label: "Pending upgrade", - value: "Delay 14d", - note: `ETA ${formatTimestamp(CLOSE_TIME + DAY)}`, + setupOpen: false, + pending: [ + { ...complianceChange, change: { tag: "Notice", values: [DAY] } }, + ], }, + ) + + expect( + actions(current) + .filter(({ id }) => id.endsWith("token-change")) + .map(({ id, slot, arg }) => [id, slot, arg]), + ).toEqual([ + ["apply-token-change", "apply-token-change-1", 1], + ["cancel-token-change", "cancel-token-change-1", 1], + ]) + expect(actions(current, governance).map(({ id }) => id)).not.toContain( + "apply-token-change", + ) + }) + + it("applies a token change after its ETA while the vault is unpaused and not winding down", () => { + const apply = (overrides: Partial) => + actions(tokenClosed({ pending: [complianceChange], ...overrides })).find( + ({ id }) => id === "apply-token-change", + )! + const due = { now: CLOSE_TIME + DAY } + + expect(condition(apply({}), "ETA reached")).toBe(false) + expect(apply(due).enabled).toBe(true) + expect(apply(due).outcome).toBe("Compliance contract becomes CABQ...GCK3.") + expect(apply(due).next).toBe( + "share token change #1: Compliance contract CABQ...GCK3", + ) + expect(apply(due).conditions.map(({ label }) => label)).toEqual([ + "ETA reached", + "Vault not paused", + "Wind-down not active", + ]) + expect( + condition(apply({ ...due, paused: "paused" }), "Vault not paused"), + ).toBe(false) + }) + + it("lists the share token's pending changes in their own group", () => { + const groups = toGovernanceGroups( + tokenClosed({ pending: [complianceChange] }), + ) + + expect( + groups.find(({ title }) => title === "Share token pending changes"), + ).toEqual({ + title: "Share token pending changes", + metrics: [ + { + label: "#1 Compliance contract", + value: "CABQ...GCK3", + title: COMPLIANCE, + note: `ETA ${formatTimestamp(CLOSE_TIME + DAY)}`, + }, + ], + }) + expect(groups.map(({ title }) => title)).not.toContain( + "Vault pending changes", + ) + }) +}) + +describe("oracle timelock", () => { + const HASH = "cd".repeat(32) + const oracleUpgrades = ( + oracle: Partial, + vault: Partial = {}, + ): GovernanceState => ({ + ...state(), + upgrades: { + vault: upgrades(vault), + token: upgrades(), + oracle: upgrades(oracle), + }, + }) + const oracleClosed = (overrides: Partial = {}) => + oracleUpgrades({ setupOpen: false, ...overrides }) + const adminChange = { + id: 3, + change: { tag: "Admin", values: [ACCOUNT] }, + eta: CLOSE_TIME + DAY, + } + const configChange = { + id: 4, + change: { + tag: "Config", + values: [ + { + freshness_duration: DAY, + cooldown_secs: 3_600n, + max_up_bps: 1_000, + max_down_bps: 500, + min_answer: 900_000_000_000_000_000n, + max_answer: 1_100_000_000_000_000_000n, + }, + ], + }, + eta: CLOSE_TIME + DAY, + } + const upgradeChange = { + id: 5, + change: { tag: "Upgrade", values: [Buffer.from(HASH, "hex")] }, + eta: CLOSE_TIME + DAY, + } + const waits = { label: "Waits for the timelock", met: true, detail: "7d" } + const actions = (current: GovernanceState, grant = oracleAdmin) => + toGovernanceActions(current, [grant], WALLET, {}) + + it("grants an oracle role through the timelock once the oracle's own setup closes", () => { + const inputs = { + "grant-oracle-role-role": "attester", + "grant-oracle-role-account": ACCOUNT, + } + const proposed = find( + "propose-oracle-grant-role", + [oracleAdmin], + inputs, + oracleUpgrades({ setupOpen: false }, { setupOpen: true }), + ) + const direct = find( + "grant-oracle-role", + [oracleAdmin], + inputs, + oracleUpgrades({ setupOpen: true }, { setupOpen: false }), + ) + + expect(proposed.slot).toBe("grant-oracle-role") + expect(proposed.label).toBe("Grant an oracle role") + expect(proposed.arg).toEqual({ role: "attester", account: ACCOUNT }) + expect(proposed.conditions).toContainEqual(waits) + expect(proposed.enabled).toBe(true) + expect(direct.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Setup is open", + }) + }) + + it("revokes every oracle role at once after setup", () => { + const revoke = (role: string) => + toGovernanceActions( + { + ...oracleClosed(), + holders: holders({ [`oracle ${role}`]: [ACCOUNT] }), + }, + [oracleAdmin], + WALLET, + { + "revoke-oracle-role-role": role, + "revoke-oracle-role-account": ACCOUNT, + }, + ).find(({ slot }) => slot === "revoke-oracle-role") + + for (const role of ["attester", "guardian"]) { + expect(revoke(role)?.id).toBe("revoke-oracle-role") + expect(revoke(role)?.conditions).toContainEqual({ + label: "Applies at once", + met: true, + detail: "Revoking this role is instant", + }) + expect(revoke(role)?.enabled).toBe(true) + } + }) + + it("hands the oracle admin over through the timelock, once confirmed, and cancels an offer at once", () => { + const inputs = { "hand-over-oracle-admin-account": ACCOUNT } + const draft = find( + "propose-oracle-admin", + [oracleAdmin], + inputs, + oracleClosed(), + ) + const confirm = fieldOf(draft, "confirm") + const confirmed = find( + "propose-oracle-admin", + [oracleAdmin], + { ...inputs, [confirm?.key ?? ""]: confirm?.value }, + oracleClosed(), + ) + const offered = { + ...state( + {}, + { + oracle: { address: ACCOUNT, liveUntilLedger: 5_000, expired: false }, + }, + ), + upgrades: oracleClosed().upgrades, + } + + expect(draft.slot).toBe("hand-over-oracle-admin") + expect(draft.label).toBe("Hand over oracle admin") + expect(confirm?.label).toBe("Hands control of the oracle to GABA...EJXA.") + expect(draft.enabled).toBe(false) + expect(confirmed.conditions).toContainEqual(waits) + expect(confirmed.enabled).toBe(true) + expect(confirmed.arg).toBe(ACCOUNT) + expect(actions(offered).map(({ id }) => id)).toContain( + "cancel-oracle-handover", + ) + }) + + it("holds a timelocked oracle action while the oracle's setup state is unknown", () => { + const grant = find( + "grant-oracle-role", + [oracleAdmin], + { "grant-oracle-role-account": ACCOUNT }, + oracleUpgrades({ setupOpen: null }), + ) + + expect(condition(grant, "Setup state read")).toBeNull() + expect(grant.enabled).toBe(false) + }) + + it("offers the oracle code upgrade only as a proposed change after setup, one at a time", () => { + const input = { "propose-oracle-upgrade-hash": HASH } + const open = find("propose-oracle-upgrade", [oracleAdmin], input) + const closed = find( + "propose-oracle-upgrade", + [oracleAdmin], + input, + oracleClosed(), + ) + const busy = find( + "propose-oracle-upgrade", + [oracleAdmin], + input, + oracleClosed({ pending: [upgradeChange] }), + ) + + expect(open.label).toBe("Propose an oracle code upgrade") + expect(condition(open, "Setup closed")).toBe(false) + expect(open.enabled).toBe(false) + expect(closed.enabled).toBe(true) + expect(closed.arg).toBe(HASH) + expect(closed.conditions).toContainEqual(waits) + expect(condition(busy, "No pending code change")).toBe(false) + expect( + find("propose-code-upgrade", [governance], {}, oracleClosed()).label, + ).toBe("Propose a code upgrade") + }) + + it("offers Apply and Cancel for every pending oracle change under the oracle's ids", () => { + const current = oracleClosed({ pending: [adminChange] }) + + expect( + actions(current) + .filter(({ id }) => id.endsWith("oracle-change")) + .map(({ id, slot, arg }) => [id, slot, arg]), + ).toEqual([ + ["apply-oracle-change", "apply-oracle-change-3", 3], + ["cancel-oracle-change", "cancel-oracle-change-3", 3], + ]) + expect(actions(current, governance).map(({ id }) => id)).not.toContain( + "apply-oracle-change", + ) + }) + + it("applies an oracle change after its ETA while the vault is unpaused and not winding down", () => { + const apply = ( + overrides: Partial, + change: PendingChange = adminChange, + ) => + actions(oracleClosed({ pending: [change], ...overrides })).find( + ({ id }) => id === "apply-oracle-change", + )! + const due = { now: CLOSE_TIME + DAY } + + expect(condition(apply({}), "ETA reached")).toBe(false) + expect(apply(due).enabled).toBe(true) + expect(apply(due).outcome).toBe("Offers the admin to GABA...EJXA.") + expect(apply(due).conditions.map(({ label }) => label)).toEqual([ + "ETA reached", + "Vault not paused", + "Wind-down not active", + ]) + expect( + condition(apply({ ...due, paused: "paused" }), "Vault not paused"), + ).toBe(false) + expect( + condition( + apply({ ...due, windDown: { ...upgradesWindDown, phase: "active" } }), + "Wind-down not active", + ), + ).toBe(false) + expect(apply(due, upgradeChange).tone).toBe("danger") + expect(fieldOf(apply(due, upgradeChange), "confirm")?.label).toBe( + `Replaces the oracle's code with ${HASH}.`, + ) + }) + + it("lists the oracle's pending changes in their own group, limits included", () => { + const groups = toGovernanceGroups(oracleClosed({ pending: [configChange] })) + + expect( + groups.find(({ title }) => title === "Oracle pending changes")?.metrics, + ).toEqual([ { - label: "Wind-down", - value: "Proposed", - note: `Activates ${formatTimestamp(CLOSE_TIME + 2n * DAY)}`, + label: "#4 Oracle limits", + value: + "freshness window 1d, cooldown 1h, largest rise 10%, largest fall 5%, price floor 0.9000, price ceiling 1.1000", + note: `ETA ${formatTimestamp(CLOSE_TIME + DAY)}`, }, ]) - expect( - toGovernanceGroups(state())[0]?.metrics.map(({ label }) => label), - ).not.toContain("Pending upgrade") }) }) diff --git a/app/src/pages/governance.ts b/app/src/pages/governance.ts index b0408c37..88679f68 100644 --- a/app/src/pages/governance.ts +++ b/app/src/pages/governance.ts @@ -5,15 +5,12 @@ import { type GovernedContract, } from "../hooks/pendingAdmins" import { type Grant, type RoleHolders } from "../hooks/useAdminAuthority" -import { - type Handovers, - type PendingUpgrade, - type Upgrades, -} from "../hooks/useGovernanceState" +import { type Handovers, type Upgrades } from "../hooks/useGovernanceState" import { type AdminOffer, type RoleChange, } from "../hooks/useGovernanceTransactions" +import { type TimelockedContract } from "../hooks/useSetupOpen" import { allMet, type Built, @@ -25,12 +22,21 @@ import { grantFor, type Inputs, parseAccount, + parseHash, type RailField, ROLES, type Spec, toAction, } from "./actions" import { DAY, formatDuration, formatTimestamp, parseDuration } from "./cycle" +import { + applyChangeSpec, + cancelChangeSpec, + type PendingChange, + pendingChangeMetrics, + QUEUES, +} from "./pendingChanges" +import { type Route, routeCondition, routeOf } from "./timelock" type Operation = "grant" | "revoke" @@ -39,26 +45,63 @@ type OfferActionId = `hand-over-${GovernedContract}-admin` | `cancel-${GovernedContract}-handover` type AcceptActionId = `accept-${GovernedContract}-admin` -type ProposalActionId = "propose-code-upgrade" | "propose-upgrade-delay" -type VaultActionId = - "apply-upgrade" | "cancel-upgrade" | "propose-wind-down" | "cancel-wind-down" +type CodeUpgradeActionId = "propose-code-upgrade" | "propose-oracle-upgrade" +type ProposalActionId = CodeUpgradeActionId | "propose-upgrade-delay" +type ProposedRoleActionId = + | "propose-grant-role" + | "propose-revoke-role" + | "propose-token-grant-role" + | "propose-token-revoke-role" + | "propose-oracle-grant-role" +type ProposedAdminActionId = + "propose-admin" | "propose-token-admin" | "propose-oracle-admin" +type ChangeActionId = + | "apply-change" + | "cancel-change" + | "apply-token-change" + | "cancel-token-change" + | "apply-oracle-change" + | "cancel-oracle-change" +type VaultActionId = "propose-wind-down" | "cancel-wind-down" export type GovernanceActionId = | RoleActionId | OfferActionId | AcceptActionId | ProposalActionId + | ProposedRoleActionId + | ProposedAdminActionId + | ChangeActionId | VaultActionId const VAULT_ACTION_IDS: GovernanceActionId[] = [ + "propose-grant-role", + "propose-revoke-role", + "propose-admin", "propose-code-upgrade", "propose-upgrade-delay", - "apply-upgrade", - "cancel-upgrade", + "apply-change", + "cancel-change", "propose-wind-down", "cancel-wind-down", ] +const TOKEN_ACTION_IDS: GovernanceActionId[] = [ + "propose-token-grant-role", + "propose-token-revoke-role", + "propose-token-admin", + "apply-token-change", + "cancel-token-change", +] + +const ORACLE_ACTION_IDS: GovernanceActionId[] = [ + "propose-oracle-grant-role", + "propose-oracle-admin", + "propose-oracle-upgrade", + "apply-oracle-change", + "cancel-oracle-change", +] + export const GOVERNANCE_ACTION_IDS: GovernanceActionId[] = [ ...GOVERNED_CONTRACTS.flatMap((contract) => [ `grant-${contract}-role` as const, @@ -68,13 +111,17 @@ export const GOVERNANCE_ACTION_IDS: GovernanceActionId[] = [ `accept-${contract}-admin` as const, ]), ...VAULT_ACTION_IDS, + ...TOKEN_ACTION_IDS, + ...ORACLE_ACTION_IDS, ] export type GovernanceAction = - | Built + | Built | Built + | Built + | Built | Built - | Built<"propose-code-upgrade", string | undefined> + | Built | Built<"propose-upgrade-delay", bigint | undefined> | Built @@ -82,7 +129,7 @@ export type GovernanceState = { holders: RoleHolders vault: string handovers: Handovers - upgrades: Upgrades + upgrades: Record } export type GovernanceGroup = { title: string; metrics: Metric[] } @@ -243,12 +290,77 @@ const accountField = (key: string, text: string, label: string): RailField => ({ text: true, }) +type ReadyUpgrades = Extract + +const readyUpgrades = ( + state: GovernanceState, + contract: TimelockedContract, +): ReadyUpgrades | undefined => { + const upgrades = state.upgrades[contract] + return upgrades.status === "ready" ? upgrades : undefined +} + +const MAX_PENDING = 16 + +const underCapacity = (upgrades?: ReadyUpgrades): Condition => ({ + label: `Under ${MAX_PENDING} pending changes`, + met: + upgrades?.pending === undefined + ? null + : upgrades.pending.length < MAX_PENDING, +}) + +const windDownInactive = (upgrades?: ReadyUpgrades): Condition => { + const phase = upgrades?.windDown.phase ?? null + return { + label: "Wind-down not active", + met: phase === null ? null : phase !== "active", + } +} + +const routeFor = ( + state: GovernanceState, + contract: TimelockedContract, + waits: boolean, +): Route => routeOf(readyUpgrades(state, contract)?.setupOpen ?? null, waits) + +const routeConditions = ( + state: GovernanceState, + contract: TimelockedContract, + route: Route, + atOnce?: string, +): Condition[] => { + const upgrades = readyUpgrades(state, contract) + const line = routeCondition(route, upgrades?.delay ?? null, atOnce) + return route === "timelock" + ? [line, underCapacity(upgrades), windDownInactive(upgrades)] + : [line] +} + +const PROPOSED_ROLES: Record< + TimelockedContract, + { grant: ProposedRoleActionId; revoke?: ProposedRoleActionId } +> = { + vault: { grant: "propose-grant-role", revoke: "propose-revoke-role" }, + token: { + grant: "propose-token-grant-role", + revoke: "propose-token-revoke-role", + }, + oracle: { grant: "propose-oracle-grant-role" }, +} + +const PROPOSED_ADMINS: Record = { + vault: "propose-admin", + token: "propose-token-admin", + oracle: "propose-oracle-admin", +} + function roleSpec( operation: Operation, contract: GovernedContract, state: GovernanceState, inputs: Inputs, -): BuiltSpec { +): BuiltSpec { const id: RoleActionId = `${operation}-${contract}-role` const { noun, roles } = CONTRACTS[contract] const roleKey = `${id}-role` @@ -291,9 +403,22 @@ function roleSpec( fields.push(confirm.field) conditions.push(confirm.condition) } + const waits = operation === "grant" || entry.role === "manager" + const route = routeFor(state, contract, waits) + conditions.push( + ...routeConditions( + state, + contract, + route, + waits ? undefined : "Revoking this role is instant", + ), + ) + const proposed = + route === "timelock" ? PROPOSED_ROLES[contract][operation] : undefined const changes = holds === (operation === "revoke") return { - id, + id: proposed ?? id, + slot: id, label: `${capitalize(operation)} ${noun}`, conditions, outcome: @@ -378,6 +503,7 @@ function handOverSpec( met: secs === undefined ? null : secs >= MIN_EXPIRY, }, limitCondition(ledger, liveUntil), + ...routeConditions(state, contract, routeFor(state, contract, true)), ] if (account !== undefined) { const confirm = confirmation( @@ -412,6 +538,51 @@ function handOverSpec( } } +function proposeAdminSpec( + contract: TimelockedContract, + state: GovernanceState, + inputs: Inputs, +): BuiltSpec { + const slot: OfferActionId = `hand-over-${contract}-admin` + const text = inputs[`${slot}-account`] ?? "" + const account = parseAccount(text) + const fields: RailField[] = [ + accountField(`${slot}-account`, text, "New admin"), + ] + const conditions: Condition[] = [ + { + label: "New admin entered", + met: text.trim() === "" ? null : account !== undefined, + }, + ...routeConditions(state, contract, "timelock"), + ] + if (account !== undefined) { + const confirm = confirmation( + confirmKey(slot), + `Hands control of the ${CONTRACTS[contract].subject} to ${shortAddress(account)}.`, + account, + inputs, + ) + fields.push(confirm.field) + conditions.push(confirm.condition) + } + return { + id: PROPOSED_ADMINS[contract], + slot, + label: `Hand over ${CONTRACTS[contract].name} admin`, + conditions, + outcome: + account === undefined + ? null + : `${shortAddress(account)} can accept the admin once the change applies.`, + fields, + danger: true, + ready: allMet(conditions), + arg: account, + next: account === undefined ? "" : shortAddress(account), + } +} + const cancelSpec = ( contract: GovernedContract, pending: PendingTransfer, @@ -428,6 +599,7 @@ const acceptSpec = ( contract: GovernedContract, ledger: Ledger, pending: PendingTransfer, + upgrades?: ReadyUpgrades, ): Spec & { arg: undefined; next: string } => ({ id: `accept-${contract}-admin`, label: `Accept ${CONTRACTS[contract].name} admin`, @@ -437,27 +609,33 @@ const acceptSpec = ( met: true, detail: formatTimestamp(ledgerTime(ledger, pending.liveUntilLedger)), }, + windDownInactive(upgrades), ], outcome: `${shortAddress(pending.address)} becomes the admin of the ${CONTRACTS[contract].subject}.`, arg: undefined, next: CONTRACTS[contract].subject, }) -type ReadyUpgrades = Extract - const MIN_UPGRADE_DELAY = 7n * DAY const MAX_UPGRADE_DELAY = 90n * DAY -const WASM_HASH = /^[0-9a-f]{64}$/i - -const upgradeName = (proposal: PendingUpgrade, full = false): string => - proposal.kind === "code" - ? `Code ${full ? proposal.hash : shortAddress(proposal.hash)}` - : `Delay ${formatDuration(proposal.secs)}` +const CODE_CHANGES = new Set(["Upgrade", "UpgradeDelay"]) -const noPendingUpgrade = (upgrades?: ReadyUpgrades): Condition => ({ - label: "No pending upgrade", - met: upgrades?.proposal === undefined ? null : upgrades.proposal === null, -}) +const proposalChecks = (upgrades?: ReadyUpgrades): Condition[] => [ + { + label: "Setup closed", + met: upgrades?.setupOpen == null ? null : !upgrades.setupOpen, + }, + { + label: "No pending code change", + met: + upgrades?.pending === undefined + ? null + : !upgrades.pending.some(({ change }) => CODE_CHANGES.has(change.tag)), + }, + underCapacity(upgrades), + windDownInactive(upgrades), + routeCondition("timelock", upgrades?.delay ?? null), +] const applyAfter = (upgrades?: ReadyUpgrades): string | null => upgrades?.now == null || upgrades.delay === null @@ -475,25 +653,25 @@ const aboveNotice = ( met: secs === undefined || notice == null ? null : secs >= notice, }) -function codeUpgradeSpec( +function codeUpgradeSpec( + id: Id, + label: string, inputs: Inputs, upgrades?: ReadyUpgrades, -): BuiltSpec<"propose-code-upgrade", string | undefined> { - const key = "propose-code-upgrade-hash" +): BuiltSpec { + const key = `${id}-hash` const text = inputs[key] ?? "" - const hash = WASM_HASH.test(text.trim()) - ? text.trim().toLowerCase() - : undefined + const hash = parseHash(text) const conditions = [ { label: "Hash of 64 hex characters", met: text.trim() === "" ? null : hash !== undefined, }, - noPendingUpgrade(upgrades), + ...proposalChecks(upgrades), ] return { - id: "propose-code-upgrade", - label: "Propose a code upgrade", + id, + label, conditions, outcome: hash === undefined ? null : applyAfter(upgrades), fields: [ @@ -531,7 +709,7 @@ function delayUpgradeSpec( met: secs === undefined ? null : secs <= MAX_UPGRADE_DELAY, }, aboveNotice(secs, upgrades?.notice), - noPendingUpgrade(upgrades), + ...proposalChecks(upgrades), ] return { id: "propose-upgrade-delay", @@ -556,64 +734,37 @@ function delayUpgradeSpec( } } +const belowUpgradeDelay = ( + notice: bigint | undefined, + delay: bigint | null, +): Condition => ({ + label: + delay === null + ? "Notice ≤ upgrade delay" + : `Notice ≤ upgrade delay ${formatDuration(delay)}`, + met: notice === undefined || delay === null ? null : notice <= delay, +}) + const notPaused = (paused: ReadyUpgrades["paused"]): Condition => ({ label: "Vault not paused", met: paused === "open" ? true : paused === "paused" ? false : null, }) -function applySpec( - proposal: PendingUpgrade, +const secsOf = ({ values }: PendingChange["change"]): bigint | undefined => + typeof values[0] === "bigint" ? values[0] : undefined + +function applyChecks( + { change }: PendingChange, upgrades: ReadyUpgrades, - inputs: Inputs, -): BuiltSpec<"apply-upgrade", undefined> { - const conditions: Condition[] = [ - { - label: "ETA reached", - met: upgrades.now === null ? null : upgrades.now >= proposal.eta, - detail: formatTimestamp(proposal.eta), - }, - notPaused(upgrades.paused), - ] - const fields: RailField[] = [] - if (proposal.kind === "delay") - conditions.push(aboveNotice(proposal.secs, upgrades.notice)) - else { - const confirm = confirmation( - confirmKey("apply-upgrade"), - `Replaces the vault's code with ${proposal.hash}.`, - proposal.hash, - inputs, - ) - fields.push(confirm.field) - conditions.push(confirm.condition) - } - return { - id: "apply-upgrade", - label: "Apply the upgrade", - conditions, - outcome: - proposal.kind === "delay" - ? `Upgrade delay becomes ${formatDuration(proposal.secs)}.` - : null, - fields, - ...(proposal.kind === "code" ? { danger: true } : {}), - ready: allMet(conditions), - arg: undefined, - next: upgradeName(proposal, true), - } +): Condition[] { + const checks = [notPaused(upgrades.paused), windDownInactive(upgrades)] + if (change.tag === "UpgradeDelay") + checks.push(aboveNotice(secsOf(change), upgrades.notice)) + if (change.tag === "Notice") + checks.push(belowUpgradeDelay(secsOf(change), upgrades.delay)) + return checks } -const cancelUpgradeSpec = ( - proposal: PendingUpgrade, -): BuiltSpec<"cancel-upgrade", undefined> => ({ - id: "cancel-upgrade", - label: "Cancel the upgrade", - conditions: [], - outcome: null, - arg: undefined, - next: upgradeName(proposal), -}) - function windDownSpec( inputs: Inputs, upgrades?: ReadyUpgrades, @@ -678,32 +829,85 @@ function adminActions( state.handovers.status === "ready" ? state.handovers.pending[contract] : null + const offer = + routeFor(state, contract, true) === "timelock" + ? build(proposeAdminSpec(contract, state, inputs), grant) + : build(handOverSpec(contract, state, inputs), grant) return [ build(roleSpec("grant", contract, state, inputs), grant), build(roleSpec("revoke", contract, state, inputs), grant), - build(handOverSpec(contract, state, inputs), grant), + offer, ...(pending === null ? [] : [build(cancelSpec(contract, pending), grant)]), ...(contract === "vault" ? vaultActions(state, grant, inputs) : []), + ...(contract === "token" + ? changeActions("token", state, grant, inputs) + : []), + ...(contract === "oracle" ? oracleActions(state, grant, inputs) : []), + ] +} + +function oracleActions( + state: GovernanceState, + grant: Grant, + inputs: Inputs, +): GovernanceAction[] { + const upgrades = readyUpgrades(state, "oracle") + return [ + build( + codeUpgradeSpec( + "propose-oracle-upgrade", + "Propose an oracle code upgrade", + inputs, + upgrades, + ), + grant, + ), + ...changeActions("oracle", state, grant, inputs), ] } +function changeActions( + contract: TimelockedContract, + state: GovernanceState, + grant: Grant, + inputs: Inputs, +): GovernanceAction[] { + const upgrades = readyUpgrades(state, contract) + if (upgrades === undefined) return [] + const queue = QUEUES[contract] + return (upgrades.pending ?? []).flatMap((pending) => [ + build( + applyChangeSpec( + queue, + pending, + upgrades.now, + applyChecks(pending, upgrades), + inputs, + ), + grant, + ), + build(cancelChangeSpec(queue, pending), grant), + ]) +} + function vaultActions( state: GovernanceState, grant: Grant, inputs: Inputs, ): GovernanceAction[] { - const upgrades = - state.upgrades.status === "ready" ? state.upgrades : undefined - const proposal = upgrades?.proposal ?? null + const upgrades = readyUpgrades(state, "vault") return [ - build(codeUpgradeSpec(inputs, upgrades), grant), + build( + codeUpgradeSpec( + "propose-code-upgrade", + "Propose a code upgrade", + inputs, + upgrades, + ), + grant, + ), build(delayUpgradeSpec(inputs, upgrades), grant), - ...(proposal === null || upgrades === undefined - ? [] - : [ - build(applySpec(proposal, upgrades, inputs), grant), - build(cancelUpgradeSpec(proposal), grant), - ]), + ...changeActions("vault", state, grant, inputs), build(windDownSpec(inputs, upgrades), grant), ...(upgrades?.windDown.phase === "proposed" ? [build(cancelWindDownSpec(), grant)] @@ -727,7 +931,17 @@ function acceptAction( grant.authority !== pending.address ) return [] - return [build(acceptSpec(contract, state.handovers.ledger, pending), grant)] + return [ + build( + acceptSpec( + contract, + state.handovers.ledger, + pending, + readyUpgrades(state, contract), + ), + grant, + ), + ] } export function toGovernanceActions( @@ -781,16 +995,7 @@ const pendingMetric = ( const upgradeMetrics = (upgrades: Upgrades): Metric[] => { if (upgrades.status !== "ready") return [] const metrics: Metric[] = [] - const { proposal, windDown } = upgrades - if (proposal != null) - metrics.push({ - label: "Pending upgrade", - value: upgradeName(proposal), - ...(proposal.kind === "code" - ? { title: upgradeName(proposal, true) } - : {}), - note: `ETA ${formatTimestamp(proposal.eta)}`, - }) + const { windDown } = upgrades if (windDown.phase === "proposed" && windDown.activeAt !== null) metrics.push({ label: "Wind-down", @@ -800,12 +1005,22 @@ const upgradeMetrics = (upgrades: Upgrades): Metric[] => { return metrics } +const pendingChangesGroup = ( + contract: TimelockedContract, + upgrades: Upgrades, +): GovernanceGroup[] => { + if (upgrades.status !== "ready") return [] + const metrics = pendingChangeMetrics(upgrades.pending) + const title = `${CONTRACTS[contract].title} pending changes` + return metrics.length === 0 ? [] : [{ title, metrics }] +} + export const toGovernanceGroups = ({ holders, handovers, upgrades, -}: GovernanceState): GovernanceGroup[] => - GOVERNED_CONTRACTS.map((contract) => { +}: GovernanceState): GovernanceGroup[] => [ + ...GOVERNED_CONTRACTS.map((contract) => { const { title, admin, roles } = CONTRACTS[contract] return { title, @@ -814,7 +1029,11 @@ export const toGovernanceGroups = ({ holdersMetric(entry.label, holders[entry.holders]), ), ...pendingMetric(handovers, contract), - ...(contract === "vault" ? upgradeMetrics(upgrades) : []), + ...(contract === "vault" ? upgradeMetrics(upgrades.vault) : []), ], } - }) + }), + ...GOVERNED_CONTRACTS.flatMap((contract) => + pendingChangesGroup(contract, upgrades[contract]), + ), +] diff --git a/app/src/pages/pendingChanges.test.ts b/app/src/pages/pendingChanges.test.ts new file mode 100644 index 00000000..ee086b23 --- /dev/null +++ b/app/src/pages/pendingChanges.test.ts @@ -0,0 +1,205 @@ +import { Buffer } from "buffer" +import { describe, expect, it } from "vitest" +import { type Condition, type Inputs } from "./actions" +import { formatTimestamp } from "./cycle" +import { + applyChangeSpec, + cancelChangeSpec, + describeChange, + type PendingChange, + pendingChangeMetrics, + QUEUES, +} from "./pendingChanges" + +const ACCOUNT = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" +const FEED = "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3" +const HASH = "ab".repeat(32) +const DAY = 86_400n +const NOW = 1_700_000_000n + +const pending = ( + change: PendingChange["change"], + eta = NOW, + id = 3, +): PendingChange => ({ id, change, eta }) + +const custodian = pending({ tag: "Custodian", values: [ACCOUNT] }) +const upgrade = pending({ tag: "Upgrade", values: [Buffer.from(HASH, "hex")] }) + +describe("describeChange", () => { + it.each<[PendingChange["change"], string, string]>([ + [upgrade.change, "Code upgrade", "abab...abab"], + [{ tag: "UpgradeDelay", values: [14n * DAY] }, "Upgrade delay", "14d"], + [{ tag: "Custodian", values: [ACCOUNT] }, "Custodian", "GABA...EJXA"], + [{ tag: "Notice", values: [DAY] }, "Notice", "1d"], + [{ tag: "WindDownDelay", values: [8n * DAY] }, "Wind-down delay", "8d"], + [ + { tag: "MinEpochDuration", values: [7n * DAY] }, + "Minimum epoch duration", + "7d", + ], + [ + { tag: "GrantRole", values: ["manager", ACCOUNT] }, + "Grant role", + "manager to GABA...EJXA", + ], + [ + { tag: "RevokeRole", values: ["manager", ACCOUNT] }, + "Revoke role", + "manager from GABA...EJXA", + ], + [{ tag: "Admin", values: [ACCOUNT] }, "Admin hand-over", "GABA...EJXA"], + [{ tag: "Oracle", values: [FEED] }, "Oracle", "CABQ...GCK3"], + [ + { tag: "Compliance", values: [FEED] }, + "Compliance contract", + "CABQ...GCK3", + ], + [ + { tag: "IdentityVerifier", values: [FEED] }, + "Identity verifier", + "CABQ...GCK3", + ], + [ + { + tag: "Config", + values: [ + { + freshness_duration: DAY, + cooldown_secs: 3_600n, + max_up_bps: 1_000, + max_down_bps: 250, + min_answer: 500_000_000_000_000_000n, + max_answer: 2_000_000_000_000_000_000n, + }, + ], + }, + "Oracle limits", + "freshness window 1d, cooldown 1h, largest rise 10%, largest fall 2.5%, price floor 0.5000, price ceiling 2.0000", + ], + ])("names %o as %s %s", (change, kind, value) => { + expect(describeChange(change)).toMatchObject({ kind, value }) + }) + + it("keeps the full hash and address for where there is room", () => { + expect(describeChange(upgrade.change).full).toBe(HASH) + expect(describeChange(custodian.change).full).toBe(ACCOUNT) + }) + + it("falls back to the tag for a kind it does not know", () => { + expect(describeChange({ tag: "Fee", values: [] })).toMatchObject({ + kind: "Fee", + }) + }) +}) + +describe("pendingChangeMetrics", () => { + it("lists every pending change with its id, kind and eta", () => { + expect(pendingChangeMetrics([custodian, { ...upgrade, id: 4 }])).toEqual([ + { + label: "#3 Custodian", + value: "GABA...EJXA", + title: ACCOUNT, + note: `ETA ${formatTimestamp(NOW)}`, + }, + { + label: "#4 Code upgrade", + value: "abab...abab", + title: HASH, + note: `ETA ${formatTimestamp(NOW)}`, + }, + ]) + }) + + it("shows nothing when nothing waits and says so when unreadable", () => { + expect(pendingChangeMetrics([])).toEqual([]) + expect(pendingChangeMetrics(undefined)).toEqual([ + { label: "Pending changes", value: null, note: "Could not be read" }, + ]) + }) +}) + +describe("applyChangeSpec", () => { + const paused: Condition = { label: "Vault not paused", met: false } + + it("applies a change once its eta is reached, passing the contract's own checks", () => { + const early = applyChangeSpec(QUEUES.vault, custodian, NOW - 1n, [], {}) + const due = applyChangeSpec(QUEUES.vault, custodian, NOW, [], {}) + const blocked = applyChangeSpec(QUEUES.vault, custodian, NOW, [paused], {}) + + expect(early.conditions[0]).toEqual({ + label: "ETA reached", + met: false, + detail: formatTimestamp(NOW), + }) + expect(due).toMatchObject({ + id: "apply-change", + slot: "apply-change-3", + label: "Apply the change #3", + ready: true, + arg: 3, + next: "vault change #3: Custodian GABA...EJXA", + outcome: "Custodian becomes GABA...EJXA.", + }) + expect(blocked.conditions).toContainEqual(paused) + expect(blocked.ready).toBe(false) + }) + + it("applies a share-token change under its own id and slot", () => { + const compliance = pending({ tag: "Compliance", values: [FEED] }) + + expect( + applyChangeSpec(QUEUES.token, compliance, NOW, [], {}), + ).toMatchObject({ + id: "apply-token-change", + slot: "apply-token-change-3", + label: "Apply the change #3", + arg: 3, + next: "share token change #3: Compliance contract CABQ...GCK3", + outcome: "Compliance contract becomes CABQ...GCK3.", + }) + expect( + applyChangeSpec(QUEUES.token, upgrade, NOW, [], {}).fields?.[0]?.label, + ).toBe(`Replaces the share token's code with ${HASH}.`) + }) + + it("holds the eta unknown until the clock is read", () => { + expect( + applyChangeSpec(QUEUES.vault, custodian, null, [], {}).conditions[0]?.met, + ).toBeNull() + }) + + it("asks to confirm a code upgrade by its full hash", () => { + const draft = applyChangeSpec(QUEUES.vault, upgrade, NOW, [], {}) + const confirm = draft.fields?.find((field) => field.kind === "confirm") + const inputs: Inputs = { [confirm?.key ?? ""]: confirm?.value ?? "" } + + expect(confirm?.label).toBe(`Replaces the vault's code with ${HASH}.`) + expect(draft.danger).toBe(true) + expect(draft.ready).toBe(false) + expect(applyChangeSpec(QUEUES.vault, upgrade, NOW, [], inputs).ready).toBe( + true, + ) + }) +}) + +describe("cancelChangeSpec", () => { + it("cancels a change by its id at any time", () => { + expect(cancelChangeSpec(QUEUES.vault, custodian)).toMatchObject({ + id: "cancel-change", + slot: "cancel-change-3", + label: "Cancel the change #3", + conditions: [], + arg: 3, + next: "vault change #3: Custodian GABA...EJXA", + }) + }) + + it("cancels a share-token change under its own id and slot", () => { + expect(cancelChangeSpec(QUEUES.token, custodian)).toMatchObject({ + id: "cancel-token-change", + slot: "cancel-token-change-3", + arg: 3, + }) + }) +}) diff --git a/app/src/pages/pendingChanges.ts b/app/src/pages/pendingChanges.ts new file mode 100644 index 00000000..49116b7e --- /dev/null +++ b/app/src/pages/pendingChanges.ts @@ -0,0 +1,239 @@ +import { Buffer } from "buffer" +import { type OracleChange, shortAddress } from "@stellar-scaffold/app-lib" +import { type Metric } from "../components/vault/MetricsStrip" +import { type TimelockedContract } from "../hooks/useSetupOpen" +import { + allMet, + type BuiltSpec, + type Condition, + confirmation, + confirmKey, + type Inputs, + parseAccount, + type RailField, +} from "./actions" +import { formatDuration, formatTimestamp, oracleLimitRows } from "./cycle" + +export type PendingChange = { + id: number + change: { tag: string; values: readonly unknown[] } + eta: bigint +} + +export type ChangeName = { kind: string; value: string; full: string } + +type Kind = { + label: string + join: string + effect?: (value: string) => string + format?: (values: readonly unknown[]) => string +} + +type OracleConfig = Extract["values"][0] + +const limitsText = ([config]: readonly unknown[]): string => { + const { + freshness_duration: freshness, + cooldown_secs: cooldown, + max_up_bps: maxUpBps, + max_down_bps: maxDownBps, + min_answer: min, + max_answer: max, + } = config as OracleConfig + return oracleLimitRows({ + freshness, + cooldown, + maxUpBps, + maxDownBps, + min, + max, + }) + .map(({ label, value }) => `${label.toLowerCase()} ${value ?? "—"}`) + .join(", ") +} + +const becomes = (label: string) => (value: string) => + `${label} becomes ${value}.` + +const KINDS: Record = { + Upgrade: { label: "Code upgrade", join: " " }, + UpgradeDelay: { + label: "Upgrade delay", + join: " ", + effect: becomes("Upgrade delay"), + }, + Custodian: { label: "Custodian", join: " ", effect: becomes("Custodian") }, + Notice: { label: "Notice", join: " ", effect: becomes("Notice") }, + WindDownDelay: { + label: "Wind-down delay", + join: " ", + effect: becomes("Wind-down delay"), + }, + MinEpochDuration: { + label: "Minimum epoch duration", + join: " ", + effect: becomes("Minimum epoch duration"), + }, + GrantRole: { + label: "Grant role", + join: " to ", + effect: (value) => `Grants ${value}.`, + }, + RevokeRole: { + label: "Revoke role", + join: " from ", + effect: (value) => `Revokes ${value}.`, + }, + Admin: { + label: "Admin hand-over", + join: " ", + effect: (value) => `Offers the admin to ${value}.`, + }, + Oracle: { label: "Oracle", join: " ", effect: becomes("Oracle") }, + Compliance: { + label: "Compliance contract", + join: " ", + effect: becomes("Compliance contract"), + }, + IdentityVerifier: { + label: "Identity verifier", + join: " ", + effect: becomes("Identity verifier"), + }, + Config: { + label: "Oracle limits", + join: " ", + format: limitsText, + effect: (value) => `Sets the oracle limits: ${value}.`, + }, +} + +export const QUEUES = { + vault: { name: "vault", apply: "apply-change", cancel: "cancel-change" }, + token: { + name: "share token", + apply: "apply-token-change", + cancel: "cancel-token-change", + }, + oracle: { + name: "oracle", + apply: "apply-oracle-change", + cancel: "cancel-oracle-change", + }, +} as const satisfies Record< + TimelockedContract, + { name: string; apply: string; cancel: string } +> + +type Queue = (typeof QUEUES)[TimelockedContract] + +const shown = (value: unknown, full: boolean): string => { + if (Buffer.isBuffer(value)) { + const hex = value.toString("hex") + return full ? hex : shortAddress(hex) + } + if (typeof value === "bigint") return formatDuration(value) + if (typeof value === "string" && !full && parseAccount(value) !== undefined) + return shortAddress(value) + return String(value) +} + +const kindOf = (tag: string): Kind => KINDS[tag] ?? { label: tag, join: " " } + +export const describeChange = ({ + tag, + values, +}: PendingChange["change"]): ChangeName => { + const { label, join, format } = kindOf(tag) + if (format !== undefined) { + const text = format(values) + return { kind: label, value: text, full: text } + } + return { + kind: label, + value: values.map((value) => shown(value, false)).join(join), + full: values.map((value) => shown(value, true)).join(join), + } +} + +export const changeName = ( + contract: string, + pending: PendingChange, +): string => { + const { kind, value } = describeChange(pending.change) + return `${contract} change #${pending.id}: ${`${kind} ${value}`.trim()}` +} + +export const pendingChangeMetrics = ( + changes: PendingChange[] | undefined, +): Metric[] => { + if (changes === undefined) + return [ + { label: "Pending changes", value: null, note: "Could not be read" }, + ] + return changes.map(({ id, change, eta }) => { + const { kind, value, full } = describeChange(change) + return { + label: `#${id} ${kind}`, + value, + ...(full === value ? {} : { title: full }), + note: `ETA ${formatTimestamp(eta)}`, + } + }) +} + +export function applyChangeSpec( + queue: Q, + pending: PendingChange, + now: bigint | null, + checks: Condition[], + inputs: Inputs, +): BuiltSpec { + const slot = `${queue.apply}-${pending.id}` + const { value, full } = describeChange(pending.change) + const conditions: Condition[] = [ + { + label: "ETA reached", + met: now === null ? null : now >= pending.eta, + detail: formatTimestamp(pending.eta), + }, + ...checks, + ] + const fields: RailField[] = [] + const code = pending.change.tag === "Upgrade" + if (code) { + const confirm = confirmation( + confirmKey(slot), + `Replaces the ${queue.name}'s code with ${full}.`, + full, + inputs, + ) + fields.push(confirm.field) + conditions.push(confirm.condition) + } + return { + id: queue.apply, + slot, + label: `Apply the change #${pending.id}`, + conditions, + outcome: kindOf(pending.change.tag).effect?.(value) ?? null, + fields, + ...(code ? { danger: true } : {}), + ready: allMet(conditions), + arg: pending.id, + next: changeName(queue.name, pending), + } +} + +export const cancelChangeSpec = ( + queue: Q, + pending: PendingChange, +): BuiltSpec => ({ + id: queue.cancel, + slot: `${queue.cancel}-${pending.id}`, + label: `Cancel the change #${pending.id}`, + conditions: [], + outcome: null, + arg: pending.id, + next: changeName(queue.name, pending), +}) diff --git a/app/src/pages/sign.test.ts b/app/src/pages/sign.test.ts index 4eb9915f..4fdac756 100644 --- a/app/src/pages/sign.test.ts +++ b/app/src/pages/sign.test.ts @@ -39,13 +39,14 @@ const pending = ( fn, args, hash: "abc123", + authorization: null, }) describe("describePending", () => { it("maps a vault setter with an amount argument", () => { expect( describePending( - pending(addresses.vault, "set_deposit_cap", [1_500_000_000n, account]), + pending(addresses.vault, "set_deposit_cap", [1_500_000_000n]), addresses, ), ).toEqual({ @@ -56,9 +57,23 @@ describe("describePending", () => { }) }) + it("maps the minimum epoch duration setter", () => { + expect( + describePending( + pending(addresses.vault, "set_min_epoch_duration", [604_800n]), + addresses, + ), + ).toEqual({ + contract: "Vault", + action: "set-min-epoch-duration", + label: "Set the minimum epoch duration", + args: [{ label: "Minimum epoch duration", value: "7d" }], + }) + }) + it("shows an absent deposit cap as no cap", () => { const { args } = describePending( - pending(addresses.vault, "set_deposit_cap", [null, account]), + pending(addresses.vault, "set_deposit_cap", [null]), addresses, ) @@ -96,29 +111,66 @@ describe("describePending", () => { }) }) - it("maps the ripcord flag to raise or lower", () => { - const raise = describePending( - pending(addresses.oracle, "set_ripcord", [true, account]), + it("maps the paused flag to pause or unpause", () => { + const pause = describePending( + pending(addresses.oracle, "set_nav_paused", [true, account]), addresses, ) - const lower = describePending( - pending(addresses.oracle, "set_ripcord", [false, account]), + const unpause = describePending( + pending(addresses.oracle, "set_nav_paused", [false, account]), + addresses, + ) + + expect(pause.action).toBe("pause-nav") + expect(pause.label).toBe("Pause the NAV") + expect(unpause.action).toBe("unpause-nav") + expect(unpause.label).toBe("Unpause the NAV") + }) + + it("describes the guardian's pause_nav as pausing the NAV", () => { + expect( + describePending( + pending(addresses.oracle, "pause_nav", [account]), + addresses, + ), + ).toMatchObject({ action: "pause-nav", label: "Pause the NAV", args: [] }) + }) + + it("shows every oracle limit of set_config, the largest fall as a percent", () => { + const { args } = describePending( + pending(addresses.oracle, "set_config", [ + { + freshness_duration: 86_400n, + cooldown_secs: 3_600n, + max_up_bps: 500, + max_down_bps: 250, + min_answer: 500_000_000_000_000_000n, + max_answer: 2_000_000_000_000_000_000n, + }, + account, + ]), addresses, ) - expect(raise.action).toBe("raise-ripcord") - expect(lower.action).toBe("lower-ripcord") - expect(lower.label).toBe("Lower the ripcord") + expect(args).toEqual([ + { label: "Freshness window", value: "1d" }, + { label: "Cooldown", value: "1h" }, + { label: "Largest rise", value: "5%" }, + { label: "Largest fall", value: "2.5%" }, + { label: "Price floor", value: "0.50" }, + { label: "Price ceiling", value: "2.00" }, + ]) }) - it("formats an attestation price and expiry", () => { + it("formats an attestation price, expiry and proof", () => { expect( describePending( pending(addresses.oracle, "attest", [ { nav_per_share: 1_050_000_000_000_000_000n, expires_at: 1_800_000_000n, - timestamp: 0n, + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "https://example.com/nav.pdf", }, account, ]), @@ -131,33 +183,69 @@ describe("describePending", () => { args: [ { label: "Price", value: "1.0500" }, { label: "Expires", value: "15 Jan 2027" }, + { label: "Proof hash", value: "ab".repeat(32) }, + { label: "Proof URI", value: "https://example.com/nav.pdf" }, ], }) }) - it("maps a forced transfer with shortened addresses and an amount", () => { + it("shows an attestation without a proof URI as none", () => { + const { args } = describePending( + pending(addresses.oracle, "attest", [ + { + nav_per_share: 1_050_000_000_000_000_000n, + expires_at: 1_800_000_000n, + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "", + }, + account, + ]), + addresses, + ) + + expect(args.at(-1)).toEqual({ label: "Proof URI", value: "None" }) + }) + + it("maps a legal transfer with its reference and the compliance caller", () => { expect( describePending( - pending(addresses.shareToken, "forced_transfer", [ + pending(addresses.shareToken, "legal_transfer", [ account, other, 25_000_000n, - account, + "COURT-ORDER-2026/17", + other, ]), addresses, ), ).toEqual({ contract: "Share token", - action: "force-transfer", - label: "Force a transfer", + action: "legal-transfer", + label: "Make a legal transfer", args: [ { label: "From", value: "GBRP...OX2H" }, { label: "To", value: "GDQN...KTL3" }, { label: "Amount", value: "2.50" }, + { label: "Reference", value: "COURT-ORDER-2026/17" }, + { label: "Caller", value: "GDQN...KTL3" }, ], }) }) + it("no longer describes a forced transfer", () => { + expect(() => + describePending( + pending(addresses.shareToken, "forced_transfer", [ + account, + other, + 25_000_000n, + account, + ]), + addresses, + ), + ).toThrow(InvalidPendingTransaction) + }) + it("maps a role grant on the share token", () => { expect( describePending( @@ -182,7 +270,7 @@ describe("describePending", () => { it("maps the configured identity verifier", () => { expect( describePending( - pending(addresses.identityVerifier, "allow", [other, false, account]), + pending(addresses.identityVerifier, "allow", [other, false]), addresses, ), ).toEqual({ @@ -224,16 +312,338 @@ describe("describePending", () => { }) }) - it("shows a code upgrade hash in hex", () => { - const { args } = describePending( - pending(addresses.vault, "propose_upgrade", [ - Buffer.from("abcd", "hex"), - account, - ]), - addresses, - ) + it.each<[unknown[], string, { label: string; value: string }[]]>([ + [ + ["Custodian", account], + "propose-custodian", + [{ label: "Custodian", value: "GBRP...OX2H" }], + ], + [ + ["Notice", 43_200n], + "propose-notice", + [{ label: "Notice", value: "12h" }], + ], + [ + ["WindDownDelay", 691_200n], + "propose-wind-down-delay", + [{ label: "Wind-down delay", value: "8d" }], + ], + [ + ["MinEpochDuration", 604_800n], + "propose-min-epoch-duration", + [{ label: "Minimum epoch duration", value: "7d" }], + ], + [ + ["Upgrade", Buffer.from("abcd", "hex")], + "propose-code-upgrade", + [{ label: "Wasm hash", value: "abcd" }], + ], + [ + ["UpgradeDelay", 1_209_600n], + "propose-upgrade-delay", + [{ label: "Upgrade delay", value: "14d" }], + ], + [ + ["GrantRole", "manager", account], + "propose-grant-role", + [ + { label: "Role", value: "manager" }, + { label: "Account", value: "GBRP...OX2H" }, + ], + ], + [ + ["RevokeRole", "manager", account], + "propose-revoke-role", + [ + { label: "Role", value: "manager" }, + { label: "Account", value: "GBRP...OX2H" }, + ], + ], + [ + ["Admin", account], + "propose-admin", + [{ label: "New admin", value: "GBRP...OX2H" }], + ], + ])("describes propose_change %o by its kind", (change, action, args) => { + expect( + describePending( + pending(addresses.vault, "propose_change", [change]), + addresses, + ), + ).toMatchObject({ contract: "Vault", action, args }) + }) + + it("refuses a proposed change the console does not propose", () => { + expect(() => + describePending( + pending(addresses.vault, "propose_change", [["Oracle", other]]), + addresses, + ), + ).toThrow(new InvalidPendingTransaction("unknown operation")) + }) + + it("names the pending change an apply or cancel acts on, or its id when gone", () => { + const changes = [ + { id: 3, change: { tag: "Custodian", values: [account] }, eta: 1n }, + ] + + expect( + describePending( + pending(addresses.vault, "apply_change", [3]), + addresses, + { vault: changes }, + ), + ).toMatchObject({ + action: "apply-change", + label: "Apply the change", + args: [ + { label: "Change", value: "vault change #3: Custodian GBRP...OX2H" }, + ], + }) + expect( + describePending( + pending(addresses.vault, "cancel_change", [4]), + addresses, + { vault: changes }, + ), + ).toMatchObject({ + action: "cancel-change", + label: "Cancel the change", + args: [{ label: "Change", value: "vault change #4" }], + }) + expect( + describePending(pending(addresses.vault, "apply_change", [3]), addresses) + .args, + ).toEqual([{ label: "Change", value: "vault change #3" }]) + }) + + it.each<[unknown[], string, { label: string; value: string }[]]>([ + [ + ["GrantRole", "manager", account], + "propose-token-grant-role", + [ + { label: "Role", value: "manager" }, + { label: "Account", value: "GBRP...OX2H" }, + ], + ], + [ + ["RevokeRole", "manager", account], + "propose-token-revoke-role", + [ + { label: "Role", value: "manager" }, + { label: "Account", value: "GBRP...OX2H" }, + ], + ], + [ + ["Admin", account], + "propose-token-admin", + [{ label: "New admin", value: "GBRP...OX2H" }], + ], + [ + ["Compliance", other], + "propose-compliance", + [{ label: "Compliance contract", value: "GDQN...KTL3" }], + ], + [ + ["IdentityVerifier", other], + "propose-identity-verifier", + [{ label: "Identity verifier", value: "GDQN...KTL3" }], + ], + ])( + "describes the share token's propose_change %o by its kind", + (change, action, args) => { + expect( + describePending( + pending(addresses.shareToken, "propose_change", [change]), + addresses, + ), + ).toMatchObject({ contract: "Share token", action, args }) + }, + ) + + it.each<[unknown[], string, string, { label: string; value: string }[]]>([ + [ + ["GrantRole", "attester", account], + "propose-oracle-grant-role", + "Propose a role grant", + [ + { label: "Role", value: "attester" }, + { label: "Account", value: "GBRP...OX2H" }, + ], + ], + [ + ["RevokeRole", "guardian", account], + "propose-oracle-revoke-role", + "Propose a role revoke", + [ + { label: "Role", value: "guardian" }, + { label: "Account", value: "GBRP...OX2H" }, + ], + ], + [ + ["Admin", account], + "propose-oracle-admin", + "Propose the hand-over", + [{ label: "New admin", value: "GBRP...OX2H" }], + ], + [ + ["Upgrade", Buffer.from("ab".repeat(32), "hex")], + "propose-oracle-upgrade", + "Propose an upgrade", + [{ label: "Wasm hash", value: "ab".repeat(32) }], + ], + [ + [ + "Config", + { + freshness_duration: 86_400n, + cooldown_secs: 3_600n, + max_up_bps: 500, + max_down_bps: 250, + min_answer: 500_000_000_000_000_000n, + max_answer: 2_000_000_000_000_000_000n, + }, + ], + "propose-oracle-config", + "Propose oracle limits", + [ + { label: "Freshness window", value: "1d" }, + { label: "Cooldown", value: "1h" }, + { label: "Largest rise", value: "5%" }, + { label: "Largest fall", value: "2.5%" }, + { label: "Price floor", value: "0.50" }, + { label: "Price ceiling", value: "2.00" }, + ], + ], + ])( + "describes the oracle's propose_change %o by its kind", + (change, action, label, args) => { + expect( + describePending( + pending(addresses.oracle, "propose_change", [change]), + addresses, + ), + ).toEqual({ contract: "Oracle", action, label, args }) + }, + ) + + it("names an oracle change from the oracle's own pending list", () => { + const changes = { + vault: [ + { id: 1, change: { tag: "Custodian", values: [account] }, eta: 1n }, + ], + oracle: [{ id: 1, change: { tag: "Admin", values: [other] }, eta: 1n }], + } + + expect( + describePending( + pending(addresses.oracle, "apply_change", [1]), + addresses, + changes, + ), + ).toEqual({ + contract: "Oracle", + action: "apply-oracle-change", + label: "Apply the change", + args: [ + { + label: "Change", + value: "oracle change #1: Admin hand-over GDQN...KTL3", + }, + ], + }) + expect( + describePending( + pending(addresses.oracle, "cancel_change", [4]), + addresses, + changes, + ), + ).toEqual({ + contract: "Oracle", + action: "cancel-oracle-change", + label: "Cancel the change", + args: [{ label: "Change", value: "oracle change #4" }], + }) + }) + + it("refuses a proposed change the other contract owns", () => { + for (const change of [ + ["Custodian", account], + ["Upgrade", Buffer.from("abcd", "hex")], + ]) + expect(() => + describePending( + pending(addresses.shareToken, "propose_change", [change]), + addresses, + ), + ).toThrow(new InvalidPendingTransaction("unknown operation")) + expect(() => + describePending( + pending(addresses.vault, "propose_change", [["Compliance", other]]), + addresses, + ), + ).toThrow(new InvalidPendingTransaction("unknown operation")) + }) + + it("names a share-token change from the token's own pending list", () => { + const changes = { + vault: [ + { id: 1, change: { tag: "Custodian", values: [account] }, eta: 1n }, + ], + token: [ + { id: 1, change: { tag: "Compliance", values: [other] }, eta: 1n }, + ], + } + + expect( + describePending( + pending(addresses.shareToken, "apply_change", [1]), + addresses, + changes, + ), + ).toMatchObject({ + contract: "Share token", + action: "apply-token-change", + label: "Apply the change", + args: [ + { + label: "Change", + value: "share token change #1: Compliance contract GDQN...KTL3", + }, + ], + }) + expect( + describePending( + pending(addresses.shareToken, "cancel_change", [4]), + addresses, + changes, + ), + ).toMatchObject({ + action: "cancel-token-change", + label: "Cancel the change", + args: [{ label: "Change", value: "share token change #4" }], + }) + expect( + describePending( + pending(addresses.vault, "apply_change", [1]), + addresses, + changes, + ).args, + ).toEqual([ + { label: "Change", value: "vault change #1: Custodian GBRP...OX2H" }, + ]) + }) - expect(args).toEqual([{ label: "Wasm hash", value: "abcd" }]) + it("no longer decodes the removed upgrade entrypoints", () => { + for (const fn of [ + "propose_upgrade", + "propose_upgrade_delay", + "apply_upgrade", + "cancel_upgrade", + ]) + expect(() => + describePending(pending(addresses.vault, fn, [account]), addresses), + ).toThrow(new InvalidPendingTransaction("unknown operation")) }) it("refuses an operation the console does not propose", () => { @@ -251,7 +661,7 @@ describe("describePending", () => { it("refuses arguments of the wrong shape", () => { expect(() => describePending( - pending(addresses.vault, "set_custodian", [12n, account]), + pending(addresses.vault, "set_custodian", [12n]), addresses, ), ).toThrow(InvalidPendingTransaction) @@ -269,6 +679,7 @@ const base: PendingStateInput = { threshold: 2, walletIsSigner: true, walletHasSigned: false, + authorization: "none", } describe("pendingState", () => { @@ -279,6 +690,9 @@ describe("pendingState", () => { ["not-signer", { walletIsSigner: false }], ["already-signed", { walletHasSigned: true }], ["ready", { weight: 2 }], + ["awaiting-authorization", { authorization: "awaiting" }], + ["authorization-expired", { authorization: "expired" }], + ["valid", { authorization: "authorized" }], ] as const)("is %s", (state, overrides) => { expect(pendingState({ ...base, ...overrides })).toBe(state) }) @@ -294,6 +708,25 @@ describe("pendingState", () => { ).toBe("valid") }) + it("waits for the admin's authorization before any envelope signature counts", () => { + expect( + pendingState({ ...base, weight: 2, authorization: "awaiting" }), + ).toBe("awaiting-authorization") + expect( + pendingState({ + ...base, + walletIsSigner: false, + authorization: "awaiting", + }), + ).toBe("awaiting-authorization") + }) + + it("ranks an expired authorization over a complete signature set", () => { + expect(pendingState({ ...base, weight: 2, authorization: "expired" })).toBe( + "authorization-expired", + ) + }) + it("ranks expiry over a complete signature set", () => { expect( pendingState({ @@ -319,6 +752,16 @@ describe("describeCollected", () => { expect(describeCollected(1, signers)).toBe("1 of 2 signatures") }) + it("counts one required signature for a single-key account", () => { + expect( + describeCollected(0, { + ...signers, + signers: [{ key: account, weight: 1 }], + thresholds: { low: 0, med: 0, high: 0 }, + }), + ).toBe("0 of 1 signatures") + }) + it("reports weight against the threshold otherwise", () => { expect( describeCollected(2, { @@ -371,6 +814,21 @@ describe("primaryAction", () => { }) }) + it("offers Authorize while the token admin's authorization is missing", () => { + expect(primaryAction("awaiting-authorization", true, true)).toEqual({ + label: "Authorize", + enabled: true, + }) + expect(primaryAction("awaiting-authorization", true, false)).toEqual({ + label: "Authorize", + enabled: false, + }) + expect(primaryAction("awaiting-authorization", false, false)).toEqual({ + label: "Connect a wallet to authorize", + enabled: false, + }) + }) + it("offers nothing in every other state", () => { for (const state of [ "expired", @@ -378,6 +836,7 @@ describe("primaryAction", () => { "executed", "not-signer", "already-signed", + "authorization-expired", ] as const) { expect(primaryAction(state, true, false)).toBeNull() } @@ -423,13 +882,34 @@ describe("toModalProps", () => { }) }) + it("names the shares, sender and receiver of a legal transfer", () => { + const description = describePending( + pending(addresses.shareToken, "legal_transfer", [ + account, + other, + 25_000_000n, + "ORDER-1", + other, + ]), + addresses, + ) + + expect( + toModalProps(description, { status: "submitted" }, handlers), + ).toMatchObject({ + action: "legal-transfer", + value: "2.50 shares from GBRP...OX2H to GDQN...KTL3", + }) + }) + it("hands the attestation price to the attest modal", () => { const description = describePending( pending(addresses.oracle, "attest", [ { nav_per_share: 1_050_000_000_000_000_000n, expires_at: 1_800_000_000n, - timestamp: 0n, + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "", }, account, ]), diff --git a/app/src/pages/sign.ts b/app/src/pages/sign.ts index 29e31904..d4f2f8a3 100644 --- a/app/src/pages/sign.ts +++ b/app/src/pages/sign.ts @@ -8,6 +8,7 @@ import { type PendingTransaction, PRICE_DECIMALS, type Price, + requiredWeight, shortAddress, uniformWeights, } from "@stellar-scaffold/app-lib" @@ -18,12 +19,14 @@ import { type TransactionModalProps, } from "../components/vault/TransactionModal" import { type TransactionStatus } from "../hooks/useContractTransaction" +import { type TimelockedContract } from "../hooks/useSetupOpen" import { exactAmount, formatDuration, formatPercent, formatSignedPrice, } from "./cycle" +import { changeName, type PendingChange, QUEUES } from "./pendingChanges" export type SignContract = "Vault" | "Oracle" | "Share token" | "Identity verifier" @@ -53,7 +56,7 @@ type Decoder = (args: unknown[]) => Decoded const malformed = () => new InvalidPendingTransaction("malformed arguments") -const address = (value: unknown): string => { +const text = (value: unknown): string => { if (typeof value !== "string") throw malformed() return value } @@ -85,7 +88,7 @@ const record = (value: unknown): Record => { const addr = (label: string, value: unknown): SignArg => ({ label, - value: shortAddress(address(value)), + value: shortAddress(text(value)), }) const amount = (label: string, value: unknown): SignArg => ({ @@ -112,11 +115,11 @@ const governed = ( ): Record => ({ grant_role: ([account, role]) => ({ action: `grant-${contract}-role`, - args: [{ label: "Role", value: address(role) }, addr("Account", account)], + args: [{ label: "Role", value: text(role) }, addr("Account", account)], }), revoke_role: ([account, role]) => ({ action: `revoke-${contract}-role`, - args: [{ label: "Role", value: address(role) }, addr("Account", account)], + args: [{ label: "Role", value: text(role) }, addr("Account", account)], }), transfer_admin_role: ([newAdmin, liveUntil]) => count(liveUntil) === 0 @@ -134,7 +137,128 @@ const governed = ( accept_admin_transfer: only(`accept-${contract}-admin`), }) -const VAULT: Record = { +const roleArgs = (role: unknown, account: unknown): SignArg[] => [ + { label: "Role", value: text(role) }, + addr("Account", account), +] + +const roleProposals = ( + grant: SignAction, + revoke: SignAction, + admin: SignAction, +): Record => ({ + GrantRole: ([role, account]) => ({ + action: grant, + args: roleArgs(role, account), + }), + RevokeRole: ([role, account]) => ({ + action: revoke, + args: roleArgs(role, account), + }), + Admin: ([account]) => ({ + action: admin, + args: [addr("New admin", account)], + }), +}) + +const VAULT_PROPOSALS: Record = { + Custodian: ([custodian]) => ({ + action: "propose-custodian", + args: [addr("Custodian", custodian)], + }), + Notice: ([secs]) => ({ + action: "propose-notice", + args: [duration("Notice", secs)], + }), + WindDownDelay: ([secs]) => ({ + action: "propose-wind-down-delay", + args: [duration("Wind-down delay", secs)], + }), + MinEpochDuration: ([secs]) => ({ + action: "propose-min-epoch-duration", + args: [duration("Minimum epoch duration", secs)], + }), + Upgrade: ([hash]) => ({ + action: "propose-code-upgrade", + args: [{ label: "Wasm hash", value: hex(hash) }], + }), + UpgradeDelay: ([secs]) => ({ + action: "propose-upgrade-delay", + args: [duration("Upgrade delay", secs)], + }), + ...roleProposals( + "propose-grant-role", + "propose-revoke-role", + "propose-admin", + ), +} + +const TOKEN_PROPOSALS: Record = { + Compliance: ([compliance]) => ({ + action: "propose-compliance", + args: [addr("Compliance contract", compliance)], + }), + IdentityVerifier: ([verifier]) => ({ + action: "propose-identity-verifier", + args: [addr("Identity verifier", verifier)], + }), + ...roleProposals( + "propose-token-grant-role", + "propose-token-revoke-role", + "propose-token-admin", + ), +} + +const decoderOf = (table: Record, name: unknown): Decoder => { + if (typeof name !== "string" || !Object.hasOwn(table, name)) + throw new InvalidPendingTransaction("unknown operation") + return table[name] as Decoder +} + +const proposal = + (table: Record): Decoder => + ([change]) => { + if (!Array.isArray(change)) throw malformed() + const [tag, ...values] = change as unknown[] + return decoderOf(table, tag)(values) + } + +export type SignChanges = Partial> + +const byId = + ( + action: SignAction, + contract: TimelockedContract, + changes: SignChanges, + ): Decoder => + ([id]) => { + const { name } = QUEUES[contract] + const known = changes[contract]?.find((change) => change.id === count(id)) + return { + action, + args: [ + { + label: "Change", + value: + known === undefined + ? `${name} change #${count(id)}` + : changeName(name, known), + }, + ], + } + } + +const timelockDecoders = ( + contract: TimelockedContract, + proposals: Record, + changes: SignChanges, +): Record => ({ + propose_change: proposal(proposals), + apply_change: byId(QUEUES[contract].apply, contract, changes), + cancel_change: byId(QUEUES[contract].cancel, contract, changes), +}) + +const vaultDecoders = (changes: SignChanges): Record => ({ ...governed("vault"), pause: only("pause-vault"), unpause: only("unpause-vault"), @@ -159,16 +283,11 @@ const VAULT: Record = { action: "set-wind-down-delay", args: [duration("Wind-down delay", secs)], }), - propose_upgrade: ([hash]) => ({ - action: "propose-code-upgrade", - args: [{ label: "Wasm hash", value: hex(hash) }], - }), - propose_upgrade_delay: ([secs]) => ({ - action: "propose-upgrade-delay", - args: [duration("Upgrade delay", secs)], + set_min_epoch_duration: ([secs]) => ({ + action: "set-min-epoch-duration", + args: [duration("Minimum epoch duration", secs)], }), - apply_upgrade: only("apply-upgrade"), - cancel_upgrade: only("cancel-upgrade"), + ...timelockDecoders("vault", VAULT_PROPOSALS, changes), propose_wind_down: only("propose-wind-down"), cancel_wind_down_proposal: only("cancel-wind-down"), close_epoch: only("close-epoch"), @@ -176,28 +295,39 @@ const VAULT: Record = { action: "deploy", args: [amount("Amount", assets)], }), -} +}) const oracleLimits = (config: Record): SignArg[] => [ duration("Freshness window", config.freshness_duration), duration("Cooldown", config.cooldown_secs), { label: "Largest rise", value: formatPercent(count(config.max_up_bps)) }, - { - label: "Largest fall", - value: - config.max_down_bps === null - ? "No limit" - : formatPercent(count(config.max_down_bps)), - }, + { label: "Largest fall", value: formatPercent(count(config.max_down_bps)) }, price("Price floor", config.min_answer), price("Price ceiling", config.max_answer), ] -const ORACLE: Record = { +const ORACLE_PROPOSALS: Record = { + Upgrade: ([hash]) => ({ + action: "propose-oracle-upgrade", + args: [{ label: "Wasm hash", value: hex(hash) }], + }), + Config: ([config]) => ({ + action: "propose-oracle-config", + args: oracleLimits(record(config)), + }), + ...roleProposals( + "propose-oracle-grant-role", + "propose-oracle-revoke-role", + "propose-oracle-admin", + ), +} + +const oracleDecoders = (changes: SignChanges): Record => ({ ...governed("oracle"), - raise_ripcord: only("raise-ripcord"), - set_ripcord: ([paused]) => ({ - action: flag(paused) ? "raise-ripcord" : "lower-ripcord", + ...timelockDecoders("oracle", ORACLE_PROPOSALS, changes), + pause_nav: only("pause-nav"), + set_nav_paused: ([paused]) => ({ + action: flag(paused) ? "pause-nav" : "unpause-nav", args: [], }), clear_latest: only("clear-latest"), @@ -205,8 +335,9 @@ const ORACLE: Record = { action: "set-oracle-config", args: oracleLimits(record(config)), }), - attest: ([report]) => { - const { nav_per_share, expires_at } = record(report) + attest: ([input]) => { + const { nav_per_share, expires_at, proof_hash, proof_uri } = record(input) + const uri = text(proof_uri) return { action: "attest", args: [ @@ -215,13 +346,16 @@ const ORACLE: Record = { value: formatSignedPrice(integer(nav_per_share) as Price), }, { label: "Expires", value: formatDate(integer(expires_at)) }, + { label: "Proof hash", value: hex(proof_hash) }, + { label: "Proof URI", value: uri === "" ? "None" : uri }, ], } }, -} +}) -const TOKEN: Record = { +const tokenDecoders = (changes: SignChanges): Record => ({ ...governed("token"), + ...timelockDecoders("token", TOKEN_PROPOSALS, changes), pause: only("pause-token"), unpause: only("unpause-token"), set_compliance: ([compliance]) => ({ @@ -244,11 +378,17 @@ const TOKEN: Record = { action: "unfreeze-shares", args: [addr("Address", account), amount("Amount", shares)], }), - forced_transfer: ([from, to, shares]) => ({ - action: "force-transfer", - args: [addr("From", from), addr("To", to), amount("Amount", shares)], + legal_transfer: ([from, to, shares, reference, caller]) => ({ + action: "legal-transfer", + args: [ + addr("From", from), + addr("To", to), + amount("Amount", shares), + { label: "Reference", value: text(reference) }, + addr("Caller", caller), + ], }), -} +}) const VERIFIER: Record = { allow: ([account, allowed]) => ({ @@ -268,12 +408,14 @@ const VERBS: Record = { ...ADMIN_VERBS, ...CYCLE_VERBS } const contractOf = ( contractId: string, addresses: SignAddresses, + changes: SignChanges, ): { contract: SignContract; table: Record } => { - if (contractId === addresses.vault) return { contract: "Vault", table: VAULT } + if (contractId === addresses.vault) + return { contract: "Vault", table: vaultDecoders(changes) } if (contractId === addresses.oracle) - return { contract: "Oracle", table: ORACLE } + return { contract: "Oracle", table: oracleDecoders(changes) } if (contractId === addresses.shareToken) - return { contract: "Share token", table: TOKEN } + return { contract: "Share token", table: tokenDecoders(changes) } if (contractId === addresses.identityVerifier) { return { contract: "Identity verifier", table: VERIFIER } } @@ -283,13 +425,10 @@ const contractOf = ( export function describePending( pending: PendingTransaction, addresses: SignAddresses, + changes: SignChanges = {}, ): SignDescription { - const { contract, table } = contractOf(pending.contractId, addresses) - const decode = table[pending.fn] - if (decode === undefined) { - throw new InvalidPendingTransaction("unknown operation") - } - const { action, args } = decode(pending.args) + const { contract, table } = contractOf(pending.contractId, addresses, changes) + const { action, args } = decoderOf(table, pending.fn)(pending.args) const verb = VERBS[action] return { contract, @@ -307,6 +446,10 @@ export type PendingState = | "not-signer" | "already-signed" | "ready" + | "awaiting-authorization" + | "authorization-expired" + +export type AuthorizationState = "none" | "awaiting" | "expired" | "authorized" export type PendingStateInput = { now: Date @@ -317,6 +460,7 @@ export type PendingStateInput = { threshold: number walletIsSigner: boolean | null walletHasSigned: boolean + authorization: AuthorizationState } export function pendingState(input: PendingStateInput): PendingState { @@ -327,6 +471,8 @@ export function pendingState(input: PendingStateInput): PendingState { ) { return "consumed" } + if (input.authorization === "expired") return "authorization-expired" + if (input.authorization === "awaiting") return "awaiting-authorization" if (input.weight >= input.threshold) return "ready" if (input.walletHasSigned) return "already-signed" if (input.walletIsSigner === false) return "not-signer" @@ -341,6 +487,9 @@ export const STATE_COPY: Record = { "not-signer": "Your wallet does not sign for this account.", "already-signed": "You already signed. Share the link with the next signer.", ready: "Signatures complete. Anyone can submit.", + "awaiting-authorization": "Waiting for the token admin's authorization.", + "authorization-expired": + "The token admin's authorization expired. The link must be rebuilt.", } export type StateTone = "neutral" | "success" | "danger" | "muted" @@ -398,10 +547,28 @@ export const STATE_VIEW: Record = { nextStep: "Only a signer of this account can sign.", shareable: true, }, + "awaiting-authorization": { + pill: "Awaiting the admin's authorization", + tone: "neutral", + nextStep: + "The token admin authorizes first. Then compliance's signers sign.", + shareable: true, + }, + "authorization-expired": { + pill: "Authorization expired", + tone: "danger", + nextStep: "Ask compliance to build the legal transfer again.", + shareable: false, + }, } export type PrimaryAction = { - label: "Sign" | "Submit" | "Connect a wallet to sign" + label: + | "Sign" + | "Submit" + | "Authorize" + | "Connect a wallet to sign" + | "Connect a wallet to authorize" enabled: boolean } @@ -411,6 +578,10 @@ export const primaryAction = ( canSign: boolean, ): PrimaryAction | null => { if (state === "ready") return { label: "Submit", enabled: true } + if (state === "awaiting-authorization") + return hasWallet + ? { label: "Authorize", enabled: canSign } + : { label: "Connect a wallet to authorize", enabled: false } if (state !== "valid") return null return hasWallet ? { label: "Sign", enabled: canSign } @@ -434,7 +605,7 @@ export const describeCollected = ( weight: number, signers: AccountSigners, ): string => { - const threshold = signers.thresholds.med + const threshold = requiredWeight(signers) return uniformWeights(signers.signers) ? `${weight} of ${threshold} signatures` : `weight ${weight} of threshold ${threshold}` @@ -445,8 +616,8 @@ type ModalHandlers = { onClose: () => void; onRetry: () => void } const EMERGENCY_ACTIONS: ReadonlySet = new Set([ "pause-vault", "unpause-vault", - "raise-ripcord", - "lower-ripcord", + "pause-nav", + "unpause-nav", "clear-latest", "pause-token", "unpause-token", @@ -481,6 +652,15 @@ export function toModalProps( } case "deploy": return { action, status, amount: args[0]?.value ?? "", ...handlers } + case "legal-transfer": { + const [from, to, shares] = args.map(({ value }) => value) + return { + action, + status, + value: `${shares ?? ""} shares from ${from ?? ""} to ${to ?? ""}`, + ...handlers, + } + } default: if (isEmergency(action)) return { action, status, ...handlers } return { diff --git a/app/src/pages/timelock.ts b/app/src/pages/timelock.ts new file mode 100644 index 00000000..320e8598 --- /dev/null +++ b/app/src/pages/timelock.ts @@ -0,0 +1,31 @@ +import { type Condition } from "./actions" +import { formatDuration } from "./cycle" + +export type Route = "direct" | "timelock" | null + +const SETUP_OPEN = "Setup is open" + +export const routeCondition = ( + route: Route, + delay: bigint | null, + atOnce = SETUP_OPEN, +): Condition => { + if (route === null) return { label: "Setup state read", met: null } + if (route === "direct") + return { label: "Applies at once", met: true, detail: atOnce } + return { + label: "Waits for the timelock", + met: true, + detail: delay === null ? "Upgrade delay" : formatDuration(delay), + } +} + +export const routeOf = ( + setupOpen: boolean | null, + waits: boolean | undefined, +): Route => { + if (setupOpen === null) return null + if (setupOpen) return "direct" + if (waits === undefined) return null + return waits ? "timelock" : "direct" +} diff --git a/app/src/pages/vaultAccess.test.ts b/app/src/pages/vaultAccess.test.ts index 1b4fb50f..b45bb060 100644 --- a/app/src/pages/vaultAccess.test.ts +++ b/app/src/pages/vaultAccess.test.ts @@ -298,6 +298,7 @@ describe("isSubscriptionOpen", () => { amount: 100_0000000n as Amount, claimed: false, priceableAt: 0n, + closedAt: 0n, }, ], archived: [], @@ -318,6 +319,7 @@ describe("isSubscriptionOpen", () => { amount: 500_0000000n as Amount, claimed: false, priceableAt: 0n, + closedAt: 0n, }, ], archived: [], @@ -338,6 +340,7 @@ describe("isSubscriptionOpen", () => { amount: 100_0000000n as Amount, claimed: false, priceableAt: 1_700_003_600n, + closedAt: 0n, }, ], archived: [], diff --git a/app/src/pages/vaultMetrics.test.ts b/app/src/pages/vaultMetrics.test.ts index 828cc38a..cbf6cc4c 100644 --- a/app/src/pages/vaultMetrics.test.ts +++ b/app/src/pages/vaultMetrics.test.ts @@ -37,21 +37,21 @@ const readAuthorities: VaultAuthorities = { describe("toMetrics", () => { it("renders the three liquidity metrics from readable figures, using the reported token symbol", () => { - const [liquidReserve, committed, uncovered] = toMetrics( + const [withdrawable, committed, uncovered] = toMetrics( readFigures, false, "USDC", ) - expect(liquidReserve).toEqual({ - label: "Liquid reserve", + expect(withdrawable).toEqual({ + label: "Withdrawable", value: "18,400.00", - note: "USDC the vault holds now", + note: "USDC held now, less cancellable deposits", }) expect(committed).toEqual({ label: "Committed", value: "6,200.00", - note: "USDC owed on priced claims", + note: "USDC owed to priced redemptions", }) expect(uncovered).toEqual({ label: "Uncovered · vault", @@ -266,6 +266,20 @@ describe("toEstimate", () => { }) describe("toSizeFigures", () => { + it("defines economic supply in one line, also while loading", () => { + const economicSupplyNote = + "Shares outside the vault, plus priced deposits not yet claimed" + const [economicSupply] = toSizeFigures(readFigures, false) + const [pendingEconomicSupply] = toSizeFigures(undefined, true) + + expect(economicSupply).toEqual({ + label: "Economic supply", + value: "1,000,000.00", + note: economicSupplyNote, + }) + expect(pendingEconomicSupply!.note).toBe(economicSupplyNote) + }) + it("renders net deployed of zero, distinguishable from unavailable", () => { const [, netDeployed] = toSizeFigures(readFigures, false) expect(netDeployed).toEqual({ label: "Net deployed", value: "0.00" }) diff --git a/app/src/pages/vaultMetrics.ts b/app/src/pages/vaultMetrics.ts index 1910416e..b9ad45cc 100644 --- a/app/src/pages/vaultMetrics.ts +++ b/app/src/pages/vaultMetrics.ts @@ -43,14 +43,14 @@ export function toMetrics( return [ metric( - "Liquid reserve", + "Withdrawable", figures?.liquidReserve ?? null, - `${tokenSymbol} the vault holds now`, + `${tokenSymbol} held now, less cancellable deposits`, ), metric( "Committed", figures?.committed ?? null, - `${tokenSymbol} owed on priced claims`, + `${tokenSymbol} owed to priced redemptions`, ), metric("Uncovered · vault", uncovered, uncoveredNote(uncovered)), ] @@ -159,16 +159,25 @@ export function toSizeFigures( figures: VaultFigures | undefined, isPending: boolean, ): FigureRow[] { - const figure = (label: string, raw: Amount | null): FigureRow => + const figure = ( + label: string, + raw: Amount | null, + note?: string, + ): FigureRow => isPending - ? { label, value: null, pending: true } + ? { label, value: null, note, pending: true } : { label, value: raw === null ? null : formatScaled(raw, AMOUNT_DECIMALS), + note, } return [ - figure("Economic supply", figures?.economicSupply ?? null), + figure( + "Economic supply", + figures?.economicSupply ?? null, + "Shares outside the vault, plus priced deposits not yet claimed", + ), figure("Net deployed", figures?.netDeployed ?? null), ] } diff --git a/app/src/pages/vaultRequests.test.ts b/app/src/pages/vaultRequests.test.ts index fad4d5f3..a825a746 100644 --- a/app/src/pages/vaultRequests.test.ts +++ b/app/src/pages/vaultRequests.test.ts @@ -4,16 +4,22 @@ import { type Price, } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" +import { HELD_WHILE_FROZEN } from "../components/vault/TransactionModal" import { type ArchivedRequest, type InvestorRequest, type UnreadableRequest, } from "../hooks/useInvestorRequests" import { type Allowance } from "../hooks/useIsAllowed" +import { type NavClassification } from "../hooks/useNavPrice" +import { type PauseState } from "../hooks/useVaultPaused" import { - allowlistRefusalFor, assignStage, + outlookFor, + type HolderInputs, toRequestEntriesByStage, + toWindDownEntry, + type WindDownClaimInput, } from "./vaultRequests" const amount = (value: bigint): Amount => value as Amount @@ -33,6 +39,7 @@ const deposit: InvestorRequest = { amount: amount(100_0000000n), claimed: false, priceableAt: 1_700_003_600n, + closedAt: 1_700_000_000n, } const redeem: InvestorRequest = { @@ -43,6 +50,7 @@ const redeem: InvestorRequest = { amount: amount(100_0000000n), claimed: false, priceableAt: 1_700_003_600n, + closedAt: 1_700_000_000n, } describe("assignStage", () => { @@ -57,17 +65,17 @@ describe("assignStage", () => { expect(assignStage(deposit, undefined)).toBe("ready") }) - it("puts a priced, refused request in blocked", () => { - expect(assignStage(deposit, { reason: "The vault is paused." })).toBe( - "blocked", - ) + it("puts a priced, held request in blocked", () => { + expect( + assignStage(deposit, { held: HELD_WHILE_FROZEN, cancellable: false }), + ).toBe("blocked") }) - it("puts an unpriced epoch in waiting even when a refusal is also present", () => { + it("puts an unpriced, held request in blocked rather than waiting", () => { const unpriced = { ...deposit, epochStatus: open } - expect(assignStage(unpriced, { reason: "The vault is paused." })).toBe( - "waiting", - ) + expect( + assignStage(unpriced, { held: HELD_WHILE_FROZEN, cancellable: false }), + ).toBe("blocked") }) it("puts a fulfilled epoch with an invalid price in blocked", () => { @@ -76,30 +84,108 @@ describe("assignStage", () => { }) }) -describe("allowlistRefusalFor", () => { - it("refuses a deposit request when the address is not on the allowlist", () => { - expect(allowlistRefusalFor("not-allowed")(deposit)).toEqual({ - reason: - "This address is not on the vault's allowlist, so it cannot receive shares right now.", - }) +const validNav = (attestedAt: bigint): NavClassification => ({ + status: "valid", + price: price(1_000_000_000_000_000_000n), + attestedAt, +}) + +const holder = (overrides: Partial = {}): HolderInputs => ({ + allowance: "allowed", + frozen: false, + windingDown: false, + pause: "open", + nav: validNav(1_700_000_100n), + ...overrides, +}) + +describe("outlookFor", () => { + const sealed: InvestorRequest = { ...deposit, epochStatus: pending } + const sealedRedeem: InvestorRequest = { ...redeem, epochStatus: pending } + + it("holds every request of a frozen holder with the frozen copy, and offers no cancel", () => { + for (const request of [deposit, redeem, sealed, sealedRedeem]) { + expect(outlookFor(holder({ frozen: true }))(request)).toEqual({ + held: HELD_WHILE_FROZEN, + cancellable: false, + }) + } }) - it("does not refuse a deposit request when the address is allowed", () => { - expect(allowlistRefusalFor("allowed")(deposit)).toBeUndefined() + it("tells a delisted holder that claiming queues the shares as a redemption", () => { + const outlook = outlookFor(holder({ allowance: "not-allowed" }))(deposit) + + expect(outlook.held).toBeUndefined() + expect(outlook.claimNote).toMatch(/not allowlisted/) + expect(outlook.claimNote).toMatch(/queued as a redemption/) }) - it.each([ - ["checking", "checking" as Allowance], - ["unreadable", "unreadable" as Allowance], + it("tells a delisted holder in a wind-down that claiming moves the shares into the wind-down claim", () => { + const outlook = outlookFor( + holder({ allowance: "not-allowed", windingDown: true }), + )(deposit) + + expect(outlook.claimNote).toMatch(/wind-down claim/) + }) + + it.each<[string, Allowance]>([ + ["allowed", "allowed"], + ["checking", "checking"], + ["unreadable", "unreadable"], ])( - "does not refuse a deposit request while the allowlist read is %s, rather than guessing", + "adds no claim note while the allowlist read is %s", (_label, allowance) => { - expect(allowlistRefusalFor(allowance)(deposit)).toBeUndefined() + expect( + outlookFor(holder({ allowance }))(deposit).claimNote, + ).toBeUndefined() }, ) - it("never refuses a redemption request, since claim_deposit is deposit-only", () => { - expect(allowlistRefusalFor("not-allowed")(redeem)).toBeUndefined() + it("adds no claim note to a delisted holder's redemption, which pays out as usual", () => { + expect( + outlookFor(holder({ allowance: "not-allowed" }))(redeem).claimNote, + ).toBeUndefined() + }) + + it("offers cancel on an open epoch, which has no price to read", () => { + expect( + outlookFor(holder())({ ...deposit, epochStatus: open }).cancellable, + ).toBe(true) + }) + + it("hides cancel on a sealed epoch once a price attested after its close is readable", () => { + expect(outlookFor(holder())(sealed).cancellable).toBe(false) + }) + + it.each<[string, Partial]>([ + ["the vault is paused", { pause: "paused" as PauseState }], + ["the NAV is paused", { nav: { status: "paused" } }], + ["the feed is stale", { nav: { status: "stale", expiresAt: 1n } }], + ["no price was ever attested", { nav: { status: "never" } }], + [ + "the price was attested before the close", + { nav: validNav(1_699_999_999n) }, + ], + ["a wind-down is active", { windingDown: true }], + ["the price cannot be read", { nav: undefined }], + ])("offers cancel on a sealed epoch when %s", (_label, overrides) => { + expect(outlookFor(holder(overrides))(sealed).cancellable).toBe(true) + }) + + it("hides cancel on a delisted holder's redemption outside a wind-down, since the shares cannot come back", () => { + const delisted = outlookFor(holder({ allowance: "not-allowed" })) + + expect(delisted({ ...redeem, epochStatus: open }).cancellable).toBe(false) + expect(delisted({ ...deposit, epochStatus: open }).cancellable).toBe(true) + }) + + it("offers cancel on a delisted holder's redemption in a wind-down, which moves it into the wind-down claim", () => { + expect( + outlookFor(holder({ allowance: "not-allowed", windingDown: true }))({ + ...redeem, + epochStatus: open, + }).cancellable, + ).toBe(true) }) }) @@ -219,7 +305,7 @@ describe("toRequestEntriesByStage", () => { const result = toRequestEntriesByStage( { requests: [redeem], archived: [], unreadable: [] }, tokens, - () => ({ reason: "This address is not on the vault's allowlist." }), + { outlookFor: () => ({ held: HELD_WHILE_FROZEN, cancellable: false }) }, ) expect(result.blocked).toEqual([ @@ -230,12 +316,12 @@ describe("toRequestEntriesByStage", () => { outLabel: "Owed to you", outAmount: "200.00 USDC", outTone: "stop", - state: "Blocked", + state: "Held", tone: "blocked", actions: [], tooltip: { - label: "Why you cannot claim this yet", - text: "This address is not on the vault's allowlist.", + label: "Why this request is held", + text: HELD_WHILE_FROZEN, }, }, ]) @@ -349,8 +435,7 @@ describe("toRequestEntriesByStage", () => { const result = toRequestEntriesByStage( { requests: [waiting], archived: [], unreadable: [] }, tokens, - undefined, - onCancel, + { onCancel }, ) expect(result.waiting[0]?.actions).toEqual([ @@ -364,8 +449,7 @@ describe("toRequestEntriesByStage", () => { const result = toRequestEntriesByStage( { requests: [waiting], archived: [], unreadable: [] }, tokens, - undefined, - onCancel, + { onCancel }, ) result.waiting[0]?.actions[0]?.onPress() @@ -379,8 +463,7 @@ describe("toRequestEntriesByStage", () => { const result = toRequestEntriesByStage( { requests: [waiting], archived: [], unreadable: [] }, tokens, - undefined, - onCancel, + { onCancel }, ) expect(result.waiting[0]?.actions).toEqual([ @@ -394,8 +477,7 @@ describe("toRequestEntriesByStage", () => { const result = toRequestEntriesByStage( { requests: [waiting], archived: [], unreadable: [] }, tokens, - undefined, - onCancel, + { onCancel }, ) result.waiting[0]?.actions[0]?.onPress() @@ -407,8 +489,7 @@ describe("toRequestEntriesByStage", () => { const result = toRequestEntriesByStage( { requests: [deposit], archived: [], unreadable: [] }, tokens, - undefined, - () => {}, + { onCancel: () => {} }, ) expect(result.ready[0]?.actions).toEqual([]) @@ -428,9 +509,7 @@ describe("toRequestEntriesByStage", () => { const result = toRequestEntriesByStage( { requests: [deposit], archived: [], unreadable: [] }, tokens, - undefined, - undefined, - onClaim, + { onClaim }, ) expect(result.ready[0]?.actions).toEqual([ @@ -443,9 +522,7 @@ describe("toRequestEntriesByStage", () => { const result = toRequestEntriesByStage( { requests: [deposit], archived: [], unreadable: [] }, tokens, - undefined, - undefined, - onClaim, + { onClaim }, ) result.ready[0]?.actions[0]?.onPress() @@ -458,9 +535,7 @@ describe("toRequestEntriesByStage", () => { const result = toRequestEntriesByStage( { requests: [redeem], archived: [], unreadable: [] }, tokens, - undefined, - undefined, - onClaim, + { onClaim }, ) expect(result.ready[0]?.actions).toEqual([ @@ -468,6 +543,53 @@ describe("toRequestEntriesByStage", () => { ]) }) + it("moves a held, unpriced request out of waiting into blocked, with no cancel action", () => { + const waiting: InvestorRequest = { ...deposit, epochStatus: open } + const result = toRequestEntriesByStage( + { requests: [waiting], archived: [], unreadable: [] }, + tokens, + { + outlookFor: () => ({ held: HELD_WHILE_FROZEN, cancellable: false }), + onCancel: () => {}, + }, + ) + + expect(result.waiting).toEqual([]) + expect(result.blocked[0]?.state).toBe("Held") + expect(result.blocked[0]?.outAmount).toBe("Not yet priced") + expect(result.blocked[0]?.actions).toEqual([]) + }) + + it("offers no cancel action on a waiting request whose cancellation window is closed", () => { + const waiting: InvestorRequest = { ...deposit, epochStatus: pending } + const result = toRequestEntriesByStage( + { requests: [waiting], archived: [], unreadable: [] }, + tokens, + { outlookFor: () => ({ cancellable: false }), onCancel: () => {} }, + ) + + expect(result.waiting[0]?.actions).toEqual([]) + }) + + it("shows the outlook's claim note on a ready request instead of the default note", () => { + const result = toRequestEntriesByStage( + { requests: [deposit], archived: [], unreadable: [] }, + tokens, + { + outlookFor: () => ({ + claimNote: "Queued instead.", + cancellable: false, + }), + onClaim: () => {}, + }, + ) + + expect(result.ready[0]?.note).toBe("Queued instead.") + expect(result.ready[0]?.actions).toEqual([ + { label: "Claim", kind: "primary", onPress: expect.any(Function) }, + ]) + }) + it("places an unreadable request in blocked, distinguishable from an archived one", () => { const unreadable: UnreadableRequest = { epochId: 4n, side: "redeem" } const result = toRequestEntriesByStage( @@ -494,3 +616,57 @@ describe("toRequestEntriesByStage", () => { ]) }) }) + +describe("toWindDownEntry", () => { + const claim: Omit = { + windingDown: true, + frozen: false, + claimable: amount(25_0000000n), + shares: amount(40_0000000n), + } + + it("offers a ready wind-down claim with what it pays and the shares it turns in", () => { + const onClaim = vi.fn() + const placed = toWindDownEntry({ ...claim, tokens, onClaim }) + + expect(placed?.stage).toBe("ready") + expect(placed?.entry).toMatchObject({ + inLabel: "Wind-down claim", + inAmount: "40.00 vUSDC", + outAmount: "25.00 USDC", + state: "Claimable", + actions: [{ label: "Claim", kind: "primary" }], + }) + placed?.entry.actions[0]?.onPress() + expect(onClaim).toHaveBeenCalled() + }) + + it("shows a frozen holder's wind-down claim as held at zero, with no Claim", () => { + const placed = toWindDownEntry({ + ...claim, + frozen: true, + claimable: amount(0n), + tokens, + onClaim: () => {}, + }) + + expect(placed?.stage).toBe("blocked") + expect(placed?.entry).toMatchObject({ + outAmount: "0.00 USDC", + state: "Held", + actions: [], + tooltip: { label: "Why this request is held", text: HELD_WHILE_FROZEN }, + }) + }) + + it.each<[string, Partial]>([ + ["no wind-down is active", { windingDown: false }], + ["the wind-down state cannot be read", { windingDown: null }], + ["nothing is claimable yet", { claimable: amount(0n) }], + ["the claimable amount cannot be read", { claimable: null }], + ])("shows no wind-down claim when %s", (_label, overrides) => { + expect( + toWindDownEntry({ ...claim, ...overrides, tokens, onClaim: () => {} }), + ).toBeUndefined() + }) +}) diff --git a/app/src/pages/vaultRequests.ts b/app/src/pages/vaultRequests.ts index 1be717b2..c338ec78 100644 --- a/app/src/pages/vaultRequests.ts +++ b/app/src/pages/vaultRequests.ts @@ -11,6 +11,10 @@ import { type RequestStage, type RequestTone, } from "../components/vault/RequestCard" +import { + HELD_WHILE_FROZEN, + NOT_ALLOWLISTED, +} from "../components/vault/TransactionModal" import { type ArchivedRequest, type InvestorRequest, @@ -18,10 +22,30 @@ import { type UnreadableRequest, } from "../hooks/useInvestorRequests" import { type Allowance } from "../hooks/useIsAllowed" +import { type NavClassification } from "../hooks/useNavPrice" +import { type PauseState } from "../hooks/useVaultPaused" export type RequestTokens = { token: string; shareToken: string } -export type ClaimRefusal = { reason: string } +export type RequestOutlook = { + held?: string + claimNote?: string + cancellable: boolean +} + +export type HolderInputs = { + allowance: Allowance + frozen: boolean | null + windingDown: boolean | null + pause: PauseState + nav: NavClassification | undefined +} + +export type RequestHandlers = { + outlookFor?: (request: InvestorRequest) => RequestOutlook + onCancel?: (request: InvestorRequest) => void + onClaim?: (request: InvestorRequest) => void +} export type LoadedRequests = { requests: InvestorRequest[] @@ -69,26 +93,59 @@ const readyNote = (side: RequestSide): string => ? "Claiming is not guaranteed to succeed. Compliance is checked when you sign." : "Claiming is not guaranteed to succeed. The reserve is checked when you sign." -export function allowlistRefusalFor( - allowance: Allowance, -): (request: InvestorRequest) => ClaimRefusal | undefined { +const isPriced = (request: InvestorRequest): boolean => + request.epochStatus.tag === "Fulfilled" + +const priceReadable = ( + request: InvestorRequest, + { pause, nav }: HolderInputs, +): boolean => + request.epochStatus.tag === "Pending" && + pause === "open" && + nav?.status === "valid" && + nav.attestedAt >= request.closedAt + +const cancellable = ( + request: InvestorRequest, + inputs: HolderInputs, +): boolean => { + const windingDown = inputs.windingDown === true + if (isPriced(request)) return false + if (!windingDown && priceReadable(request, inputs)) return false + return !( + request.side === "redeem" && + inputs.allowance === "not-allowed" && + !windingDown + ) +} + +const delistedClaimNote = (windingDown: boolean): string => + windingDown + ? `${NOT_ALLOWLISTED} Claiming does not send you shares. They move into your wind-down claim instead.` + : `${NOT_ALLOWLISTED} Claiming does not send you shares. They are queued as a redemption that pays out at the next price.` + +export function outlookFor( + inputs: HolderInputs, +): (request: InvestorRequest) => RequestOutlook { return (request) => { - if (request.side !== "deposit") return undefined - if (allowance !== "not-allowed") return undefined - return { - reason: - "This address is not on the vault's allowlist, so it cannot receive shares right now.", + if (inputs.frozen === true) + return { held: HELD_WHILE_FROZEN, cancellable: false } + const outlook: RequestOutlook = { + cancellable: cancellable(request, inputs), } + if (request.side === "deposit" && inputs.allowance === "not-allowed") + outlook.claimNote = delistedClaimNote(inputs.windingDown === true) + return outlook } } export function assignStage( request: InvestorRequest, - refusal: ClaimRefusal | undefined, + outlook: RequestOutlook | undefined, ): RequestStage { - if (request.epochStatus.tag !== "Fulfilled") return "waiting" - if (!hasValidPrice(request.sharePrice)) return "blocked" - return refusal === undefined ? "ready" : "blocked" + if (outlook?.held !== undefined) return "blocked" + if (!isPriced(request)) return "waiting" + return hasValidPrice(request.sharePrice) ? "ready" : "blocked" } const baseEntry = ( @@ -144,6 +201,7 @@ function readyEntry( request: InvestorRequest, tokens: RequestTokens, onClaim: ((request: InvestorRequest) => void) | undefined, + claimNote: string | undefined, ): RequestEntry { const owed = owedAmount(request.side, request.amount, request.sharePrice) const actions = onClaim !== undefined ? [claimAction(request, onClaim)] : [] @@ -154,23 +212,29 @@ function readyEntry( outTone: "ok", state: "Priced", tone: stageTone.ready, - note: readyNote(request.side), + note: claimNote ?? readyNote(request.side), } } -function blockedEntry( +function heldEntry( request: InvestorRequest, tokens: RequestTokens, - refusal: ClaimRefusal, + reason: string, ): RequestEntry { - const owed = owedAmount(request.side, request.amount, request.sharePrice) + const owed = + isPriced(request) && hasValidPrice(request.sharePrice) + ? owedAmount(request.side, request.amount, request.sharePrice) + : null return { ...baseEntry(request, tokens), - outAmount: `${formatExact(owed, AMOUNT_DECIMALS)} ${outTicker(request.side, tokens)}`, - outTone: "stop", - state: "Blocked", + outAmount: + owed === null + ? "Not yet priced" + : `${formatExact(owed, AMOUNT_DECIMALS)} ${outTicker(request.side, tokens)}`, + outTone: owed === null ? "word" : "stop", + state: "Held", tone: stageTone.blocked, - tooltip: { label: "Why you cannot claim this yet", text: refusal.reason }, + tooltip: { label: "Why this request is held", text: reason }, } } @@ -191,20 +255,23 @@ function invalidPriceEntry( } } -export function toPresentEntry( +function toPresentEntry( request: InvestorRequest, tokens: RequestTokens, - refusal: ClaimRefusal | undefined, - onCancel?: (request: InvestorRequest) => void, - onClaim?: (request: InvestorRequest) => void, + outlook: RequestOutlook, + { onCancel, onClaim }: RequestHandlers, ): RequestEntry { - const stage = assignStage(request, refusal) - if (stage === "waiting") return waitingEntry(request, tokens, onCancel) + if (outlook.held !== undefined) + return heldEntry(request, tokens, outlook.held) + if (!isPriced(request)) + return waitingEntry( + request, + tokens, + outlook.cancellable ? onCancel : undefined, + ) if (!hasValidPrice(request.sharePrice)) return invalidPriceEntry(request, tokens) - if (stage === "blocked" && refusal !== undefined) - return blockedEntry(request, tokens, refusal) - return readyEntry(request, tokens, onClaim) + return readyEntry(request, tokens, onClaim, outlook.claimNote) } function archivedEntry(request: ArchivedRequest): RequestEntry { @@ -243,21 +310,20 @@ function unreadableEntry(request: UnreadableRequest): RequestEntry { } } +const OPEN_OUTLOOK: RequestOutlook = { cancellable: true } + export function toRequestEntriesByStage( loaded: LoadedRequests, tokens: RequestTokens, - refusalFor: (request: InvestorRequest) => ClaimRefusal | undefined = () => - undefined, - onCancel?: (request: InvestorRequest) => void, - onClaim?: (request: InvestorRequest) => void, + handlers: RequestHandlers = {}, ): EntriesByStage { const entries: EntriesByStage = { ready: [], blocked: [], waiting: [] } for (const request of loaded.requests) { if (request.claimed) continue - const refusal = refusalFor(request) - entries[assignStage(request, refusal)].push( - toPresentEntry(request, tokens, refusal, onCancel, onClaim), + const outlook = handlers.outlookFor?.(request) ?? OPEN_OUTLOOK + entries[assignStage(request, outlook)].push( + toPresentEntry(request, tokens, outlook, handlers), ) } for (const request of loaded.archived) { @@ -269,3 +335,64 @@ export function toRequestEntriesByStage( return entries } + +export type WindDownClaimInput = { + windingDown: boolean | null + frozen: boolean | null + claimable: Amount | null + shares: Amount | null + tokens: RequestTokens + onClaim?: () => void +} + +const assetAmount = (value: Amount, tokens: RequestTokens): string => + `${formatExact(value, AMOUNT_DECIMALS)} ${tokens.token}` + +export function toWindDownEntry({ + windingDown, + frozen, + claimable, + shares, + tokens, + onClaim, +}: WindDownClaimInput): + { stage: RequestStage; entry: RequestEntry } | undefined { + if (windingDown !== true || claimable === null) return undefined + const base = { + id: "wind-down", + inLabel: "Wind-down claim", + inAmount: + shares === null + ? "Unknown amount" + : `${formatExact(shares, AMOUNT_DECIMALS)} ${tokens.shareToken}`, + outLabel: "Owed to you", + outAmount: assetAmount(claimable, tokens), + } + if (frozen === true) + return { + stage: "blocked", + entry: { + ...base, + outTone: "stop", + state: "Held", + tone: stageTone.blocked, + actions: [], + tooltip: { label: "Why this request is held", text: HELD_WHILE_FROZEN }, + }, + } + if (claimable === 0n) return undefined + return { + stage: "ready", + entry: { + ...base, + outTone: "ok", + state: "Claimable", + tone: stageTone.ready, + actions: + onClaim === undefined + ? [] + : [{ label: "Claim", kind: "primary", onPress: onClaim }], + note: "Claiming turns in your unfrozen shares, and later rounds pay you for them.", + }, + } +} diff --git a/app/src/providers/WalletProvider.tsx b/app/src/providers/WalletProvider.tsx index 7313c8c2..666b901e 100644 --- a/app/src/providers/WalletProvider.tsx +++ b/app/src/providers/WalletProvider.tsx @@ -2,6 +2,7 @@ import { fetchBalances, type MappedBalances, onWalletChange, + signAuthEntry, signTransaction, } from "@stellar-scaffold/app-lib" import { createContext, useCallback, useEffect, useMemo, useState } from "react" @@ -34,6 +35,7 @@ export interface WalletContextType { isPending: boolean networkPassphrase?: string signTransaction: typeof signTransaction + signAuthEntry: typeof signAuthEntry updateBalances: () => Promise } @@ -42,6 +44,7 @@ export const WalletContext = createContext({ balances: {}, updateBalances: async () => {}, signTransaction, + signAuthEntry, }) export const WalletProvider = ({ children }: { children: React.ReactNode }) => { @@ -88,6 +91,7 @@ export const WalletProvider = ({ children }: { children: React.ReactNode }) => { updateBalances, isPending, signTransaction, + signAuthEntry, }), [address, networkPassphrase, balances, updateBalances, isPending], ) diff --git a/contracts/async-vault/Cargo.toml b/contracts/async-vault/Cargo.toml index 2da5c3d3..d1d52182 100644 --- a/contracts/async-vault/Cargo.toml +++ b/contracts/async-vault/Cargo.toml @@ -11,11 +11,13 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } -storage = { path = "../../crates/storage" } +strata-storage = { path = "../../crates/strata-storage" } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-contract-utils = { workspace = true } -bindings = { path = "../../crates/bindings" } +stellar-tokens = { workspace = true } +strata-bindings = { path = "../../crates/strata-bindings" } +strata-timelock = { path = "../../crates/strata-timelock" } [dev-dependencies] @@ -24,7 +26,11 @@ nav-oracle = { path = "../nav-oracle" } share-token = { path = "../share-token" } compliance = { path = "../compliance" } identity-verifier = { path = "../identity-verifier" } -proptest = "1" +proptest = "=1.11.0" [features] upgrade_wasm = [] +# Builds a stand-in for a later vault version, used only by the upgrade tests. +upgrade_probe = [] +# Drops the wind-down minimum to zero for the local harness. Never deployed. +short_wind_down = [] diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index b4023db6..b8da6a33 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -1,42 +1,31 @@ -use bindings::ShareClient; use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; +use stellar_tokens::rwa::RWATokenClient; -use crate::epoch; use crate::error::VaultError; -use crate::event::{DepositCancelled, DepositClaimed, DepositRequested}; +use crate::event::{ + DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRefunded, DepositRequested, +}; +use crate::guards; use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::pricing::{Pricing, PricingScheme}; -use crate::state::{self, DepositRequest, EpochStatus}; -use crate::treasury; +use crate::redeem; +use crate::settlement; +use crate::state::{self, DepositRequest}; use crate::wind_down; -fn refuse_if_exceeds_cap(e: &Env, amount: i128) { - let Some(cap) = state::deposit_cap(e) else { - return; - }; - let current = treasury::deposited_capital(e); - let new_total = current - .checked_add(amount) - .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - if new_total > cap { - panic_with_error!(e, VaultError::DepositCapExceeded); - } -} - -pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { - wind_down::refuse_if_active(e); - from.require_auth(); - - if amount <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - refuse_if_exceeds_cap(e, amount); - +pub(crate) fn request(e: &Env, holder: &Address, amount: i128) -> u64 { + guards::not_winding_down(e); + guards::no_wind_down_proposed(e); + guards::setup_finished(e); + holder.require_auth(); + guards::positive_amount(e, amount); + guards::allowed_to(e, Exit::RequestDeposit, holder); + guards::feed_valid(e); + guards::escrow_covered(e); + guards::within_cap(e, amount); let epoch_id = state::current_epoch(e); - - if state::get_deposit_request(e, epoch_id, from).is_some() { - panic_with_error!(e, VaultError::RequestOutstanding); - } + guards::no_open_deposit(e, epoch_id, holder); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -49,20 +38,24 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { state::set_deposit_request( e, epoch_id, - from, + holder, &DepositRequest { amount, claimed: false, }, ); state::set_epoch(e, epoch_id, &epoch); - state::set_cancellable_escrow(e, state::cancellable_escrow(e) + amount); + let escrow = state::cancellable_escrow(e) + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + state::set_cancellable_escrow(e, escrow); + settlement::on_deposit_requested(e, epoch_id); let asset = state::get_addr(e, &DataKey::Asset); - TokenClient::new(e, &asset).transfer(from, e.current_contract_address(), &amount); + TokenClient::new(e, &asset).transfer(holder, e.current_contract_address(), &amount); DepositRequested { - controller: from.clone(), + holder: holder.clone(), epoch: epoch_id, amount, } @@ -71,98 +64,134 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { epoch_id } -pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { - caller.require_auth(); - +pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { + holder.require_auth(); let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status != EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::EpochNotFulfilled); - } - - let mut request = state::get_deposit_request(e, epoch_id, caller) + let mut request = state::get_deposit_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - - if request.claimed { - panic_with_error!(e, VaultError::AlreadyClaimed); - } + guards::epoch_fulfilled(e, &epoch); + guards::not_claimed(e, request.claimed); + let standing = lock_policy::standing(e, holder); + let decision = lock_policy::decide(Exit::ClaimDeposit, standing, wind_down::is_active(e)); + guards::not_refused(e, decision, standing); let shares = Pricing::deposit_shares(e, request.amount, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); if shares == 0 { - // Pricing already released this epoch's escrow into the reserve, so a - // refund now is an extra payout rather than part of committed. It comes - // out of free reserve and must not reach money owed to a priced exit. - if treasury::free_reserve(e) < request.amount { - panic_with_error!(e, VaultError::ClaimNotCovered); + guards::refund_covered(e, request.amount); + return_escrow(e, holder, epoch_id, request.amount); + DepositRefunded { + holder: holder.clone(), + epoch: epoch_id, + amount: request.amount, } - return refund_deposit(e, caller, epoch_id, request.amount); + .publish(e); + settlement::on_deposit_claimed(e, epoch_id, 0); + return 0; } request.claimed = true; - state::set_deposit_request(e, epoch_id, caller, &request); - state::set_pending_mint_shares(e, state::pending_mint_shares(e).saturating_sub(shares)); + state::set_deposit_request(e, epoch_id, holder, &request); + state::set_pending_mint_shares(e, state::reduce(e, state::pending_mint_shares(e), shares)); + + if decision != Decision::Proceed { + convert_to_exit(e, holder, epoch_id, shares, standing, decision); + return 0; + } let share_token = state::get_addr(e, &DataKey::ShareToken); - ShareClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address()); + RWATokenClient::new(e, &share_token).mint(holder, &shares, &e.current_contract_address()); DepositClaimed { - controller: caller.clone(), + holder: holder.clone(), epoch: epoch_id, amount: request.amount, shares, } .publish(e); + settlement::on_deposit_claimed(e, epoch_id, shares); shares } -/// Returns the settlement asset and clears the request. Used by cancellation and -/// by a claim whose conversion floors to zero. -fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) -> i128 { - let mut epoch = state::get_epoch(e, epoch_id) - .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - epoch.total_deposited -= amount; - state::set_epoch(e, epoch_id, &epoch); - state::remove_deposit_request(e, epoch_id, controller); - - let asset = state::get_addr(e, &DataKey::Asset); - TokenClient::new(e, &asset).transfer(&e.current_contract_address(), controller, &amount); +/// The holder is off the allowlist, so the shares exit in cash: next price, or the wind-down. +fn convert_to_exit( + e: &Env, + holder: &Address, + epoch_id: u64, + shares: i128, + standing: Standing, + decision: Decision, +) { + let into_epoch = match decision { + Decision::ConvertToExit => { + let vault = e.current_contract_address(); + let share_token = state::get_addr(e, &DataKey::ShareToken); + RWATokenClient::new(e, &share_token).mint(&vault, &shares, &vault); + redeem::queue_escrowed(e, holder, shares) + } + Decision::Surrender => { + wind_down::credit(e, holder, shares); + 0 + } + Decision::Proceed | Decision::Refuse | Decision::Hold => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + }; - DepositCancelled { - controller: controller.clone(), + DepositConvertedToExit { + holder: holder.clone(), epoch: epoch_id, - amount, + into_epoch, + shares, } .publish(e); + settlement::on_deposit_claimed(e, epoch_id, shares); +} + +/// Returns the settlement asset and clears the request. Used by cancellation and +/// by a claim whose conversion floors to zero; leaves a priced epoch's total alone. +/// Publishes nothing; the caller publishes the event that fits its own reason. +fn return_escrow(e: &Env, holder: &Address, epoch_id: u64, amount: i128) { + state::remove_deposit_request(e, epoch_id, holder); - 0 + let asset = state::get_addr(e, &DataKey::Asset); + TokenClient::new(e, &asset).transfer(&e.current_contract_address(), holder, &amount); } -pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { - from.require_auth(); +/// Lowers an epoch's deposit total; only a cancellation still open for the epoch does this. +fn remove_from_epoch_total(e: &Env, epoch_id: u64, amount: i128) { + let mut epoch = state::get_epoch(e, epoch_id) + .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); + epoch.total_deposited = state::reduce(e, epoch.total_deposited, amount); + state::set_epoch(e, epoch_id, &epoch); +} +pub(crate) fn cancel(e: &Env, holder: &Address, epoch_id: u64) -> i128 { + holder.require_auth(); let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status == EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::AlreadyPriced); - } - - // Cancellation is an escape hatch, not a choice. Once the feed can price a - // sealed epoch, the price is knowable and the only way out is to take it. - // Fulfilment is closed during a wind-down, so this epoch will never take a - // price. There is nothing to decline. - if !wind_down::is_active(e) && epoch::is_priceable(e, &epoch) { - panic_with_error!(e, VaultError::PriceAvailable); - } - - let request = state::get_deposit_request(e, epoch_id, from) + let request = state::get_deposit_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); + guards::not_priced(e, &epoch); + guards::no_price_available(e, &epoch); + guards::allowed_to(e, Exit::CancelDeposit, holder); + guards::cancellation_covered(e, request.amount); - state::set_cancellable_escrow(e, state::cancellable_escrow(e) - request.amount); - refund_deposit(e, from, epoch_id, request.amount); + state::set_cancellable_escrow( + e, + state::reduce(e, state::cancellable_escrow(e), request.amount), + ); + remove_from_epoch_total(e, epoch_id, request.amount); + settlement::on_deposit_cancelled(e, epoch_id); + return_escrow(e, holder, epoch_id, request.amount); + DepositCancelled { + holder: holder.clone(), + epoch: epoch_id, + amount: request.amount, + } + .publish(e); request.amount } diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index ce915f3e..ef616692 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -1,13 +1,15 @@ -use bindings::{OracleFeedClient, OracleState}; use soroban_sdk::{panic_with_error, Env}; +use stellar_contract_utils::pausable; +use strata_bindings::{OracleFeedClient, OracleState}; use crate::error::VaultError; use crate::event::{EpochClosed, EpochFulfilled}; +use crate::guards; use crate::keys::DataKey; use crate::pricing::{Pricing, PricingScheme}; +use crate::settlement; use crate::state::{self, EpochInfo, EpochStatus}; use crate::timing::{FulfilmentTiming, StandardTiming}; -use crate::wind_down; /// The schedule this vault fulfils on. type Timing = StandardTiming; @@ -23,17 +25,21 @@ pub(crate) fn open(total_deposited: i128) -> EpochInfo { } } -/// The feed is usable and its standing valuation is no older than the close. -/// Deliberately excludes the notice: see `is_priceable`. +/// The feed is usable, its standing valuation is no older than the close, and +/// the share price resolves; a feed that fails to answer counts as no readable +/// price. Deliberately excludes the notice: see `is_priceable`. fn price_readable(e: &Env, epoch: &EpochInfo) -> Result<(), VaultError> { let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle)); - if feed.state() != OracleState::Valid { + if !matches!(feed.try_state(), Ok(Ok(OracleState::Valid))) { return Err(VaultError::FeedNotValid); } - if !Timing::price_is_current(epoch.closed_at, feed.attested_at()) { + let Ok(Ok(attested_at)) = feed.try_attested_at() else { + return Err(VaultError::FeedNotValid); + }; + if !Timing::price_is_current(epoch.closed_at, attested_at) { return Err(VaultError::AttestationBeforeClose); } - Ok(()) + Pricing::resolve_share_price(e).map(|_| ()) } /// Every condition pricing an epoch requires. It answers rather than aborting, @@ -48,22 +54,21 @@ pub(crate) fn fulfilable(e: &Env, epoch: &EpochInfo) -> Result<(), VaultError> { price_readable(e, epoch) } -/// True when the epoch's price can already be read, which is what closes the -/// cancellation window: cancelling after this would be declining a price the -/// investor has seen. +/// True when the epoch's price can already be read and the vault could take +/// it, which is what closes the cancellation window: cancelling after this +/// would be declining a price the investor has seen. A paused vault cannot +/// price, so its sealed epochs stay cancellable until the pause lifts. pub(crate) fn is_priceable(e: &Env, epoch: &EpochInfo) -> bool { - epoch.status == EpochStatus::Pending && price_readable(e, epoch).is_ok() + epoch.status == EpochStatus::Pending && !pausable::paused(e) && price_readable(e, epoch).is_ok() } pub(crate) fn close(e: &Env) -> u64 { - wind_down::refuse_if_active(e); + guards::not_winding_down(e); + guards::setup_finished(e); let current = state::current_epoch(e); let mut epoch = state::get_epoch(e, current) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status != EpochStatus::Open { - panic_with_error!(e, VaultError::EpochNotOpen); - } + guards::epoch_open(e, &epoch); let next = current .checked_add(1) @@ -73,11 +78,13 @@ pub(crate) fn close(e: &Env) -> u64 { epoch.status = EpochStatus::Pending; epoch.closed_at = closed_at; - epoch.priceable_at = Timing::priceable_at(closed_at, state::notice(e)); + epoch.priceable_at = Timing::priceable_at(closed_at, state::notice(e)) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); state::set_epoch(e, current, &epoch); state::set_epoch(e, next, &open(0)); state::set_current_epoch(e, next); + state::set_epoch_opened_at(e, closed_at); EpochClosed { epoch: current, @@ -90,13 +97,11 @@ pub(crate) fn close(e: &Env) -> u64 { } pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 { - wind_down::refuse_if_active(e); + guards::not_winding_down(e); + guards::setup_finished(e); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if let Err(refusal) = fulfilable(e, &epoch) { - panic_with_error!(e, refusal); - } + guards::fulfilable(e, &epoch); let share_price = Pricing::resolve_share_price(e).unwrap_or_else(|err| panic_with_error!(e, err)); @@ -108,8 +113,9 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 { .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); state::set_committed(e, committed); + let mut shares_owed = 0; if epoch.total_deposited > 0 { - let shares_owed = Pricing::deposit_shares(e, epoch.total_deposited, share_price) + shares_owed = Pricing::deposit_shares(e, epoch.total_deposited, share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); let updated_pending_mint = state::pending_mint_shares(e) .checked_add(shares_owed) @@ -124,7 +130,10 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 { state::set_pending_burn_shares(e, updated_pending_burn); } - state::set_cancellable_escrow(e, state::cancellable_escrow(e) - epoch.total_deposited); + state::set_cancellable_escrow( + e, + state::reduce(e, state::cancellable_escrow(e), epoch.total_deposited), + ); epoch.status = EpochStatus::Fulfilled; epoch.share_price = share_price; @@ -136,6 +145,7 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 { total_deposited: epoch.total_deposited, } .publish(e); + settlement::on_priced(e, epoch_id, shares_owed, owed); share_price } diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 9d487fff..4d827297 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -1,114 +1,189 @@ use soroban_sdk::contracterror; -/// Vault failures. Codes are stable once assigned and are never reused; a -/// removed variant leaves its number retired. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum VaultError { - /// The constructor was given the same address for two authorities that - /// must be held separately. - RolesNotDistinct = 6000, - /// The controller has no request in the given epoch. - RequestNotFound = 6001, - /// An amount or share quantity of zero or less was specified. - InvalidAmount = 6007, - /// The controller already holds an unclaimed request in this epoch. A - /// second request is rejected rather than added to the first, so that at - /// most one request per controller per epoch holds by construction. - RequestOutstanding = 6009, - /// The epoch total would exceed `i128::MAX`. - AmountTooLarge = 6014, - /// No epoch is stored under the given id. - EpochNotFound = 6029, - /// An entry the constructor writes is absent from instance storage, which - /// means the instance was archived or the contract was never constructed. - NotInitialized = 6030, - /// The oracle returned a share price of zero or less. Unreachable with a - /// correctly configured feed; kept because the oracle sits behind a - /// settable address. - InvalidSharePrice = 6031, - /// The epoch is not `Open`, so it cannot be closed or take new requests. - EpochNotOpen = 6032, - /// The epoch is not `Pending`, so it cannot be priced. An - /// epoch must be closed before it can be fulfilled. - EpochNotPending = 6038, - /// The epoch counter would exceed `u64::MAX`. - EpochOverflow = 6033, - /// The epoch has not been fulfilled yet, so it has no share price to - /// claim against. - EpochNotFulfilled = 6034, - /// The request was already claimed. Claiming is idempotent by rejection, - /// not by silently minting nothing twice. - AlreadyClaimed = 6035, - /// The vault does not hold enough to pay this claim yet. The liability - /// stands and the claim succeeds once the reserve covers it. - ClaimNotCovered = 6037, - /// The amount would deploy assets already owed to holders whose exit has - /// been priced but not yet claimed. - ReserveCommittedToExits = 6005, - /// No custodian has been set, so capital has nowhere to go. - CustodianNotSet = 6012, - /// The epoch is priced, so the request can no longer be cancelled. The - /// controller claims instead. - AlreadyPriced = 6039, - /// The vault must always have an admin. Governance hands over in two steps - /// rather than stepping down into nobody. - AdminRequired = 6040, - /// The epoch is sealed and the feed can price it, so the price it will take - /// is already knowable. Cancelling now would be declining a price after - /// seeing it. Fulfil and claim instead. - PriceAvailable = 6041, - /// The epoch closed less than its notice ago, so its price is not fixed - /// yet and every request in it is still waiting. - NoticeNotElapsed = 6042, - /// The standing valuation was accepted before the epoch closed. Pricing - /// against it would use a number the close already outran. - AttestationBeforeClose = 6043, - /// The feed is paused or stale, so the epoch has no price to take. - FeedNotValid = 6044, - /// The notice exceeds the longest the vault accepts. A notice no investor - /// could outlive would strand their exit with no way to undo it. - NoticeTooLong = 6045, - /// The vault is winding down. Entry, pricing and transfers to the custodian - /// are closed for the rest of its life. - WindDownActive = 6046, - /// No wind-down proposal stands. - WindDownNotProposed = 6047, - /// A proposal already stands. Re-proposing would let governance push the - /// activation date back indefinitely. - WindDownAlreadyProposed = 6048, - /// The announcement has not run its delay yet. - WindDownDelayNotElapsed = 6049, - /// The wind-down has not been activated, so there is nothing to distribute. - WindDownNotActive = 6050, - /// No round has been finalised, so there is no snapshot to claim against. - DistributionNotStarted = 6051, - /// The round would distribute nothing, or there is no supply to divide by. - NothingToDistribute = 6052, - /// The delay exceeds the longest the vault accepts. A delay nobody could - /// outlive would mean the vault never closes. - WindDownDelayTooLong = 6053, - /// Nothing to surrender and nothing owed. Claiming is idempotent by - /// rejection, as elsewhere in this contract. - NoEntitlement = 6054, - /// A proposal already stands. Replacing one means cancelling it first, so - /// the standing eta is never quietly moved. - UpgradeProposalExists = 6055, - /// No proposal stands. - UpgradeProposalNotFound = 6056, - /// The delay has not elapsed. - UpgradeDelayNotElapsed = 6057, - /// The delay is below the shortest the vault accepts. - UpgradeDelayTooShort = 6058, - /// The delay is shorter than the notice, so an investor could not complete - /// an exit before the change applies. - UpgradeDelayBelowNotice = 6059, - /// The delay exceeds the longest the vault accepts. - UpgradeDelayTooLong = 6060, - /// The notice exceeds the upgrade delay, so an investor could not complete - /// an exit before an upgrade lands. - NoticeAboveUpgradeDelay = 6061, - /// A deposit request would breach the vault's configured deposit cap. - DepositCapExceeded = 6062, +strata_bindings::error_enum! { + strata_bindings::VAULT_ERRORS, + + /// Vault failures, coded 6000 to 6999. Codes are stable once assigned and + /// are never reused; a removed variant leaves its number retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum VaultError { + /// The constructor was given the same address for two authorities that + /// must be held separately. + RolesNotDistinct = 6000, + /// The holder has no request in the given epoch. + RequestNotFound = 6001, + /// An amount or share quantity of zero or less was specified, a + /// negative deposit cap, or a minimum epoch duration outside 1 to 30 + /// days. + InvalidAmount = 6007, + /// The holder already has an unclaimed request in this epoch. A + /// second request is rejected rather than added to the first, so that at + /// most one request per holder per epoch holds by construction. + RequestOutstanding = 6009, + /// A sum, count or conversion would not fit its type: a running total or + /// a conversion of assets or shares past `i128`, an epoch's count of + /// open requests past `u32`, or the end of an epoch's notice past + /// `u64`. + AmountTooLarge = 6014, + /// No epoch is stored under the given id. + EpochNotFound = 6029, + /// An entry the constructor writes is missing from instance storage. A + /// constructed vault always has it. + NotInitialized = 6030, + /// The oracle returned a share price of zero or less. Unreachable with a + /// correctly configured feed; kept because the oracle sits behind a + /// settable address. + InvalidSharePrice = 6031, + /// The epoch is not `Open`, so it cannot be closed or take new requests. + EpochNotOpen = 6032, + /// The epoch is not `Pending`, so it cannot be priced. An + /// epoch must be closed before it can be fulfilled. + EpochNotPending = 6038, + /// The epoch counter would exceed `u64::MAX`. + EpochOverflow = 6033, + /// The epoch has not been fulfilled yet, so it has no share price to + /// claim against. + EpochNotFulfilled = 6034, + /// The request was already claimed. Claiming is idempotent by rejection, + /// not by silently minting nothing twice. + AlreadyClaimed = 6035, + /// The vault does not hold enough to pay this claim yet. The liability + /// stands and the claim succeeds once the reserve covers it. + ClaimNotCovered = 6037, + /// The amount would deploy assets that are not free: escrow depositors + /// can still cancel, or cash owed to holders whose exit has been priced + /// but not yet claimed. + ReserveCommittedToExits = 6005, + /// No custodian has been set, so capital has nowhere to go. + CustodianNotSet = 6012, + /// The epoch is priced, so the request can no longer be cancelled. The + /// holder claims instead. + AlreadyPriced = 6039, + /// The call needs the admin. `unpause` refuses any other caller. + /// `renounce_admin` is always refused, because the vault must always have + /// an admin: governance hands over in two steps rather than stepping down + /// into nobody. + AdminRequired = 6040, + /// The epoch is sealed and the feed can price it, so the price it will take + /// is already knowable. Cancelling now would be declining a price after + /// seeing it. Fulfil and claim instead. + PriceAvailable = 6041, + /// The epoch closed less than its notice ago, so its price is not fixed + /// yet and every request in it is still waiting. + NoticeNotElapsed = 6042, + /// The standing valuation was accepted before the epoch closed. Pricing + /// against it would use a number the close already outran. + AttestationBeforeClose = 6043, + /// The feed is paused, stale or fails to answer, so no epoch can be + /// priced from it. While it is paused or stale, no deposit is taken. + FeedNotValid = 6044, + /// The notice exceeds the longest the vault accepts. A notice no investor + /// could outlive would strand their exit with no way to undo it. + NoticeTooLong = 6045, + /// The vault is winding down. New requests, epoch closes, pricing, + /// transfers to the custodian, wind-down proposals, admin hand-overs + /// and timelocked changes are closed for the rest of its life. + WindDownActive = 6046, + /// No wind-down proposal stands. + WindDownNotProposed = 6047, + /// A proposal already stands. Re-proposing would let governance push the + /// activation date back indefinitely. While it stands, the vault takes + /// no new deposit. + WindDownAlreadyProposed = 6048, + /// The announcement has not run its delay yet. + WindDownDelayNotElapsed = 6049, + /// The wind-down has not been activated, so there is nothing to distribute. + WindDownNotActive = 6050, + /// No round has been finalised, so there is no snapshot to claim against. + DistributionNotStarted = 6051, + /// The round would distribute nothing, or there is no supply to divide by. + NothingToDistribute = 6052, + /// The delay exceeds the longest the vault accepts. A delay nobody could + /// outlive would mean the vault never closes. + WindDownDelayTooLong = 6053, + /// Nothing to surrender and nothing owed, or the holder is the vault, + /// whose shares are other holders' escrow. Claiming is idempotent by + /// rejection, as elsewhere in this contract. + NoEntitlement = 6054, + /// A code change already waits. Replacing one means cancelling it first, so + /// the standing eta is never quietly moved. + UpgradeProposalExists = 6055, + /// No pending change has this id. + ChangeNotFound = 6056, + /// The change's delay has not elapsed. + ChangeNotReady = 6057, + /// The delay is below the shortest the vault accepts. + UpgradeDelayTooShort = 6058, + /// The delay is shorter than the notice, so an investor could not complete + /// an exit before the change applies. Checked when a change is proposed and + /// again when it applies, since the notice may have risen in between. + UpgradeDelayBelowNotice = 6059, + /// The delay exceeds the longest the vault accepts. + UpgradeDelayTooLong = 6060, + /// The notice exceeds the upgrade delay, so an investor could not complete + /// an exit before an upgrade lands. + NoticeAboveUpgradeDelay = 6061, + /// A deposit request would breach the vault's configured deposit cap. + DepositCapExceeded = 6062, + /// As many changes as the timelock holds already wait. Apply or cancel one + /// first. + TooManyPendingChanges = 6063, + /// Setup is still open, so the timelock protects nothing yet: the vault + /// takes no request, seals or prices no epoch, and queues no change. + /// Governance finishes setup first. + SetupOpen = 6064, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 6065, + /// Setup has finished, and it never reopens. + SetupClosed = 6066, + /// A wiring check `health()` reports is false (token, oracle and compliance + /// bound, sole token manager, allowlisted, not frozen, matching decimals, + /// distinct addresses, a feed that answers, a custodian) or the token has + /// shares outstanding, or the vault's own books are not empty, so setup + /// cannot finish. + SetupIncomplete = 6067, + /// The holder fails the share token's identity check, so the vault takes no + /// deposit request from them and returns no escrowed shares to them. A + /// verifier that cannot answer, such as a broken or archived one, counts + /// as a failed check. + NotAllowlisted = 6068, + /// The wind-down delay is below the shortest the vault accepts. + WindDownDelayTooShort = 6069, + /// A running total would go below zero, or the deposited capital the cap + /// is checked against would go below `i128::MIN`. Either means the vault's + /// books disagree with themselves; it fails loudly instead of clamping. + AccountingMismatch = 6070, + /// The holder is frozen. What the vault owes them is held, not paid, + /// returned or moved, until compliance lifts the freeze. A partial freeze + /// refuses only what would touch frozen shares. + Frozen = 6071, + /// The proposed feed does not name this vault as its own, its setup is + /// still open, or it does not answer `state()` and `freshness_duration()`. + /// Its admin could then change its limits with no delay, or no epoch could + /// be priced from it. + FeedNotBound = 6072, + /// The holder may not receive shares (delisted, frozen or refused by the + /// compliance rules), so the escrowed shares stay with the vault and the + /// request stays open. + HolderNotEligible = 6073, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 6074, + /// The custodian would be the vault itself. Capital deployed to it would + /// never leave, and the books would count it as both held and deployed. + InvalidCustodian = 6075, + /// The vault holds less than the escrow depositors can still cancel, + /// because the asset's issuer took part of its balance. A cancellation + /// the balance cannot cover waits, and no new deposit is taken, until the + /// vault is funded. + EscrowNotCovered = 6076, + /// The caller lacks `manager` and the open epoch has been open less than + /// the minimum epoch duration, or has no recorded start yet (setup is + /// still open). + MinEpochDurationNotElapsed = 6077, + } } diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index 26f1cf5a..61c3620f 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -1,9 +1,11 @@ use soroban_sdk::{contractevent, Address}; +use crate::governance::VaultChange; + #[contractevent] pub struct DepositRequested { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub amount: i128, } @@ -19,7 +21,7 @@ pub struct EpochFulfilled { #[contractevent] pub struct DepositClaimed { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub amount: i128, pub shares: i128, @@ -28,7 +30,7 @@ pub struct DepositClaimed { #[contractevent] pub struct RedeemRequested { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub shares: i128, } @@ -36,7 +38,7 @@ pub struct RedeemRequested { #[contractevent] pub struct RedeemClaimed { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub shares: i128, pub assets: i128, @@ -72,7 +74,7 @@ pub struct Deployed { #[contractevent] pub struct Funded { #[topic] - pub from: Address, + pub caller: Address, pub assets: i128, pub net_deployed: i128, } @@ -80,42 +82,50 @@ pub struct Funded { #[contractevent] pub struct DepositCancelled { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub amount: i128, } #[contractevent] -pub struct RedeemCancelled { +pub struct DepositRefunded { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, - pub shares: i128, + pub amount: i128, } #[contractevent] -pub struct UpgradeProposed { - pub eta: u64, +pub struct RedeemCancelled { + #[topic] + pub holder: Address, + pub epoch: u64, + pub shares: i128, } #[contractevent] -pub struct UpgradeDelayProposed { - pub secs: u64, +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: VaultChange, pub eta: u64, } #[contractevent] -pub struct UpgradeCancelled {} +pub struct ChangeCancelled { + #[topic] + pub id: u32, +} #[contractevent] -pub struct UpgradeDelaySet { - pub secs: u64, +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: VaultChange, } #[contractevent] -pub struct Upgraded { - pub schema_version: u32, -} +pub struct SetupFinished {} #[contractevent] pub struct WindDownDelaySet { @@ -138,8 +148,10 @@ pub struct WindDownRoundFinalized { #[topic] pub round: u32, pub pot: i128, + /// Always equal to `pot`; kept for callers that read it separately. pub credited: i128, - pub acc_per_share: i128, + /// Asset base units credited by every round so far. + pub distributed: i128, } #[contractevent] @@ -150,8 +162,40 @@ pub struct WindDownClaimed { pub assets: i128, } +/// An escrowed redemption burned into the holder's wind-down entitlement. +#[contractevent] +pub struct RedemptionSurrendered { + #[topic] + pub holder: Address, + pub epoch: u64, + pub shares: i128, +} + +/// A priced deposit queued to exit in `into_epoch`; zero means the wind-down entitlement. +#[contractevent] +pub struct DepositConvertedToExit { + #[topic] + pub holder: Address, + pub epoch: u64, + pub into_epoch: u64, + pub shares: i128, +} + +#[contractevent] +pub struct EpochReconciled { + #[topic] + pub epoch: u64, + pub share_dust: i128, + pub cash_dust: i128, +} + #[contractevent] pub struct DepositCapUpdated { pub old_cap: Option, pub new_cap: Option, } + +#[contractevent] +pub struct MinEpochDurationSet { + pub secs: u64, +} diff --git a/contracts/async-vault/src/extend.rs b/contracts/async-vault/src/extend.rs new file mode 100644 index 00000000..2de355a3 --- /dev/null +++ b/contracts/async-vault/src/extend.rs @@ -0,0 +1,13 @@ +//! OpenZeppelin extends a role entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env, Symbol}; +use stellar_access::access_control; + +/// The role, its member-list slot, the member count and the role list: all a grant writes. +pub(crate) fn role_entries(e: &Env, account: &Address, role: &Symbol) { + if let Some(index) = access_control::has_role(e, account, role) { + access_control::get_role_member(e, role, index); + } + access_control::get_role_member_count(e, role); + access_control::get_existing_roles(e); +} diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs new file mode 100644 index 00000000..d79e2f91 --- /dev/null +++ b/contracts/async-vault/src/governance.rs @@ -0,0 +1,320 @@ +use soroban_sdk::{ + contracttype, panic_with_error, symbol_short, token::TokenClient, Address, BytesN, Env, Symbol, + Vec, +}; +use stellar_access::{ + access_control::{self, AccessControlClient, AccessControlStorageKey}, + role_transfer, +}; +use stellar_contract_utils::{ + pausable::{self, PausableError}, + upgradeable, +}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{ + identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, + RWATokenClient, + }, +}; +use strata_bindings::{OracleFeedClient, VaultBindingClient}; +use strata_timelock::{queue, setup, Clock, Refusal}; + +use crate::error::VaultError; +use crate::event::{ + ChangeApplied, ChangeCancelled, ChangeProposed, MinEpochDurationSet, NoticeSet, SetupFinished, +}; +use crate::extend; +use crate::guards; +use crate::keys::DataKey; +use crate::state; +use crate::treasury; +use crate::upgrade; +use crate::wind_down; +use crate::AsyncVault; + +/// Seven days of five-second ledgers: how long the next admin has to accept a +/// hand-over. +pub const ADMIN_ACCEPT_LEDGERS: u32 = 7 * 17_280; + +/// A privileged change that waits for the timelock before it lands. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VaultChange { + Upgrade(BytesN<32>), + UpgradeDelay(u64), + Custodian(Address), + Notice(u64), + WindDownDelay(u64), + GrantRole(Symbol, Address), + RevokeRole(Symbol, Address), + Admin(Address), + Oracle(Address), + MinEpochDuration(u64), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VaultPendingChange { + pub id: u32, + pub change: VaultChange, + pub eta: u64, +} + +/// The share token's role that mints and burns. The vault must hold it alone. +const TOKEN_MANAGER_ROLE: Symbol = symbol_short!("manager"); + +/// What `finish_setup` needs to see before the vault opens. `health()` reports +/// every check but `books_empty`, false from a live vault's first request, and +/// `no_shares_outstanding`, false from its first claim. +pub(crate) struct Wiring { + pub token_bound: bool, + pub oracle_bound: bool, + pub sole_token_manager: bool, + /// Shares minted before setup would redeem against depositors' money. + pub no_shares_outstanding: bool, + pub allowlisted: bool, + pub vault_unfrozen: bool, + pub compliance_bound: bool, + pub decimals_match: bool, + pub addresses_distinct: bool, + pub custodian_set: bool, + pub feed_answers: bool, + /// A request booked before setup would be paid from a depositor's money. + pub books_empty: bool, +} + +impl Wiring { + pub(crate) fn complete(&self) -> bool { + self.token_bound + && self.oracle_bound + && self.sole_token_manager + && self.no_shares_outstanding + && self.allowlisted + && self.vault_unfrozen + && self.compliance_bound + && self.decimals_match + && self.addresses_distinct + && self.custodian_set + && self.feed_answers + && self.books_empty + } +} + +/// The clock this vault's queue runs on, and that it reports to the contracts +/// bound to it. +pub(crate) fn clock(e: &Env) -> Clock { + Clock { + delay: upgrade::delay(e), + exit_window: state::notice(e), + paused_secs: strata_timelock::pause::paused_secs(e), + paused: pausable::paused(e), + winding_down: wind_down::is_active(e), + } +} + +fn refuse(e: &Env, refusal: Refusal) -> ! { + match refusal { + Refusal::WindingDown => panic_with_error!(e, VaultError::WindDownActive), + Refusal::Paused => panic_with_error!(e, PausableError::EnforcedPause), + Refusal::NotElapsed => panic_with_error!(e, VaultError::ChangeNotReady), + Refusal::BelowExitWindow => panic_with_error!(e, VaultError::UpgradeDelayBelowNotice), + Refusal::NotFound => panic_with_error!(e, VaultError::ChangeNotFound), + Refusal::TooMany => panic_with_error!(e, VaultError::TooManyPendingChanges), + Refusal::SetupOpen => panic_with_error!(e, VaultError::SetupOpen), + Refusal::SetupClosed => panic_with_error!(e, VaultError::SetupClosed), + // The vault owns the clock and binds to nothing, so these two never arise. + Refusal::AlreadyBound => panic_with_error!(e, VaultError::SetupClosed), + Refusal::Unbound => panic_with_error!(e, VaultError::SetupIncomplete), + } +} + +/// True when `contract` reads this vault's clock and its own setup has +/// finished, so its admin changes wait for this timelock. +pub(crate) fn bound_here(e: &Env, contract: &Address) -> bool { + let binding = VaultBindingClient::new(e, contract); + matches!(binding.try_vault(), Ok(Ok(Some(vault))) if vault == e.current_contract_address()) + && matches!(binding.try_setup_open(), Ok(Ok(false))) +} + +/// True when `feed` answers the views the vault prices from. A new feed has no +/// report yet, so this asks only for answers, not for a valid price. +pub(crate) fn answers_as_feed(e: &Env, feed: &Address) -> bool { + let feed = OracleFeedClient::new(e, feed); + matches!(feed.try_state(), Ok(Ok(_))) && matches!(feed.try_freshness_duration(), Ok(Ok(_))) +} + +fn compliance_bound(e: &Env, compliance: &Address, token: &Address) -> bool { + let linked = matches!( + TokenBinderClient::new(e, compliance).try_linked_tokens(), + Ok(Ok(linked)) if linked.contains(token) + ); + let binding = VaultBindingClient::new(e, compliance); + let bound_here_or_external = match (binding.try_vault(), binding.try_setup_open()) { + (Ok(Ok(vault)), Ok(Ok(open))) => vault == Some(e.current_contract_address()) && !open, + // Either view failing marks an external module; its governance is a trust assumption. + _ => true, + }; + linked && bound_here_or_external +} + +/// True when nothing has been requested, committed or priced in the vault's +/// own books: the first epoch is still open and empty, and nothing is owed. +pub(crate) fn books_empty(e: &Env) -> bool { + if state::current_epoch(e) != state::FIRST_EPOCH { + return false; + } + let first_epoch_empty = match state::get_epoch(e, state::FIRST_EPOCH) { + Some(epoch) => epoch.total_deposited == 0 && epoch.total_shares_redeeming == 0, + None => false, + }; + first_epoch_empty + && state::committed(e) == 0 + && state::pending_mint_shares(e) == 0 + && state::pending_burn_shares(e) == 0 +} + +fn all_different(addresses: &[&Address]) -> bool { + addresses + .iter() + .enumerate() + .all(|(i, a)| addresses[i + 1..].iter().all(|b| a != b)) +} + +pub(crate) fn wiring(e: &Env) -> Wiring { + let me = e.current_contract_address(); + let asset = state::get_addr(e, &DataKey::Asset); + let token = state::get_addr(e, &DataKey::ShareToken); + let oracle = state::get_addr(e, &DataKey::Oracle); + let roles = AccessControlClient::new(e, &token); + let rwa = RWATokenClient::new(e, &token); + let shares = FungibleTokenClient::new(e, &token); + Wiring { + token_bound: bound_here(e, &token), + oracle_bound: bound_here(e, &oracle), + sole_token_manager: matches!( + roles.try_get_role_member_count(&TOKEN_MANAGER_ROLE), + Ok(Ok(1)) + ) && matches!( + roles.try_get_role_member(&TOKEN_MANAGER_ROLE, &0), + Ok(Ok(holder)) if holder == me + ), + no_shares_outstanding: matches!(shares.try_total_supply(), Ok(Ok(0))), + allowlisted: match rwa.try_identity_verifier() { + Ok(Ok(verifier)) => matches!( + IdentityVerifierClient::new(e, &verifier).try_verify_identity(&me), + Ok(Ok(())) + ), + _ => false, + }, + vault_unfrozen: matches!(rwa.try_is_frozen(&me), Ok(Ok(false))), + compliance_bound: match rwa.try_compliance() { + Ok(Ok(compliance)) => compliance_bound(e, &compliance, &token), + _ => false, + }, + decimals_match: matches!( + (TokenClient::new(e, &asset).try_decimals(), shares.try_decimals()), + (Ok(Ok(of_asset)), Ok(Ok(of_shares))) if of_asset == of_shares + ), + addresses_distinct: all_different(&[&asset, &token, &oracle, &me]), + custodian_set: state::get_addr_opt(e, &DataKey::Custodian) + .is_some_and(|custodian| custodian != me), + feed_answers: answers_as_feed(e, &oracle), + books_empty: books_empty(e), + } +} + +pub(crate) fn finish_setup(e: &Env) { + guards::setup_open(e); + guards::no_hand_over_pending(e); + guards::wiring_complete(e); + setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); + state::set_epoch_opened_at(e, e.ledger().timestamp()); + SetupFinished {}.publish(e); +} + +pub(crate) fn set_notice(e: &Env, secs: u64) { + state::set_notice(e, secs); + NoticeSet { secs }.publish(e); +} + +pub(crate) fn set_min_epoch_duration(e: &Env, secs: u64) { + state::set_min_epoch_duration(e, secs); + MinEpochDurationSet { secs }.publish(e); +} + +pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { + guards::not_winding_down(e); + guards::setup_finished(e); + guards::valid_change(e, &change); + guards::not_a_second_code_change(e, &change); + let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); + ChangeProposed { + id, + change, + eta: e.ledger().timestamp().saturating_add(upgrade::delay(e)), + } + .publish(e); + id +} + +pub(crate) fn cancel(e: &Env, id: u32) { + queue::cancel(e, id).unwrap_or_else(|r| refuse(e, r)); + ChangeCancelled { id }.publish(e); +} + +pub(crate) fn apply(e: &Env, id: u32) { + let change: VaultChange = + queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + // The notice or the delay may have moved since the proposal; this check decides. + guards::valid_change(e, &change); + let admin = access_control::get_admin(e) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AdminRequired)); + match &change { + VaultChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + VaultChange::UpgradeDelay(secs) => upgrade::set_delay(e, *secs), + VaultChange::Custodian(custodian) => treasury::set_custodian(e, custodian), + VaultChange::Notice(secs) => set_notice(e, *secs), + VaultChange::WindDownDelay(secs) => wind_down::set_delay(e, *secs), + VaultChange::GrantRole(role, account) => { + access_control::grant_role_no_auth(e, account, role, &admin); + extend::role_entries(e, account, role); + } + VaultChange::RevokeRole(role, account) => { + access_control::revoke_role_no_auth(e, account, role, &admin) + } + VaultChange::Admin(next) => offer_admin(e, &admin, next), + VaultChange::Oracle(feed) => state::set_addr(e, &DataKey::Oracle, feed), + VaultChange::MinEpochDuration(secs) => { + set_min_epoch_duration(e, *secs); + // A vault upgraded in place has no recorded start. + if state::epoch_opened_at(e).is_none() { + state::set_epoch_opened_at(e, e.ledger().timestamp()); + } + } + } + ChangeApplied { id, change }.publish(e); +} + +/// The first step of the two-step hand-over, without the admin authorisation +/// `transfer_admin_role` would ask for a second time inside `apply_change`. +fn offer_admin(e: &Env, admin: &Address, next: &Address) { + let live_until = e.ledger().sequence().saturating_add(ADMIN_ACCEPT_LEDGERS); + role_transfer::transfer_role(e, next, &AccessControlStorageKey::PendingAdmin, live_until); + access_control::emit_admin_transfer_initiated(e, admin, next, live_until); +} + +pub(crate) fn pending(e: &Env) -> Vec { + let clock = clock(e); + let mut out = Vec::new(e); + for id in queue::open_ids(e).iter() { + if let Some((change, schedule)) = queue::pending::(e, id) { + out.push_back(VaultPendingChange { + id, + change, + eta: schedule.eta(&clock), + }); + } + } + out +} diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs new file mode 100644 index 00000000..795533b0 --- /dev/null +++ b/contracts/async-vault/src/guards.rs @@ -0,0 +1,427 @@ +use soroban_sdk::{panic_with_error, Address, Env, Symbol}; +use stellar_access::{ + access_control::{self, AccessControlError, AccessControlStorageKey}, + role_transfer, +}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{compliance::ComplianceClient as RulesClient, RWATokenClient}, +}; +use strata_bindings::{OracleFeedClient, OracleState}; +use strata_timelock::{queue, setup}; + +use crate::epoch; +use crate::error::VaultError; +use crate::governance::{self, VaultChange}; +use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit, Standing}; +use crate::roles::{VaultRoles, MANAGER_ROLE}; +use crate::state::{self, EpochInfo, EpochStatus}; +use crate::treasury; +use crate::upgrade::{self, MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; +use crate::wind_down::{ + self, WindDownInfo, WindDownStatus, MAX_WIND_DOWN_DELAY, MIN_WIND_DOWN_DELAY, +}; +use crate::{MAX_NOTICE_SECS, MIN_EPOCH_DURATION_CEILING, MIN_EPOCH_DURATION_FLOOR}; + +pub(crate) fn roles_distinct(e: &Env, roles: &VaultRoles) { + if roles.treasury == roles.guardian + || roles.treasury == roles.governance + || roles.compliance == roles.governance + || roles.compliance == roles.treasury + { + panic_with_error!(e, VaultError::RolesNotDistinct); + } +} + +/// The first check in every entrypoint body the wind-down closes; `apply_change` +/// is closed by the timelock's own refusal instead. +pub(crate) fn not_winding_down(e: &Env) { + if wind_down::is_active(e) { + panic_with_error!(e, VaultError::WindDownActive); + } +} + +pub(crate) fn winding_down(e: &Env) { + if !wind_down::is_active(e) { + panic_with_error!(e, VaultError::WindDownNotActive); + } +} + +/// Nothing is taken in or queued until every admin change on the kit waits for +/// the timelock. +pub(crate) fn setup_finished(e: &Env) { + if setup::is_open(e) { + panic_with_error!(e, VaultError::SetupOpen); + } +} + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, VaultError::SetupClosed); + } +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, VaultError::UseTimelock); + } +} + +/// After setup, revoking `manager` waits like a grant; any other revoke stays +/// instant. +pub(crate) fn direct_revoke_allowed(e: &Env, role: &Symbol) { + if *role == MANAGER_ROLE { + direct_change_allowed(e); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} + +pub(crate) fn not_lowered_after_setup(e: &Env, new: u64, current: u64) { + if new < current { + direct_change_allowed(e); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, VaultError::AdminTransferPending); + } +} + +/// Stands in for `#[only_admin]` where an OpenZeppelin trait fixes the caller. +pub(crate) fn caller_is_admin(e: &Env, caller: &Address) { + if access_control::get_admin(e).as_ref() != Some(caller) { + panic_with_error!(e, VaultError::AdminRequired); + } +} + +pub(crate) fn wiring_complete(e: &Env) { + if !governance::wiring(e).complete() { + panic_with_error!(e, VaultError::SetupIncomplete); + } +} + +pub(crate) fn non_negative_cap(e: &Env, cap: Option) { + if cap.is_some_and(|cap| cap < 0) { + panic_with_error!(e, VaultError::InvalidAmount); + } +} + +pub(crate) fn notice_in_bounds(e: &Env, secs: u64) { + if secs > MAX_NOTICE_SECS { + panic_with_error!(e, VaultError::NoticeTooLong); + } + if secs > upgrade::delay(e) { + panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); + } +} + +#[cfg_attr(feature = "short_wind_down", allow(clippy::absurd_extreme_comparisons))] +pub(crate) fn wind_down_delay_in_bounds(e: &Env, secs: u64) { + if secs < MIN_WIND_DOWN_DELAY { + panic_with_error!(e, VaultError::WindDownDelayTooShort); + } + if secs > MAX_WIND_DOWN_DELAY { + panic_with_error!(e, VaultError::WindDownDelayTooLong); + } +} + +/// A shorter delay than the notice would let a change land before an epoch +/// sealed at its proposal could be priced. +pub(crate) fn upgrade_delay_in_bounds(e: &Env, secs: u64) { + if secs < MIN_UPGRADE_DELAY { + panic_with_error!(e, VaultError::UpgradeDelayTooShort); + } + if secs > MAX_UPGRADE_DELAY { + panic_with_error!(e, VaultError::UpgradeDelayTooLong); + } + if secs < state::notice(e) { + panic_with_error!(e, VaultError::UpgradeDelayBelowNotice); + } +} + +pub(crate) fn min_epoch_duration_in_bounds(e: &Env, secs: u64) { + if !(MIN_EPOCH_DURATION_FLOOR..=MIN_EPOCH_DURATION_CEILING).contains(&secs) { + panic_with_error!(e, VaultError::InvalidAmount); + } +} + +pub(crate) fn custodian_not_the_vault(e: &Env, custodian: &Address) { + if *custodian == e.current_contract_address() { + panic_with_error!(e, VaultError::InvalidCustodian); + } +} + +/// A feed that cannot answer would stall every epoch after the swap. +pub(crate) fn feed_bound_here_and_answering(e: &Env, feed: &Address) { + if !governance::bound_here(e, feed) || !governance::answers_as_feed(e, feed) { + panic_with_error!(e, VaultError::FeedNotBound); + } +} + +/// What a change must meet when it is proposed and again when it applies: a +/// delay, notice or minimum within its bounds, a feed bound here that answers, +/// or a custodian other than the vault. +pub(crate) fn valid_change(e: &Env, change: &VaultChange) { + match change { + VaultChange::UpgradeDelay(secs) => upgrade_delay_in_bounds(e, *secs), + VaultChange::Notice(secs) => notice_in_bounds(e, *secs), + VaultChange::WindDownDelay(secs) => wind_down_delay_in_bounds(e, *secs), + VaultChange::Oracle(feed) => feed_bound_here_and_answering(e, feed), + VaultChange::Custodian(custodian) => custodian_not_the_vault(e, custodian), + VaultChange::MinEpochDuration(secs) => min_epoch_duration_in_bounds(e, *secs), + VaultChange::Upgrade(_) + | VaultChange::GrantRole(..) + | VaultChange::RevokeRole(..) + | VaultChange::Admin(_) => {} + } +} + +/// One code change at a time, so a standing eta is never quietly replaced. +pub(crate) fn not_a_second_code_change(e: &Env, change: &VaultChange) { + if changes_code(change) && code_change_pending(e) { + panic_with_error!(e, VaultError::UpgradeProposalExists); + } +} + +fn changes_code(change: &VaultChange) -> bool { + matches!( + change, + VaultChange::Upgrade(_) | VaultChange::UpgradeDelay(_) + ) +} + +fn code_change_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id).is_some_and(|(change, _)| changes_code(&change)) + }) +} + +pub(crate) fn no_wind_down_proposed(e: &Env) { + if matches!( + wind_down::info(e), + Some(WindDownInfo { + status: WindDownStatus::Proposed, + .. + }) + ) { + panic_with_error!(e, VaultError::WindDownAlreadyProposed); + } +} + +pub(crate) fn wind_down_proposed(e: &Env) { + if !matches!( + wind_down::info(e), + Some(WindDownInfo { + status: WindDownStatus::Proposed, + .. + }) + ) { + panic_with_error!(e, VaultError::WindDownNotProposed); + } +} + +pub(crate) fn wind_down_delay_elapsed(e: &Env, proposal: &WindDownInfo) { + if e.ledger().timestamp() < proposal.active_at { + panic_with_error!(e, VaultError::WindDownDelayNotElapsed); + } +} + +pub(crate) fn distribution_started(e: &Env) { + if wind_down::info(e).map(|wd| wd.round).unwrap_or(0) == 0 { + panic_with_error!(e, VaultError::DistributionNotStarted); + } +} + +/// A round needs a supply to divide by and a pot that credits something. +pub(crate) fn something_to_distribute(e: &Env, value: i128) { + if value <= 0 { + panic_with_error!(e, VaultError::NothingToDistribute); + } +} + +pub(crate) fn has_entitlement(e: &Env, surrendered: i128, assets: i128) { + if surrendered == 0 && assets == 0 { + panic_with_error!(e, VaultError::NoEntitlement); + } +} + +/// The vault's own shares are other holders' escrow, not an entitlement. +pub(crate) fn holder_not_the_vault(e: &Env, holder: &Address) { + if *holder == e.current_contract_address() { + panic_with_error!(e, VaultError::NoEntitlement); + } +} + +pub(crate) fn positive_amount(e: &Env, amount: i128) { + if amount <= 0 { + panic_with_error!(e, VaultError::InvalidAmount); + } +} + +pub(crate) fn feed_valid(e: &Env) { + let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle)); + if feed.state() != OracleState::Valid { + panic_with_error!(e, VaultError::FeedNotValid); + } +} + +pub(crate) fn within_cap(e: &Env, amount: i128) { + let Some(cap) = state::deposit_cap(e) else { + return; + }; + let new_total = treasury::deposited_capital(e) + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + if new_total > cap { + panic_with_error!(e, VaultError::DepositCapExceeded); + } +} + +pub(crate) fn within_free_reserve(e: &Env, assets: i128) { + if assets > treasury::free_reserve(e) { + panic_with_error!(e, VaultError::ReserveCommittedToExits); + } +} + +pub(crate) fn no_open_deposit(e: &Env, epoch_id: u64, holder: &Address) { + if state::get_deposit_request(e, epoch_id, holder).is_some() { + panic_with_error!(e, VaultError::RequestOutstanding); + } +} + +pub(crate) fn no_open_redeem(e: &Env, epoch_id: u64, holder: &Address) { + if state::get_redeem_request(e, epoch_id, holder).is_some() { + panic_with_error!(e, VaultError::RequestOutstanding); + } +} + +pub(crate) fn not_claimed(e: &Env, claimed: bool) { + if claimed { + panic_with_error!(e, VaultError::AlreadyClaimed); + } +} + +/// A request within the balance but short of the free shares needs frozen +/// ones; beyond the balance entirely, the token's own check applies instead. +pub(crate) fn needs_no_frozen_shares(e: &Env, holder: &Address, shares: i128) { + if shares > lock_policy::free_shares(e, holder) { + let held = + FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).balance(holder); + if shares <= held { + panic_with_error!(e, VaultError::Frozen); + } + } +} + +/// A refund after pricing comes out of free reserve, never out of money owed to exits. +pub(crate) fn refund_covered(e: &Env, amount: i128) { + if treasury::free_reserve(e) < amount { + panic_with_error!(e, VaultError::ClaimNotCovered); + } +} + +/// Against liquid reserve, not the raw balance: escrow a depositor can still +/// cancel is not available to pay an exit. +pub(crate) fn payout_covered(e: &Env, assets: i128) { + if treasury::liquid_reserve(e) < assets { + panic_with_error!(e, VaultError::ClaimNotCovered); + } +} + +/// Against the raw balance: after a clawback, cancellations are paid in arrival order. +pub(crate) fn cancellation_covered(e: &Env, amount: i128) { + if treasury::held(e) < amount { + panic_with_error!(e, VaultError::EscrowNotCovered); + } +} + +/// A new deposit never pays an earlier cancellation the vault can no longer cover. +pub(crate) fn escrow_covered(e: &Env) { + if treasury::liquid_reserve(e) < 0 { + panic_with_error!(e, VaultError::EscrowNotCovered); + } +} + +/// The holder may hold shares and the compliance rules let the vault send them. +pub(crate) fn eligible_to_receive(e: &Env, holder: &Address, shares: i128) { + let vault = e.current_contract_address(); + let token = state::get_addr(e, &DataKey::ShareToken); + let share = RWATokenClient::new(e, &token); + if lock_policy::standing(e, holder) != Standing::Listed + || !RulesClient::new(e, &share.compliance()).can_transfer(&vault, holder, &shares, &token) + { + panic_with_error!(e, VaultError::HolderNotEligible); + } +} + +pub(crate) fn epoch_open(e: &Env, epoch: &EpochInfo) { + if epoch.status != EpochStatus::Open { + panic_with_error!(e, VaultError::EpochNotOpen); + } +} + +pub(crate) fn epoch_fulfilled(e: &Env, epoch: &EpochInfo) { + if epoch.status != EpochStatus::Fulfilled { + panic_with_error!(e, VaultError::EpochNotFulfilled); + } +} + +pub(crate) fn not_priced(e: &Env, epoch: &EpochInfo) { + if epoch.status == EpochStatus::Fulfilled { + panic_with_error!(e, VaultError::AlreadyPriced); + } +} + +/// A readable price closes cancellation, except in a wind-down, which prices nothing. +pub(crate) fn no_price_available(e: &Env, epoch: &EpochInfo) { + if !wind_down::is_active(e) && epoch::is_priceable(e, epoch) { + panic_with_error!(e, VaultError::PriceAvailable); + } +} + +pub(crate) fn fulfilable(e: &Env, epoch: &EpochInfo) { + if let Err(refusal) = epoch::fulfilable(e, epoch) { + panic_with_error!(e, refusal); + } +} + +/// The lock policy lets the holder's `exit` go ahead as asked. +pub(crate) fn allowed_to(e: &Env, exit: Exit, holder: &Address) { + let standing = lock_policy::standing(e, holder); + if lock_policy::decide(exit, standing, wind_down::is_active(e)) != Decision::Proceed { + panic_with_error!(e, lock_policy::refused_with(standing)); + } +} + +/// The lock policy neither refuses nor holds the exit. +pub(crate) fn not_refused(e: &Env, decision: Decision, standing: Standing) { + if matches!(decision, Decision::Refuse | Decision::Hold) { + panic_with_error!(e, lock_policy::refused_with(standing)); + } +} + +/// The manager seals at any time, anyone else once the open epoch has run the minimum. +pub(crate) fn allowed_to_seal(e: &Env, caller: &Address) { + if access_control::has_role(e, caller, &MANAGER_ROLE).is_some() { + return; + } + let Some(minimum) = state::min_epoch_duration(e) else { + panic_with_error!(e, AccessControlError::Unauthorized); + }; + let elapsed = state::epoch_opened_at(e) + .is_some_and(|start| e.ledger().timestamp() >= start.saturating_add(minimum)); + if !elapsed { + panic_with_error!(e, VaultError::MinEpochDurationNotElapsed); + } +} diff --git a/contracts/async-vault/src/health.rs b/contracts/async-vault/src/health.rs new file mode 100644 index 00000000..9f6d01fe --- /dev/null +++ b/contracts/async-vault/src/health.rs @@ -0,0 +1,59 @@ +use soroban_sdk::{contracttype, Env}; +use strata_bindings::{OracleFeedClient, OracleState}; + +use crate::governance; +use crate::keys::DataKey; +use crate::state; + +/// What a deployment checks before it trusts the vault, and what monitoring +/// keeps checking after. On a healthy vault `setup_open` is false and every +/// other field is true. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Health { + /// False once `finish_setup` has run. While true, every admin change is + /// instant, and the vault takes no request and seals or prices no epoch. + pub setup_open: bool, + pub token_bound: bool, + pub oracle_bound: bool, + pub sole_token_manager: bool, + pub allowlisted: bool, + pub vault_unfrozen: bool, + pub compliance_bound: bool, + pub decimals_match: bool, + /// The asset, the share token, the oracle and the vault are four + /// different addresses. + pub addresses_distinct: bool, + /// A custodian is set, and it is not the vault. + pub custodian_set: bool, + /// The oracle answers `state` and `freshness_duration`, report or not. + pub feed_answers: bool, + pub feed_valid: bool, + /// A report outlives the notice, so an epoch's cancellation window does + /// not reopen on its own while the notice runs. + pub freshness_covers_notice: bool, +} + +pub(crate) fn health(e: &Env) -> Health { + let wiring = governance::wiring(e); + let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle)); + let notice = state::notice(e); + Health { + setup_open: strata_timelock::setup::is_open(e), + token_bound: wiring.token_bound, + oracle_bound: wiring.oracle_bound, + sole_token_manager: wiring.sole_token_manager, + allowlisted: wiring.allowlisted, + vault_unfrozen: wiring.vault_unfrozen, + compliance_bound: wiring.compliance_bound, + decimals_match: wiring.decimals_match, + addresses_distinct: wiring.addresses_distinct, + custodian_set: wiring.custodian_set, + feed_answers: wiring.feed_answers, + feed_valid: matches!(feed.try_state(), Ok(Ok(OracleState::Valid))), + freshness_covers_notice: matches!( + feed.try_freshness_duration(), + Ok(Ok(freshness)) if freshness >= notice + ), + } +} diff --git a/contracts/async-vault/src/keys.rs b/contracts/async-vault/src/keys.rs index 401cd278..c258ee71 100644 --- a/contracts/async-vault/src/keys.rs +++ b/contracts/async-vault/src/keys.rs @@ -17,13 +17,15 @@ pub(crate) enum DataKey { UserDeposit(u64, Address), UserRedeem(u64, Address), UpgradeDelay, - UpgradeProposal, - PausedAt, WindDownDelay, WindDown, WindDownSupply, - WindDownAcc, + WindDownDistributed, WindDownOwed, WindDownPosition(Address), + WindDownCredited, DepositCap, + Settlement(u64), + EpochOpenedAt, + MinEpochDuration, } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 69b552d7..48ca3490 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -1,25 +1,72 @@ +//! # Design +//! +//! Deposits and redemptions are requests. Each joins the open epoch; the epoch +//! is sealed and priced at the oracle's report, and each holder claims the +//! result. +//! +//! - **The manager seals at any time, and anyone once the open epoch has run +//! the minimum epoch duration governance sets; anyone prices.** The price is +//! the oracle's, not the caller's. The cost: a silent attester stalls every +//! request; a silent manager stalls them until the minimum runs, or, with no +//! minimum set, until a replacement passes the timelock. +//! - **Pricing records totals; claims mint and burn.** Pricing costs the same +//! for any number of requests. The cost: an epoch's rounding remainder stays +//! in `committed` and `pending_mint_shares` until its last request settles. +//! - **A readable price closes cancellation,** during the notice too, until the +//! feed goes stale or the NAV or the vault is paused. The cost: a report that +//! lapses lets requests leave a sealed epoch late. +//! - **Priced before paid.** A redemption is priced whether or not the cash is +//! there; its claim waits for the liquid reserve, and nothing goes to the +//! custodian meanwhile. The cost: an exit can wait on treasury. +//! - **A refund below one share is not reserved.** Pricing sees only totals, so +//! the refund comes from the free reserve at claim. The cost: treasury or a +//! wind-down round can spend it first, and the claim waits. +//! - **Checked totals.** An asset or share total fails with `AmountTooLarge` on +//! overflow, and with `AccountingMismatch` below zero unless signed, as +//! `net_deployed` is. The cost: books that disagree fail the call. +//! - **A freeze holds every exit.** A frozen holder's requests, claims and +//! cancellations fail with `Frozen` until it lifts: compliance can delay a +//! payout, never redirect it. The cost: even a covered claim waits. +//! - **A delisted holder leaves in cash.** Shares priced for them join their +//! redemption in the open epoch, or their wind-down entitlement. The cost: +//! another epoch at its price, and a wait while the vault itself is delisted. +//! - **Setup, then the timelock.** Until `finish_setup` checks the wiring, +//! admin changes are instant and requests and epochs refused; after it, +//! changes that move value or cut a wait are timelocked. The cost: fixes +//! take a week. +//! - **Wind-down claims run the compliance hooks:** surrendered shares burn +//! through the `destroyed` modules. The cost: one failing in a wind-down +//! blocks those claims, and no change can remove it then. + #![no_std] mod deposit; mod epoch; mod error; mod event; +mod extend; +mod governance; +mod guards; +mod health; mod keys; +mod lock_policy; mod pricing; mod redeem; mod roles; +mod settlement; mod state; mod timing; mod treasury; mod upgrade; mod wind_down; -use bindings::ShareClient; -use soroban_sdk::{contract, contractimpl, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{contract, contractimpl, panic_with_error, Address, Env, Symbol, Vec}; use stellar_access::access_control::{self, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_role, when_not_paused}; +use stellar_tokens::fungible::FungibleTokenClient; +use strata_timelock::{Clock, ClockSource, Refusal, TimelockHost, DAY_DURATION}; use keys::DataKey; use roles::{GUARDIAN_ROLE, MANAGER_ROLE, TREASURY_ROLE}; @@ -27,21 +74,36 @@ use state::FIRST_EPOCH; pub use error::VaultError; pub use event::{ - CustodianSet, Deployed, DepositCapUpdated, DepositClaimed, DepositRequested, EpochClosed, - EpochFulfilled, Funded, NoticeSet, RedeemClaimed, RedeemRequested, UpgradeCancelled, - UpgradeDelayProposed, UpgradeDelaySet, UpgradeProposed, Upgraded, WindDownActivated, - WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, - WindDownRoundFinalized, + ChangeApplied, ChangeCancelled, ChangeProposed, CustodianSet, Deployed, DepositCancelled, + DepositCapUpdated, DepositClaimed, DepositConvertedToExit, DepositRefunded, DepositRequested, + EpochClosed, EpochFulfilled, EpochReconciled, Funded, MinEpochDurationSet, NoticeSet, + RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered, SetupFinished, + WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, + WindDownProposed, WindDownRoundFinalized, }; +pub use governance::{VaultChange, VaultPendingChange, ADMIN_ACCEPT_LEDGERS}; +pub use health::Health; pub use pricing::{DirectUnitPricing, PricingScheme}; pub use roles::VaultRoles; +pub use settlement::EpochSettlement; pub use state::{DepositRequest, EpochInfo, EpochStatus, RedeemRequest}; -pub use upgrade::{UpgradeAction, UpgradeProposal, MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; -pub use wind_down::{WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY}; +pub use upgrade::{MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; +pub use wind_down::{ + WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY, MIN_WIND_DOWN_DELAY, +}; /// Thirty days. Longer than any notice a real structure uses, short enough that /// setting it by mistake is survivable. -pub const MAX_NOTICE_SECS: u64 = 30 * 24 * 60 * 60; +pub const MAX_NOTICE_SECS: u64 = 30 * DAY_DURATION; + +/// One day, so outsiders cannot seal a stream of tiny epochs. +pub const MIN_EPOCH_DURATION_FLOOR: u64 = DAY_DURATION; + +/// Thirty days: monthly epochs fit, and a silent manager stalls no longer. +pub const MIN_EPOCH_DURATION_CEILING: u64 = 30 * DAY_DURATION; + +#[cfg(feature = "short_wind_down")] +soroban_sdk::contractmeta!(key = "build", val = "short wind-down; local tests only"); fn role_holder(e: &Env, role: &Symbol) -> Option
      { if access_control::get_role_member_count(e, role) == 0 { @@ -55,6 +117,30 @@ pub struct AsyncVault; #[contractimpl] impl AsyncVault { + /// Creates the vault with its asset, share token, oracle and authorities, + /// and opens epoch 1. + /// + /// # Arguments + /// + /// * `asset` - The deposit asset's token contract. + /// * `share_token` - The share token. + /// * `oracle` - The feed epochs are priced from. + /// * `roles` - `governance` becomes the admin; `manager`, `treasury` and + /// `guardian` get their roles. `compliance` is read only by the + /// distinctness check; `attester` is not read. + /// + /// # Errors + /// + /// * `RolesNotDistinct` - `treasury` equals `guardian` or `governance`, or + /// `compliance` equals `governance` or `treasury`. + /// + /// # Events + /// + /// * `RoleGranted` for `manager`, `treasury` and `guardian`. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor( e: &Env, asset: Address, @@ -62,24 +148,15 @@ impl AsyncVault { oracle: Address, roles: VaultRoles, ) { - // The vault kit white-label topology specifies five distinct authorities: - // governance, manager, treasury, guardian, compliance, and attester. - // Although compliance is enforced on the identity verifier and attestation - // on the NAV oracle, the vault checks pairwise distinctness at construction - // time to prevent role misconfiguration across the kit ecosystem. Neither - // compliance nor attester is granted a direct role on the vault contract itself. - if roles.treasury == roles.guardian - || roles.treasury == roles.governance - || roles.compliance == roles.governance - || roles.compliance == roles.treasury - { - panic_with_error!(e, VaultError::RolesNotDistinct); - } + guards::roles_distinct(e, &roles); access_control::set_admin(e, &roles.governance); access_control::grant_role_no_auth(e, &roles.manager, &MANAGER_ROLE, &roles.governance); + extend::role_entries(e, &roles.manager, &MANAGER_ROLE); access_control::grant_role_no_auth(e, &roles.treasury, &TREASURY_ROLE, &roles.governance); + extend::role_entries(e, &roles.treasury, &TREASURY_ROLE); access_control::grant_role_no_auth(e, &roles.guardian, &GUARDIAN_ROLE, &roles.governance); + extend::role_entries(e, &roles.guardian, &GUARDIAN_ROLE); state::set_addr(e, &DataKey::Asset, &asset); state::set_addr(e, &DataKey::ShareToken, &share_token); @@ -90,93 +167,134 @@ impl AsyncVault { upgradeable::set_schema_version(e, 1); } + /// The deposit asset's token contract. pub fn asset(e: &Env) -> Address { state::get_addr(e, &DataKey::Asset) } + /// The share token. pub fn share_token(e: &Env) -> Address { state::get_addr(e, &DataKey::ShareToken) } + /// The feed epochs are priced from. pub fn oracle(e: &Env) -> Address { state::get_addr(e, &DataKey::Oracle) } + /// The admin: governance. pub fn governance(e: &Env) -> Option
      { access_control::get_admin(e) } + /// The first holder of `manager`, which closes epochs. pub fn manager(e: &Env) -> Option
      { role_holder(e, &MANAGER_ROLE) } + /// The first holder of `treasury`, which deploys capital to the + /// custodian. pub fn treasury(e: &Env) -> Option
      { role_holder(e, &TREASURY_ROLE) } + /// The first holder of `guardian`, which pauses the vault. pub fn guardian(e: &Env) -> Option
      { role_holder(e, &GUARDIAN_ROLE) } + /// Where `deploy_to_custodian` sends capital, once set. pub fn custodian(e: &Env) -> Option
      { state::get_addr_opt(e, &DataKey::Custodian) } + /// Asset base units sent to the custodian less those funded back; + /// negative once more came back than went out. pub fn net_deployed(e: &Env) -> i128 { state::net_deployed(e) } + /// Asset base units the vault holds that nothing is owed against: what is + /// left after cancellable escrow, priced redemptions and credited + /// wind-down rounds, and never below zero. pub fn free_reserve(e: &Env) -> i128 { treasury::free_reserve(e) } + /// Asset base units owed to priced redemptions not yet claimed. pub fn committed(e: &Env) -> i128 { state::committed(e) } + /// Asset base units owed to priced redemptions and cancellable escrow + /// beyond what the vault holds. While it is positive, nothing can be + /// deployed to the custodian. pub fn uncovered(e: &Env) -> i128 { treasury::uncovered(e) } + /// Asset base units the vault holds, less the escrow depositors can still + /// cancel. Can turn negative after the asset's issuer takes from the vault. pub fn liquid_reserve(e: &Env) -> i128 { treasury::liquid_reserve(e) } + /// Asset base units escrowed by deposits in unpriced epochs. pub fn cancellable_escrow(e: &Env) -> i128 { state::cancellable_escrow(e) } + /// Share base units priced for deposits and not yet claimed. pub fn pending_mint_shares(e: &Env) -> i128 { state::pending_mint_shares(e) } + /// Share base units of priced redemptions not yet claimed. pub fn pending_burn_shares(e: &Env) -> i128 { state::pending_burn_shares(e) } + /// Share base units outside the vault, plus those priced for deposits and + /// not yet minted: the token's supply, less the vault's own balance, plus + /// `pending_mint_shares`. pub fn total_economic_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); - let client = ShareClient::new(e, &share_token); + let client = FungibleTokenClient::new(e, &share_token); let total_supply = client.total_supply(); let vault_escrow = client.balance(&e.current_contract_address()); - let circulating = total_supply.saturating_sub(vault_escrow); + let circulating = state::reduce(e, total_supply, vault_escrow); circulating .checked_add(state::pending_mint_shares(e)) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) } + /// The cap on deposited capital, in asset base units; `None` means no cap. pub fn deposit_cap(e: &Env) -> Option { state::deposit_cap(e) } + /// Caps deposited capital: what the vault holds plus what it has deployed, + /// less what priced redemptions are owed. A deposit request that would take + /// it past the cap is refused. Instant, also after setup. + /// + /// # Arguments + /// + /// * `cap` - Asset base units, or `None` for no cap. + /// + /// # Errors + /// + /// * `InvalidAmount` - `cap` is negative. + /// + /// # Events + /// + /// * `DepositCapUpdated` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn set_deposit_cap(e: &Env, cap: Option, caller: Address) { - caller.require_auth(); - if let Some(c) = cap { - if c < 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - } + pub fn set_deposit_cap(e: &Env, cap: Option) { + guards::non_negative_cap(e, cap); let old_cap = state::deposit_cap(e); state::set_deposit_cap(e, cap); DepositCapUpdated { @@ -186,192 +304,795 @@ impl AsyncVault { .publish(e); } + /// Sets where deployed capital goes. Instant during setup; afterwards the + /// custodian changes through the timelock. Never the vault itself. + /// + /// # Arguments + /// + /// * `custodian` - The account that receives deployed capital. + /// + /// # Errors + /// + /// * `InvalidCustodian` - `custodian` is the vault. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `CustodianSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn set_custodian(e: &Env, custodian: Address, caller: Address) { - caller.require_auth(); + pub fn set_custodian(e: &Env, custodian: Address) { + guards::custodian_not_the_vault(e, &custodian); + guards::direct_change_allowed(e); treasury::set_custodian(e, &custodian); } + /// Sets how long a wind-down proposal waits before it can start. Raising + /// is instant. Lowering shortens the warning holders get before a + /// wind-down starts, so after setup it waits for the timelock. + /// + /// # Arguments + /// + /// * `secs` - Seconds, from 7 to 90 days. + /// + /// # Errors + /// + /// * `WindDownDelayTooShort` - `secs` is under 7 days. + /// * `WindDownDelayTooLong` - `secs` is over 90 days. + /// * `UseTimelock` - Lowering after setup. + /// + /// # Events + /// + /// * `WindDownDelaySet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn set_wind_down_delay(e: &Env, secs: u64, caller: Address) { - caller.require_auth(); + pub fn set_wind_down_delay(e: &Env, secs: u64) { + guards::wind_down_delay_in_bounds(e, secs); + guards::not_lowered_after_setup(e, secs, wind_down::delay(e)); wind_down::set_delay(e, secs); } + /// Seconds a wind-down proposal waits before it can start. pub fn wind_down_delay(e: &Env) -> u64 { wind_down::delay(e) } + /// Sets how long the open epoch must stay open before anyone may seal it. + /// Instant during setup; afterwards it changes through the timelock. + /// + /// # Arguments + /// + /// * `secs` - Seconds, from 1 to 30 days. + /// + /// # Errors + /// + /// * `InvalidAmount` - `secs` is under 1 day or over 30 days. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `MinEpochDurationSet` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn set_min_epoch_duration(e: &Env, secs: u64) { + guards::min_epoch_duration_in_bounds(e, secs); + guards::direct_change_allowed(e); + governance::set_min_epoch_duration(e, secs); + } + + /// Seconds the open epoch must stay open before anyone but the manager may + /// seal it. `None` until governance sets one; while `None`, only the + /// manager seals. + pub fn min_epoch_duration(e: &Env) -> Option { + state::min_epoch_duration(e) + } + + /// The wind-down once proposed: whether it is proposed or active, the time + /// in seconds it may start, and the rounds finalised. pub fn wind_down(e: &Env) -> Option { wind_down::info(e) } + /// Announces a wind-down, which anyone can start once the delay has run. + /// New deposits stop at once; exits go on. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is already winding down. + /// * `WindDownAlreadyProposed` - A proposal already stands. + /// + /// # Events + /// + /// * `WindDownProposed`, with `active_at` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn propose_wind_down(e: &Env, caller: Address) { - caller.require_auth(); + pub fn propose_wind_down(e: &Env) { wind_down::propose(e); } + /// Withdraws the standing wind-down proposal. + /// + /// # Errors + /// + /// * `WindDownActive` - The wind-down has started. + /// * `WindDownNotProposed` - No proposal stands. + /// + /// # Events + /// + /// * `WindDownProposalCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn cancel_wind_down_proposal(e: &Env, caller: Address) { - caller.require_auth(); + pub fn cancel_wind_down_proposal(e: &Env) { wind_down::cancel_proposal(e); } - /// Open to anyone once the delay has passed, so the operator cannot stall - /// the wind-down it announced. + /// Starts the wind-down: from here on the vault takes no request and + /// prices nothing, and holders exit through wind-down rounds. Open to + /// anyone once the delay has passed, so the operator cannot stall the + /// wind-down it announced. + /// + /// # Errors + /// + /// * `WindDownActive` - The wind-down has already started. + /// * `WindDownNotProposed` - No proposal stands. + /// * `WindDownDelayNotElapsed` - The delay has not run. + /// + /// # Events + /// + /// * `WindDownActivated` + /// + /// # Authorization + /// + /// None: anyone may call. pub fn activate_wind_down(e: &Env) { wind_down::activate(e); } - /// Open to anyone once active, distributing the free reserve in proportion - /// to the supply snapshot. + /// Distributes the free reserve in proportion to the supply snapshot the + /// first round takes, and returns the asset base units the round credits. + /// Open to anyone once the wind-down is active. + /// + /// # Errors + /// + /// * `WindDownNotActive` - The wind-down has not started. + /// * `NothingToDistribute` - No supply to divide by, or the free reserve + /// is empty. + /// * `AmountTooLarge` - A figure of the round would overflow. + /// + /// # Events + /// + /// * `WindDownRoundFinalized` + /// + /// # Authorization + /// + /// None: anyone may call. pub fn finalize_wind_down_round(e: &Env) -> i128 { wind_down::finalize_round(e) } + /// Asset base units the rounds credited that holders have not claimed. pub fn wind_down_owed(e: &Env) -> i128 { wind_down::owed(e) } + /// Share base units the first round's snapshot divides by; zero before it. pub fn wind_down_supply(e: &Env) -> i128 { wind_down::supply_snapshot(e) } - pub fn wind_down_acc(e: &Env) -> i128 { - wind_down::acc(e) + /// Asset base units every round has credited so far. A holder has earned + /// their entitlement's share of it over `wind_down_supply`. + pub fn wind_down_distributed(e: &Env) -> i128 { + wind_down::distributed(e) } - /// Surrenders whatever the holder holds and pays their share of every round - /// finalised since they last claimed. + /// Surrenders the free shares the holder holds and pays their share of + /// every round finalised since they last claimed. Returns the asset base + /// units paid. A frozen holder is refused; a partial freeze surrenders + /// only the free shares. + /// + /// # Arguments + /// + /// * `holder` - The shareholder. + /// + /// # Errors + /// + /// * `DistributionNotStarted` - No round has been finalised. + /// * `Frozen` - `holder` is frozen. + /// * `AmountTooLarge` - A total would overflow. + /// * `AccountingMismatch` - A payout would go below what was already paid. + /// * `NoEntitlement` - Nothing to surrender and nothing owed, or + /// `holder` is the vault. + /// * The asset's own error when `holder` cannot receive it. + /// + /// # Events + /// + /// * `WindDownClaimed`, after the token's `Burn` when shares are + /// surrendered. + /// + /// # Authorization + /// + /// `holder` signs. pub fn claim_wind_down(e: &Env, holder: Address) -> i128 { wind_down::claim(e, &holder) } + /// What `claim_wind_down` would pay `holder` now, in asset base units: + /// zero before the first round, for a frozen holder and for the vault; + /// fails where the claim fails. + /// + /// # Arguments + /// + /// * `holder` - The shareholder. pub fn wind_down_claimable(e: &Env, holder: Address) -> i128 { wind_down::claimable(e, &holder) } + /// Sets the notice: how long a closed epoch waits before it can be priced. + /// Raising is instant. Lowering shortens the wait of every epoch closed + /// afterwards, so after setup it waits for the timelock. + /// + /// # Arguments + /// + /// * `secs` - Seconds; at most 30 days and at most the upgrade delay. + /// + /// # Errors + /// + /// * `UseTimelock` - Lowering after setup. + /// * `NoticeTooLong` - `secs` is over 30 days. + /// * `NoticeAboveUpgradeDelay` - `secs` exceeds the upgrade delay. + /// + /// # Events + /// + /// * `NoticeSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn set_notice(e: &Env, secs: u64, caller: Address) { - caller.require_auth(); - if secs > MAX_NOTICE_SECS { - panic_with_error!(e, VaultError::NoticeTooLong); - } - if secs > upgrade::delay(e) { - panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); - } - state::set_notice(e, secs); - NoticeSet { secs }.publish(e); + pub fn set_notice(e: &Env, secs: u64) { + guards::not_lowered_after_setup(e, secs, state::notice(e)); + guards::notice_in_bounds(e, secs); + governance::set_notice(e, secs); } + /// Ends the window in which admin changes are instant. Needs every wiring + /// check `health()` reports, no shares outstanding, the vault's own books + /// empty and no admin hand-over pending. Deposits open once the feed + /// holds a valid report, and setup never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `SetupIncomplete` - A wiring check fails, shares are outstanding, or + /// the vault's own books are not empty. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn propose_upgrade(e: &Env, wasm_hash: BytesN<32>, caller: Address) { - caller.require_auth(); - upgrade::propose_wasm(e, wasm_hash); + pub fn finish_setup(e: &Env) { + governance::finish_setup(e); + } + + /// Whether setup is still open: admin changes are instant, and the vault + /// takes no request and seals or prices no epoch. + pub fn setup_open(e: &Env) -> bool { + strata_timelock::setup::is_open(e) } + /// Whether setup is open, the wiring `finish_setup` checks except the share + /// supply and the vault's own books, and the feed: whether it can price + /// now, and whether its reports outlive the notice. + pub fn health(e: &Env) -> Health { + health::health(e) + } + + /// Queues a change behind the timelock and returns its id. It waits the + /// upgrade delay, plus any time the vault spends paused. + /// + /// # Arguments + /// + /// * `change` - An upgrade, a new upgrade delay, custodian, notice, + /// wind-down delay, oracle or minimum epoch duration, a role grant or + /// revoke, or an admin hand-over. Durations in seconds. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `UpgradeDelayTooShort`, `UpgradeDelayTooLong`, + /// `UpgradeDelayBelowNotice`, `NoticeTooLong`, `NoticeAboveUpgradeDelay`, + /// `WindDownDelayTooShort`, `WindDownDelayTooLong`, `FeedNotBound`, + /// `InvalidCustodian`, `InvalidAmount` - The change is out of bounds. + /// * `UpgradeProposalExists` - An upgrade or an upgrade delay change, while + /// one of the two already waits. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds; time the vault spends + /// paused from now on moves it later. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn propose_upgrade_delay(e: &Env, secs: u64, caller: Address) { - caller.require_auth(); - upgrade::propose_delay(e, secs); + pub fn propose_change(e: &Env, change: VaultChange) -> u32 { + governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn cancel_upgrade(e: &Env, caller: Address) { - caller.require_auth(); - upgrade::cancel(e); + pub fn cancel_change(e: &Env, id: u32) { + governance::cancel(e, id); } + /// Lands a change whose delay has run, checking its bounds again. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `PausableError::EnforcedPause` - The vault is paused. + /// * `UpgradeDelayBelowNotice` - The change's delay is shorter than the + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time paused since, has not run. + /// * The bound errors of `propose_change`, checked again. + /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account + /// without the role. + /// * An `Upgrade` whose Wasm is not on the network fails the call. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `CustodianSet`, `NoticeSet`, + /// `WindDownDelaySet`, `MinEpochDurationSet`, `RoleGranted`, + /// `RoleRevoked` or `AdminTransferInitiated`. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - #[when_not_paused] - pub fn apply_upgrade(e: &Env, caller: Address) { - caller.require_auth(); - upgrade::apply(e); + pub fn apply_change(e: &Env, id: u32) { + governance::apply(e, id); } - pub fn upgrade_delay(e: &Env) -> u64 { - upgrade::delay(e) + /// Every change still waiting, with the time in seconds it may land as + /// the clock reads now. + pub fn pending_changes(e: &Env) -> Vec { + governance::pending(e) } - pub fn upgrade_proposal(e: &Env) -> Option { - upgrade::proposal(e) + /// Seconds a proposed change waits: 7 days until an `UpgradeDelay` change + /// sets another. + pub fn upgrade_delay(e: &Env) -> u64 { + upgrade::delay(e) } + /// The storage layout version, `1` until an upgrade migrates it. pub fn schema_version(e: &Env) -> u32 { upgradeable::get_schema_version(e) } + /// Sends free reserve to the custodian and returns `net_deployed` after + /// it. It never sends escrow a depositor can cancel or cash owed to + /// priced exits. The pause does not stop it. + /// + /// # Arguments + /// + /// * `caller` - A `treasury` holder. + /// * `assets` - Asset base units. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `treasury`. + /// * `WindDownActive` - The vault is winding down. + /// * `InvalidAmount` - `assets` is zero or negative. + /// * `CustodianNotSet` - No custodian is set. + /// * `ReserveCommittedToExits` - `assets` exceeds the free reserve. + /// * The asset's own error when the custodian cannot receive it. + /// + /// # Events + /// + /// * `Deployed` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "treasury")] pub fn deploy_to_custodian(e: &Env, caller: Address, assets: i128) -> i128 { treasury::deploy(e, assets) } - pub fn fund(e: &Env, from: Address, assets: i128) -> i128 { - treasury::fund(e, &from, assets) + /// Pays assets into the vault, lowering `net_deployed` by them, and + /// returns `net_deployed` after it. The custodian's capital comes back + /// this way. Anyone may fund, during a pause or a wind-down too. + /// + /// # Arguments + /// + /// * `caller` - The account paying. + /// * `assets` - Asset base units. + /// + /// # Errors + /// + /// * `InvalidAmount` - `assets` is zero or negative. + /// * `AmountTooLarge` - `net_deployed` would fall below `i128::MIN`. + /// * The asset's own error when `caller` cannot pay. + /// + /// # Events + /// + /// * `Funded` + /// + /// # Authorization + /// + /// `caller` signs. + pub fn fund(e: &Env, caller: Address, assets: i128) -> i128 { + treasury::fund(e, &caller, assets) } + /// Seconds a closed epoch waits before it can be priced. pub fn notice(e: &Env) -> u64 { state::notice(e) } + /// The id of the open epoch, which takes new requests. pub fn current_epoch(e: &Env) -> u64 { state::current_epoch(e) } + /// Ledger time, in seconds, the open epoch started (when setup finished or + /// the epoch before it was sealed). `None` before setup finishes. + pub fn epoch_opened_at(e: &Env) -> Option { + state::epoch_opened_at(e) + } + + /// An epoch: its status, deposits in asset base units, redemptions in share + /// base units, price in WAD, and close and pricing times in seconds. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. pub fn get_epoch(e: &Env, epoch_id: u64) -> Option { state::get_epoch(e, epoch_id) } - pub fn get_deposit_request( - e: &Env, - epoch_id: u64, - controller: Address, - ) -> Option { - state::get_deposit_request(e, epoch_id, &controller) + /// What pricing promised the epoch and what has been paid against it: + /// open request counts, shares in share base units, assets in asset base + /// units. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. + pub fn get_epoch_settlement(e: &Env, epoch_id: u64) -> Option { + settlement::get(e, epoch_id) + } + + /// A holder's deposit in an epoch: its amount in asset base units, and + /// whether it was claimed. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. + /// * `holder` - The depositor. + pub fn get_deposit_request(e: &Env, epoch_id: u64, holder: Address) -> Option { + state::get_deposit_request(e, epoch_id, &holder) } + /// Escrows `amount` of the asset into the open epoch and returns the + /// epoch's id. The deposit can be priced once the epoch closes and its + /// notice has run. Refused while the feed cannot price. + /// + /// # Arguments + /// + /// * `holder` - The depositor. + /// * `amount` - Asset base units. + /// + /// # Errors + /// + /// * `PausableError::EnforcedPause` - The vault is paused. + /// * `WindDownActive`, `WindDownAlreadyProposed` - A wind-down is active + /// or proposed. + /// * `SetupOpen` - Setup has not finished. + /// * `InvalidAmount` - `amount` is zero or negative. + /// * `NotAllowlisted` - `holder` fails the identity check. + /// * `Frozen` - `holder` is frozen. + /// * `FeedNotValid` - The feed is stale or the NAV is paused. + /// * `EscrowNotCovered` - The vault holds less than its pending escrow. + /// * `DepositCapExceeded` - The deposit would pass the cap. + /// * `RequestOutstanding` - `holder` has a deposit in this epoch already. + /// * `AmountTooLarge` - A total would overflow. + /// * The asset's own error when `holder` cannot pay. + /// + /// # Events + /// + /// * `DepositRequested` + /// + /// # Authorization + /// + /// `holder` signs. #[when_not_paused] - pub fn request_deposit(e: &Env, from: Address, amount: i128) -> u64 { - deposit::request(e, &from, amount) + pub fn request_deposit(e: &Env, holder: Address, amount: i128) -> u64 { + deposit::request(e, &holder, amount) } - pub fn claim_deposit(e: &Env, caller: Address, epoch_id: u64) -> i128 { - deposit::claim(e, &caller, epoch_id) + /// Mints a priced deposit's shares and returns them, in share base units. + /// Zero means none: a deposit worth less than one share base unit was + /// refunded, or a delisted holder's shares joined an exit. + /// + /// # Arguments + /// + /// * `holder` - The depositor. + /// * `epoch_id` - The epoch the deposit joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `RequestNotFound` - `holder` has no deposit in the epoch. + /// * `EpochNotFulfilled` - The epoch is not priced yet. + /// * `AlreadyClaimed` - The deposit was claimed. + /// * `Frozen` - `holder` is frozen. + /// * `ClaimNotCovered` - The free reserve cannot cover a refund yet. + /// * `RWAError::MintNotCompliant` - The compliance rules refuse the mint. + /// * `RWAError::IdentityVerificationFailed` - The vault is delisted, for a + /// conversion. + /// * The asset's own error when `holder` cannot receive a refund. + /// + /// # Events + /// + /// * `DepositClaimed`, `DepositConvertedToExit` or, for a refund, + /// `DepositRefunded`. + /// * `EpochReconciled` once the epoch's last request settles. + /// + /// # Authorization + /// + /// `holder` signs. + pub fn claim_deposit(e: &Env, holder: Address, epoch_id: u64) -> i128 { + deposit::claim(e, &holder, epoch_id) } - /// Cancels an unpriced deposit. Never blocked by the pause. - pub fn cancel_deposit(e: &Env, from: Address, epoch_id: u64) -> i128 { - deposit::cancel(e, &from, epoch_id) + /// Cancels an unpriced deposit and returns the asset base units refunded. + /// Open while the epoch has no readable price: a paused vault or NAV, or + /// an expired report, reopens it. Never blocked by the pause. + /// + /// # Arguments + /// + /// * `holder` - The depositor. + /// * `epoch_id` - The epoch the deposit joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `RequestNotFound` - `holder` has no deposit in the epoch. + /// * `AlreadyPriced` - The epoch is priced; claim instead. + /// * `PriceAvailable` - A valid report accepted after the close gives a + /// share price above zero, so the price is known, even while the notice + /// runs. + /// * `Frozen` - `holder` is frozen. + /// * `EscrowNotCovered` - The vault's balance cannot cover the refund yet. + /// * The asset's own error when `holder` cannot receive it. + /// + /// # Events + /// + /// * `DepositCancelled` + /// + /// # Authorization + /// + /// `holder` signs. + pub fn cancel_deposit(e: &Env, holder: Address, epoch_id: u64) -> i128 { + deposit::cancel(e, &holder, epoch_id) } - /// Cancels an unpriced redemption. Refused for a controller the share token - /// will not let hold shares; that controller exits through the cash claim. - pub fn cancel_redeem(e: &Env, from: Address, epoch_id: u64) -> i128 { - redeem::cancel(e, &from, epoch_id) + /// Cancels an unpriced redemption and returns its share base units. Outside + /// a wind-down the shares come back, even while the token is paused, unless + /// the holder is delisted, frozen or refused by the compliance rules; in one + /// the escrow joins the holder's entitlement, and a frozen holder is held. + /// + /// # Arguments + /// + /// * `holder` - The redeemer. + /// * `epoch_id` - The epoch the redemption joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `RequestNotFound` - `holder` has no redemption in the epoch. + /// * `AlreadyPriced` - The epoch is priced; claim instead. + /// * `PriceAvailable` - A valid report accepted after the close gives a + /// share price above zero, so the price is known, even while the notice + /// runs. + /// * `NotAllowlisted` - `holder` is delisted. + /// * `Frozen` - `holder` is frozen. + /// * `HolderNotEligible` - The compliance rules refuse the return. + /// + /// # Events + /// + /// * `RedeemCancelled`, or `RedemptionSurrendered` in a wind-down. + /// + /// # Authorization + /// + /// `holder` signs. + pub fn cancel_redeem(e: &Env, holder: Address, epoch_id: u64) -> i128 { + redeem::cancel(e, &holder, epoch_id) } - pub fn request_redeem(e: &Env, from: Address, shares: i128) -> u64 { - redeem::request(e, &from, shares) + /// Escrows `shares` into the open epoch's redemptions and returns the + /// epoch's id. Open to a delisted holder, and while the vault or the token + /// is paused. + /// + /// # Arguments + /// + /// * `holder` - The redeemer. + /// * `shares` - Share base units. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `SetupOpen` - Setup has not finished. + /// * `InvalidAmount` - `shares` is zero or negative. + /// * `Frozen` - `holder` is frozen, or `shares` needs frozen shares. + /// * `RequestOutstanding` - `holder` has a redemption in this epoch + /// already. + /// * `AmountTooLarge` - A total would overflow. + /// * `RWAError::InsufficientBalance` - `shares` exceeds the balance. + /// + /// # Events + /// + /// * `RedeemRequested`, after the token's `Transfer`. + /// + /// # Authorization + /// + /// `holder` signs. + pub fn request_redeem(e: &Env, holder: Address, shares: i128) -> u64 { + redeem::request(e, &holder, shares) } - pub fn get_redeem_request( - e: &Env, - epoch_id: u64, - controller: Address, - ) -> Option { - state::get_redeem_request(e, epoch_id, &controller) + /// A holder's redemption in an epoch: its shares in share base units, and + /// whether it was claimed. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. + /// * `holder` - The redeemer. + pub fn get_redeem_request(e: &Env, epoch_id: u64, holder: Address) -> Option { + state::get_redeem_request(e, epoch_id, &holder) } - pub fn claim_redeem(e: &Env, caller: Address, epoch_id: u64) -> i128 { - redeem::claim(e, &caller, epoch_id) + /// Pays a priced redemption and returns the asset base units paid. Open + /// while paused and to a delisted holder. Outside a wind-down, shares + /// priced at nothing come back instead. + /// + /// # Arguments + /// + /// * `holder` - The redeemer. + /// * `epoch_id` - The epoch the redemption joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `RequestNotFound` - `holder` has no redemption in the epoch. + /// * `EpochNotFulfilled` - The epoch is not priced yet. + /// * `AlreadyClaimed` - The redemption was claimed. + /// * `Frozen` - `holder` is frozen. + /// * `ClaimNotCovered` - The liquid reserve cannot pay it yet. + /// * `HolderNotEligible` - Shares priced at nothing cannot come back. + /// * The asset's own error when `holder` cannot receive it. + /// + /// # Events + /// + /// * `RedeemClaimed`, or `RedeemCancelled` when the shares come back. + /// * `EpochReconciled` once the epoch's last request settles. + /// + /// # Authorization + /// + /// `holder` signs. + pub fn claim_redeem(e: &Env, holder: Address, epoch_id: u64) -> i128 { + redeem::claim(e, &holder, epoch_id) } - #[only_role(caller, "manager")] + /// Seals the open epoch, opens the next, and returns the sealed epoch's + /// id. The manager seals at any time, anyone else once the open epoch has + /// been open for the minimum epoch duration. It can be priced once its + /// notice has run. + /// + /// # Arguments + /// + /// * `caller` - A `manager` holder, or any account once the minimum has + /// run. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `manager` and no + /// minimum epoch duration is set. + /// * `MinEpochDurationNotElapsed` - `caller` lacks `manager` and the open + /// epoch has not run the minimum. + /// * `WindDownActive` - The vault is winding down. + /// * `SetupOpen` - Setup has not finished. + /// * `AmountTooLarge` - The notice would end past the largest ledger time. + /// + /// # Events + /// + /// * `EpochClosed` + /// + /// # Authorization + /// + /// `caller` signs. pub fn close_epoch(e: &Env, caller: Address) -> u64 { + guards::allowed_to_seal(e, &caller); + caller.require_auth(); epoch::close(e) } + /// Prices a sealed epoch at the feed's standing report and returns the + /// share price in WAD: asset base units per share base unit. Open to + /// anyone once the notice has run and a report accepted after the close + /// is valid. + /// + /// # Arguments + /// + /// * `epoch_id` - A sealed epoch. + /// + /// # Errors + /// + /// * `PausableError::EnforcedPause` - The vault is paused. + /// * `WindDownActive` - The vault is winding down. + /// * `SetupOpen` - Setup has not finished. + /// * `EpochNotFound` - No such epoch. + /// * `EpochNotPending` - The epoch is open or already priced. + /// * `NoticeNotElapsed` - The notice has not run. + /// * `FeedNotValid` - The feed is paused, stale or fails to answer. + /// * `AttestationBeforeClose` - The report predates the close. + /// * `InvalidSharePrice` - The feed returned zero or less. + /// * `AmountTooLarge` - A total or a conversion would overflow. + /// + /// # Events + /// + /// * `EpochFulfilled` + /// * `EpochReconciled` when the epoch has no open request. + /// + /// # Authorization + /// + /// None: anyone may call. #[when_not_paused] pub fn fulfill_epoch(e: &Env, epoch_id: u64) -> i128 { epoch::fulfill(e, epoch_id) @@ -380,9 +1101,137 @@ impl AsyncVault { #[contractimpl(contracttrait)] impl AccessControl for AsyncVault { + /// Grants `role` to `account` at once. Only during setup; after it, propose + /// a `GrantRole` change. + /// + /// # Arguments + /// + /// * `account` - The account that gets the role. + /// * `role` - `manager`, `treasury` or `guardian`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// + /// # Events + /// + /// * `RoleGranted`, unless `account` already holds `role`. + /// + /// # Authorization + /// + /// `caller` signs. + fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { + guards::direct_change_allowed(e); + access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); + } + + /// Takes `role` from `account`. Instant for every role but `manager`: + /// taking `treasury` or `guardian` away moves no value. After setup, + /// revoking `manager` waits like a grant, because without it no epoch + /// closes and no exit is priced. + /// + /// # Arguments + /// + /// * `account` - The account that loses the role. + /// * `role` - `manager`, `treasury` or `guardian`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - `manager`, after setup. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// * `AccessControlError::RoleNotHeld` - `account` does not hold `role`. + /// + /// # Events + /// + /// * `RoleRevoked` + /// + /// # Authorization + /// + /// `caller` signs. + fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { + guards::direct_revoke_allowed(e, &role); + access_control::revoke_role(e, &account, &role, &caller); + } + + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. + fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { + guards::direct_hand_over_allowed(e, live_until_ledger); + access_control::transfer_admin_role(e, &new_admin, live_until_ledger); + } + + /// Makes the nominee of a pending hand-over the admin. Refused while the + /// vault winds down: no admin power changes hands then. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `RoleTransferError::NoPendingTransfer` - No offer is pending. + /// * `RoleTransferError::TransferExpired` - The offer's last ledger has + /// passed. + /// + /// # Events + /// + /// * `AdminTransferCompleted` + /// + /// # Authorization + /// + /// The nominee signs. + fn accept_admin_transfer(e: &Env) { + guards::not_winding_down(e); + access_control::accept_admin_transfer(e); + } + + /// Refused. A role admin could grant that role with no delay. + /// + /// # Arguments + /// + /// * `role`, `admin_role` - Not read. + /// + /// # Errors + /// + /// * `UseTimelock` - Always. + fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { + panic_with_error!(e, VaultError::UseTimelock); + } + /// Refused. A vault without an admin could never be unpaused, never set a - /// custodian, never rotate a role and never be upgraded again. - /// `transfer_admin_role` and `accept_admin_transfer` are the way out. + /// custodian, never rotate a role and never be upgraded again. The way out + /// is a hand-over: propose and apply an `Admin` change + /// (`transfer_admin_role` during setup), then the nominee calls + /// `accept_admin_transfer`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, VaultError::AdminRequired); } @@ -390,17 +1239,97 @@ impl AccessControl for AsyncVault { #[contractimpl(contracttrait)] impl Pausable for AsyncVault { + /// Pauses the vault: no deposit request, no pricing, and no timelocked + /// change on the kit lands; pending changes wait longer by the time + /// paused. Claims, cancellations and redemption requests stay open. + /// + /// # Arguments + /// + /// * `caller` - A `guardian` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `guardian`. + /// * `PausableError::EnforcedPause` - The vault is already paused. + /// + /// # Events + /// + /// * `Paused` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "guardian")] fn pause(e: &Env, caller: Address) { pausable::pause(e); - upgrade::on_pause(e); + strata_timelock::pause::start(e); } - #[only_admin] + /// Lifts the vault's pause. + /// + /// # Arguments + /// + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `caller` is not the admin. + /// * `PausableError::ExpectedPause` - The vault is not paused. + /// + /// # Events + /// + /// * `Unpaused` + /// + /// # Authorization + /// + /// `caller` signs. fn unpause(e: &Env, caller: Address) { + guards::caller_is_admin(e, &caller); caller.require_auth(); pausable::unpause(e); - upgrade::on_unpause(e); + strata_timelock::pause::end(e); + } +} + +#[contractimpl(contracttrait)] +impl ClockSource for AsyncVault { + /// The clock the kit's timelocks run on: the upgrade delay and the notice + /// in seconds, the seconds spent paused, and whether the vault is paused + /// or winding down. + fn timelock_clock(e: &Env) -> Clock { + governance::clock(e) + } +} + +impl TimelockHost for AsyncVault { + fn clock(e: &Env) -> Result { + Ok(governance::clock(e)) + } +} + +/// Stands in for a later vault version in the upgrade tests. Never deployed. +#[cfg(feature = "upgrade_probe")] +#[soroban_sdk::contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ProbeError { + AlreadyMigrated = 6100, +} + +#[cfg(feature = "upgrade_probe")] +#[contractimpl] +impl AsyncVault { + #[only_admin] + pub fn migrate(e: &Env) { + strata_storage::bump_instance(e); + if upgradeable::get_schema_version(e) >= 2 { + panic_with_error!(e, ProbeError::AlreadyMigrated); + } + upgradeable::set_schema_version(e, 2); + } + + pub fn probe(_e: &Env) -> bool { + true } } diff --git a/contracts/async-vault/src/lock_policy.rs b/contracts/async-vault/src/lock_policy.rs new file mode 100644 index 00000000..ec51cd00 --- /dev/null +++ b/contracts/async-vault/src/lock_policy.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{Address, Env}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{identity_verification::IdentityVerifierClient, RWATokenClient}, +}; + +use crate::error::VaultError; +use crate::keys::DataKey; +use crate::state; + +/// Where a holder stands with the share token. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Standing { + Listed, + Delisted, + Frozen, +} + +/// The holder's standing, asked of the share token and its identity verifier, +/// so the vault and the token never disagree. +pub(crate) fn standing(e: &Env, holder: &Address) -> Standing { + let token = RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)); + if token.is_frozen(holder) { + return Standing::Frozen; + } + let verifier = token.identity_verifier(); + match IdentityVerifierClient::new(e, &verifier).try_verify_identity(holder) { + Ok(Ok(())) => Standing::Listed, + _ => Standing::Delisted, + } +} + +/// The vault's entrypoints that move a holder's value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Exit { + RequestDeposit, + ClaimDeposit, + CancelRedeem, + CancelDeposit, + RequestRedeem, + ClaimRedeem, + ClaimWindDown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Decision { + Proceed, + Refuse, + /// The priced shares join the holder's redemption in the open epoch. + ConvertToExit, + /// The shares join the holder's wind-down entitlement. + Surrender, + /// Nothing moves until the freeze is lifted. + Hold, +} + +/// What `exit` does for a holder in `standing`. No storage, no calls. +pub(crate) fn decide(exit: Exit, standing: Standing, winding_down: bool) -> Decision { + match (exit, standing, winding_down) { + (Exit::RequestDeposit, Standing::Listed, _) => Decision::Proceed, + (Exit::RequestDeposit, Standing::Delisted | Standing::Frozen, _) => Decision::Refuse, + + (Exit::ClaimDeposit, Standing::Listed, _) => Decision::Proceed, + (Exit::ClaimDeposit, Standing::Delisted, false) => Decision::ConvertToExit, + (Exit::ClaimDeposit, Standing::Delisted, true) => Decision::Surrender, + (Exit::ClaimDeposit, Standing::Frozen, _) => Decision::Hold, + + (Exit::CancelRedeem, Standing::Listed, false) => Decision::Proceed, + (Exit::CancelRedeem, Standing::Delisted, false) => Decision::Refuse, + (Exit::CancelRedeem, Standing::Listed | Standing::Delisted, true) => Decision::Surrender, + (Exit::CancelRedeem, Standing::Frozen, _) => Decision::Hold, + + (Exit::CancelDeposit, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::CancelDeposit, Standing::Frozen, _) => Decision::Hold, + + (Exit::RequestRedeem, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::RequestRedeem, Standing::Frozen, _) => Decision::Hold, + + (Exit::ClaimRedeem, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::ClaimRedeem, Standing::Frozen, _) => Decision::Hold, + + (Exit::ClaimWindDown, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::ClaimWindDown, Standing::Frozen, _) => Decision::Hold, + } +} + +/// The error an exit fails with when `decide` refuses or holds it. +pub(crate) fn refused_with(standing: Standing) -> VaultError { + match standing { + Standing::Frozen => VaultError::Frozen, + Standing::Listed | Standing::Delisted => VaultError::NotAllowlisted, + } +} + +/// Shares the holder can move: the balance less any partial freeze. +pub(crate) fn free_shares(e: &Env, holder: &Address) -> i128 { + let token = state::get_addr(e, &DataKey::ShareToken); + let balance = FungibleTokenClient::new(e, &token).balance(holder); + let frozen = RWATokenClient::new(e, &token).get_frozen_tokens(holder); + (balance - frozen).max(0) +} diff --git a/contracts/async-vault/src/pricing.rs b/contracts/async-vault/src/pricing.rs index e9ca7849..d5ca6107 100644 --- a/contracts/async-vault/src/pricing.rs +++ b/contracts/async-vault/src/pricing.rs @@ -1,6 +1,6 @@ -use bindings::OracleFeedClient; use soroban_sdk::Env; use stellar_contract_utils::math::{i128_fixed_point::checked_mul_div_floor, wad::WAD_SCALE}; +use strata_bindings::OracleFeedClient; use crate::error::VaultError; use crate::keys::DataKey; @@ -9,8 +9,9 @@ use crate::state; /// Defines the pricing interface for epoch share price resolution and conversions. /// A deployment or vault variant selects or implements a scheme conforming to this trait. pub trait PricingScheme { - /// Resolves the share price for an epoch being fulfilled. - /// Returns the share price in WAD scale (18 decimals), or a VaultError if invalid. + /// Resolves the share price an epoch would take now, in WAD scale (18 + /// decimals), or a VaultError if invalid. Cancellation calls it too, to + /// decide whether a price is readable, so it must not write state. fn resolve_share_price(e: &Env) -> Result; /// Converts asset amount to share amount at `share_price`, rounding down @@ -44,7 +45,9 @@ pub(crate) type Pricing = DirectUnitPricing; impl PricingScheme for DirectUnitPricing { fn resolve_share_price(e: &Env) -> Result { let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle)); - let share_price = feed.nav_per_share(); + let Ok(Ok(share_price)) = feed.try_nav_per_share() else { + return Err(VaultError::FeedNotValid); + }; if share_price <= 0 { return Err(VaultError::InvalidSharePrice); } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index c85e5e72..72721b7c 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -1,28 +1,25 @@ -use bindings::ShareClient; use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; +use stellar_tokens::rwa::RWATokenClient; -use crate::epoch; use crate::error::VaultError; -use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested}; +use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered}; +use crate::guards; use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; -use crate::state::{self, EpochStatus, RedeemRequest}; -use crate::treasury; +use crate::settlement; +use crate::state::{self, RedeemRequest}; use crate::wind_down; -pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { - wind_down::refuse_if_active(e); - from.require_auth(); - - if shares <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - +pub(crate) fn request(e: &Env, holder: &Address, shares: i128) -> u64 { + guards::not_winding_down(e); + guards::setup_finished(e); + holder.require_auth(); + guards::positive_amount(e, shares); + guards::allowed_to(e, Exit::RequestRedeem, holder); + guards::needs_no_frozen_shares(e, holder, shares); let epoch_id = state::current_epoch(e); - - if state::get_redeem_request(e, epoch_id, from).is_some() { - panic_with_error!(e, VaultError::RequestOutstanding); - } + guards::no_open_redeem(e, epoch_id, holder); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -35,20 +32,21 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { state::set_redeem_request( e, epoch_id, - from, + holder, &RedeemRequest { shares, claimed: false, }, ); state::set_epoch(e, epoch_id, &epoch); + settlement::on_redeem_requested(e, epoch_id); let vault = e.current_contract_address(); let share_token = state::get_addr(e, &DataKey::ShareToken); - ShareClient::new(e, &share_token).forced_transfer(from, &vault, &shares, &vault); + RWATokenClient::new(e, &share_token).forced_transfer(holder, &vault, &shares, &vault); RedeemRequested { - controller: from.clone(), + holder: holder.clone(), epoch: epoch_id, shares, } @@ -57,111 +55,157 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { epoch_id } -pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { - caller.require_auth(); - +pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { + holder.require_auth(); let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status != EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::EpochNotFulfilled); - } - - let mut request = state::get_redeem_request(e, epoch_id, caller) + let mut request = state::get_redeem_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - - if request.claimed { - panic_with_error!(e, VaultError::AlreadyClaimed); - } + guards::epoch_fulfilled(e, &epoch); + guards::not_claimed(e, request.claimed); + guards::allowed_to(e, Exit::ClaimRedeem, holder); let assets = Pricing::redeem_assets(e, request.shares, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); state::set_pending_burn_shares( e, - state::pending_burn_shares(e).saturating_sub(request.shares), + state::reduce(e, state::pending_burn_shares(e), request.shares), ); - if assets == 0 { - return_shares(e, caller, epoch_id, request.shares); + // In a wind-down they are burned below instead: its snapshot left them out. + if assets == 0 && !wind_down::is_active(e) { + return_shares(e, holder, epoch_id, request.shares); + settlement::on_redeem_claimed(e, epoch_id, 0); return 0; } - // Against liquid reserve, not the raw balance: escrow a depositor can still - // cancel is not available to pay an exit. - if treasury::liquid_reserve(e) < assets { - panic_with_error!(e, VaultError::ClaimNotCovered); - } + guards::payout_covered(e, assets); let vault = e.current_contract_address(); let asset = state::get_addr(e, &DataKey::Asset); request.claimed = true; - state::set_redeem_request(e, epoch_id, caller, &request); - state::set_committed(e, state::committed(e).saturating_sub(assets)); + state::set_redeem_request(e, epoch_id, holder, &request); + state::set_committed(e, state::reduce(e, state::committed(e), assets)); - TokenClient::new(e, &asset).transfer(&vault, caller, &assets); - ShareClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( + if assets > 0 { + TokenClient::new(e, &asset).transfer(&vault, holder, &assets); + } + RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( &vault, &request.shares, &vault, ); RedeemClaimed { - controller: caller.clone(), + holder: holder.clone(), epoch: epoch_id, shares: request.shares, assets, } .publish(e); + settlement::on_redeem_claimed(e, epoch_id, assets); assets } -/// Moves escrowed shares back to the controller and clears the request. The -/// checked transfer is deliberate: the share token decides whether this holder -/// may receive a regulated share, so a frozen or de-listed controller is refused -/// here and exits through the cash claim instead. -fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { +/// Queues shares the vault already holds, adding to any redemption open in the epoch. +pub(crate) fn queue_escrowed(e: &Env, holder: &Address, shares: i128) -> u64 { + let epoch_id = state::current_epoch(e); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - epoch.total_shares_redeeming -= shares; + epoch.total_shares_redeeming = epoch + .total_shares_redeeming + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + + let request = match state::get_redeem_request(e, epoch_id, holder) { + Some(mut open) => { + open.shares = open + .shares + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + open + } + None => { + settlement::on_redeem_requested(e, epoch_id); + RedeemRequest { + shares, + claimed: false, + } + } + }; + state::set_redeem_request(e, epoch_id, holder, &request); state::set_epoch(e, epoch_id, &epoch); - state::remove_redeem_request(e, epoch_id, controller); + epoch_id +} - let vault = e.current_contract_address(); - ShareClient::new(e, &state::get_addr(e, &DataKey::ShareToken)) - .transfer(&vault, controller, &shares); +/// Moves escrowed shares back to the holder and clears the request. +fn return_shares(e: &Env, holder: &Address, epoch_id: u64, shares: i128) { + let mut epoch = state::get_epoch(e, epoch_id) + .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); + epoch.total_shares_redeeming = state::reduce(e, epoch.total_shares_redeeming, shares); + state::set_epoch(e, epoch_id, &epoch); + state::remove_redeem_request(e, epoch_id, holder); + + deliver(e, holder, shares); RedeemCancelled { - controller: controller.clone(), + holder: holder.clone(), epoch: epoch_id, shares, } .publish(e); } -pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { - from.require_auth(); +/// Checks the receiver, then moves as manager, so the vault's listing and token pause never apply. +fn deliver(e: &Env, holder: &Address, shares: i128) { + guards::eligible_to_receive(e, holder, shares); + let vault = e.current_contract_address(); + let token = state::get_addr(e, &DataKey::ShareToken); + RWATokenClient::new(e, &token).forced_transfer(&vault, holder, &shares, &vault); +} +pub(crate) fn cancel(e: &Env, holder: &Address, epoch_id: u64) -> i128 { + holder.require_auth(); let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status == EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::AlreadyPriced); + let request = state::get_redeem_request(e, epoch_id, holder) + .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); + guards::not_priced(e, &epoch); + guards::no_price_available(e, &epoch); + let standing = lock_policy::standing(e, holder); + let decision = lock_policy::decide(Exit::CancelRedeem, standing, wind_down::is_active(e)); + guards::not_refused(e, decision, standing); + + match decision { + Decision::Proceed => return_shares(e, holder, epoch_id, request.shares), + Decision::Surrender => surrender_escrow(e, holder, epoch_id, request.shares), + Decision::Refuse | Decision::Hold | Decision::ConvertToExit => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } } + settlement::on_redeem_cancelled(e, epoch_id); + request.shares +} - // Cancellation is an escape hatch, not a choice. Once the feed can price a - // sealed epoch, the price is knowable and the only way out is to take it. - // Fulfilment is closed during a wind-down, so this epoch will never take a - // price. There is nothing to decline. - if !wind_down::is_active(e) && epoch::is_priceable(e, &epoch) { - panic_with_error!(e, VaultError::PriceAvailable); - } +/// Burns the escrow into the entitlement, so no token pause or allowlist place can hold it. +fn surrender_escrow(e: &Env, holder: &Address, epoch_id: u64, shares: i128) { + let mut epoch = state::get_epoch(e, epoch_id) + .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); + epoch.total_shares_redeeming = state::reduce(e, epoch.total_shares_redeeming, shares); + state::set_epoch(e, epoch_id, &epoch); + state::remove_redeem_request(e, epoch_id, holder); - let request = state::get_redeem_request(e, epoch_id, from) - .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); + let vault = e.current_contract_address(); + RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn(&vault, &shares, &vault); + wind_down::credit(e, holder, shares); - return_shares(e, from, epoch_id, request.shares); - request.shares + RedemptionSurrendered { + holder: holder.clone(), + epoch: epoch_id, + shares, + } + .publish(e); } diff --git a/contracts/async-vault/src/roles.rs b/contracts/async-vault/src/roles.rs index dbedcab8..3e095562 100644 --- a/contracts/async-vault/src/roles.rs +++ b/contracts/async-vault/src/roles.rs @@ -1,9 +1,9 @@ use soroban_sdk::{contracttype, symbol_short, Address, Symbol}; -/// Closes an epoch and sets its share price. Held by the Manager contract. +/// Seals the open epoch. Not the share token's `manager`, which the vault holds. pub const MANAGER_ROLE: Symbol = symbol_short!("manager"); -/// Moves capital to the custodian and funds the vault back. +/// Sends free reserve to the custodian. Anyone may fund the vault back. pub const TREASURY_ROLE: Symbol = symbol_short!("treasury"); pub const GUARDIAN_ROLE: Symbol = symbol_short!("guardian"); diff --git a/contracts/async-vault/src/settlement.rs b/contracts/async-vault/src/settlement.rs new file mode 100644 index 00000000..9db3d948 --- /dev/null +++ b/contracts/async-vault/src/settlement.rs @@ -0,0 +1,126 @@ +use soroban_sdk::{contracttype, panic_with_error, Env}; + +use crate::error::VaultError; +use crate::event::EpochReconciled; +use crate::keys::DataKey; +use crate::state::{self, EpochStatus}; + +/// What pricing promised an epoch's requests and what has been paid against +/// it. Every claim floors, so the two drift apart by a few units; once the +/// last request settles the remainder leaves `pending_mint_shares` and +/// `committed`, and the cash stays in the reserve as fund money. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct EpochSettlement { + pub deposits_open: u32, + pub redeems_open: u32, + pub shares_promised: i128, + pub shares_settled: i128, + pub assets_promised: i128, + pub assets_settled: i128, + pub reconciled: bool, +} + +pub(crate) fn get(e: &Env, epoch: u64) -> Option { + strata_storage::get_persistent(e, &DataKey::Settlement(epoch)) +} + +fn load(e: &Env, epoch: u64) -> EpochSettlement { + get(e, epoch).unwrap_or_default() +} + +fn save(e: &Env, epoch: u64, s: &EpochSettlement) { + strata_storage::set_persistent(e, &DataKey::Settlement(epoch), s); +} + +fn open_one(e: &Env, open: u32) -> u32 { + open.checked_add(1) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) +} + +fn close_one(e: &Env, open: u32) -> u32 { + open.checked_sub(1) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AccountingMismatch)) +} + +fn add(e: &Env, total: i128, amount: i128) -> i128 { + total + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) +} + +pub(crate) fn on_deposit_requested(e: &Env, epoch: u64) { + let mut s = load(e, epoch); + s.deposits_open = open_one(e, s.deposits_open); + save(e, epoch, &s); +} + +pub(crate) fn on_deposit_cancelled(e: &Env, epoch: u64) { + let mut s = load(e, epoch); + s.deposits_open = close_one(e, s.deposits_open); + save(e, epoch, &s); +} + +pub(crate) fn on_redeem_requested(e: &Env, epoch: u64) { + let mut s = load(e, epoch); + s.redeems_open = open_one(e, s.redeems_open); + save(e, epoch, &s); +} + +pub(crate) fn on_redeem_cancelled(e: &Env, epoch: u64) { + let mut s = load(e, epoch); + s.redeems_open = close_one(e, s.redeems_open); + save(e, epoch, &s); +} + +/// Called once the epoch is stored as fulfilled. +pub(crate) fn on_priced(e: &Env, epoch: u64, shares_promised: i128, assets_promised: i128) { + let mut s = load(e, epoch); + s.shares_promised = shares_promised; + s.assets_promised = assets_promised; + reconcile_if_settled(e, epoch, &mut s); + save(e, epoch, &s); +} + +/// `shares` is zero for a claim that floored to nothing and was refunded. +pub(crate) fn on_deposit_claimed(e: &Env, epoch: u64, shares: i128) { + let mut s = load(e, epoch); + s.deposits_open = close_one(e, s.deposits_open); + s.shares_settled = add(e, s.shares_settled, shares); + reconcile_if_settled(e, epoch, &mut s); + save(e, epoch, &s); +} + +/// `assets` is zero for a claim that paid nothing and returned the shares. +pub(crate) fn on_redeem_claimed(e: &Env, epoch: u64, assets: i128) { + let mut s = load(e, epoch); + s.redeems_open = close_one(e, s.redeems_open); + s.assets_settled = add(e, s.assets_settled, assets); + reconcile_if_settled(e, epoch, &mut s); + save(e, epoch, &s); +} + +fn reconcile_if_settled(e: &Env, epoch: u64, s: &mut EpochSettlement) { + if s.reconciled || s.deposits_open > 0 || s.redeems_open > 0 { + return; + } + let priced = state::get_epoch(e, epoch).is_some_and(|ep| ep.status == EpochStatus::Fulfilled); + if !priced { + return; + } + let share_dust = state::reduce(e, s.shares_promised, s.shares_settled); + let cash_dust = state::reduce(e, s.assets_promised, s.assets_settled); + state::set_pending_mint_shares( + e, + state::reduce(e, state::pending_mint_shares(e), share_dust), + ); + state::set_committed(e, state::reduce(e, state::committed(e), cash_dust)); + s.reconciled = true; + + EpochReconciled { + epoch, + share_dust, + cash_dust, + } + .publish(e); +} diff --git a/contracts/async-vault/src/state.rs b/contracts/async-vault/src/state.rs index b201dce1..c137978c 100644 --- a/contracts/async-vault/src/state.rs +++ b/contracts/async-vault/src/state.rs @@ -5,6 +5,15 @@ use crate::keys::DataKey; pub(crate) const FIRST_EPOCH: u64 = 1; +/// Takes `amount` off a running total that can never be negative. A result +/// below zero means the books disagree, so it fails loudly. +pub(crate) fn reduce(e: &Env, total: i128, amount: i128) -> i128 { + match total.checked_sub(amount) { + Some(left) if left >= 0 => left, + _ => panic_with_error!(e, VaultError::AccountingMismatch), + } +} + #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum EpochStatus { @@ -43,141 +52,141 @@ pub struct RedeemRequest { } pub(crate) fn set_addr(e: &Env, key: &DataKey, addr: &Address) { - storage::set_instance(e, key, addr); + strata_storage::set_instance(e, key, addr); } pub(crate) fn get_addr(e: &Env, key: &DataKey) -> Address { - storage::get_instance(e, key) + strata_storage::get_instance(e, key) .unwrap_or_else(|| panic_with_error!(e, VaultError::NotInitialized)) } pub(crate) fn get_addr_opt(e: &Env, key: &DataKey) -> Option
      { - storage::get_instance(e, key) + strata_storage::get_instance(e, key) } pub(crate) fn set_net_deployed(e: &Env, assets: i128) { - storage::set_instance(e, &DataKey::NetDeployed, &assets); + strata_storage::set_instance(e, &DataKey::NetDeployed, &assets); } pub(crate) fn net_deployed(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::NetDeployed).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::NetDeployed).unwrap_or(0) } pub(crate) fn set_current_epoch(e: &Env, id: u64) { - storage::set_instance(e, &DataKey::CurrentEpoch, &id); + strata_storage::set_instance(e, &DataKey::CurrentEpoch, &id); } pub(crate) fn current_epoch(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::CurrentEpoch) + strata_storage::get_instance(e, &DataKey::CurrentEpoch) .unwrap_or_else(|| panic_with_error!(e, VaultError::NotInitialized)) } pub(crate) fn set_notice(e: &Env, secs: u64) { - storage::set_instance(e, &DataKey::Notice, &secs); + strata_storage::set_instance(e, &DataKey::Notice, &secs); } /// Seconds an epoch waits after closing before it can be priced. Absent means /// none, so a vault deployed without one behaves as it always did. pub(crate) fn notice(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::Notice).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::Notice).unwrap_or(0) +} + +pub(crate) fn set_epoch_opened_at(e: &Env, at: u64) { + strata_storage::set_instance(e, &DataKey::EpochOpenedAt, &at); +} + +/// Ledger time the open epoch started. Absent before setup finishes, and on a +/// vault upgraded from a release that did not record it. +pub(crate) fn epoch_opened_at(e: &Env) -> Option { + strata_storage::get_instance(e, &DataKey::EpochOpenedAt) +} + +pub(crate) fn set_min_epoch_duration(e: &Env, secs: u64) { + strata_storage::set_instance(e, &DataKey::MinEpochDuration, &secs); +} + +pub(crate) fn min_epoch_duration(e: &Env) -> Option { + strata_storage::get_instance(e, &DataKey::MinEpochDuration) } pub(crate) fn set_epoch(e: &Env, id: u64, epoch: &EpochInfo) { - storage::set_persistent(e, &DataKey::Epoch(id), epoch); + strata_storage::set_persistent(e, &DataKey::Epoch(id), epoch); } pub(crate) fn get_epoch(e: &Env, id: u64) -> Option { - storage::get_persistent(e, &DataKey::Epoch(id)) + strata_storage::get_persistent(e, &DataKey::Epoch(id)) } -pub(crate) fn set_deposit_request( - e: &Env, - epoch: u64, - controller: &Address, - request: &DepositRequest, -) { - storage::set_persistent(e, &DataKey::UserDeposit(epoch, controller.clone()), request); +pub(crate) fn set_deposit_request(e: &Env, epoch: u64, holder: &Address, request: &DepositRequest) { + strata_storage::set_persistent(e, &DataKey::UserDeposit(epoch, holder.clone()), request); } -pub(crate) fn get_deposit_request( - e: &Env, - epoch: u64, - controller: &Address, -) -> Option { - storage::get_persistent(e, &DataKey::UserDeposit(epoch, controller.clone())) +pub(crate) fn get_deposit_request(e: &Env, epoch: u64, holder: &Address) -> Option { + strata_storage::get_persistent(e, &DataKey::UserDeposit(epoch, holder.clone())) } -pub(crate) fn set_redeem_request( - e: &Env, - epoch: u64, - controller: &Address, - request: &RedeemRequest, -) { - storage::set_persistent(e, &DataKey::UserRedeem(epoch, controller.clone()), request); +pub(crate) fn set_redeem_request(e: &Env, epoch: u64, holder: &Address, request: &RedeemRequest) { + strata_storage::set_persistent(e, &DataKey::UserRedeem(epoch, holder.clone()), request); } -pub(crate) fn get_redeem_request( - e: &Env, - epoch: u64, - controller: &Address, -) -> Option { - storage::get_persistent(e, &DataKey::UserRedeem(epoch, controller.clone())) +pub(crate) fn get_redeem_request(e: &Env, epoch: u64, holder: &Address) -> Option { + strata_storage::get_persistent(e, &DataKey::UserRedeem(epoch, holder.clone())) } /// Cash owed to holders whose exit is priced but not yet claimed. pub(crate) fn set_committed(e: &Env, assets: i128) { - storage::set_instance(e, &DataKey::Committed, &assets); + strata_storage::set_instance(e, &DataKey::Committed, &assets); } pub(crate) fn committed(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::Committed).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::Committed).unwrap_or(0) } /// Deposit escrow across every unpriced epoch, still cancellable by the /// investor. Pricing an epoch moves its share of this into the reserve. pub(crate) fn set_cancellable_escrow(e: &Env, assets: i128) { - storage::set_instance(e, &DataKey::CancellableEscrow, &assets); + strata_storage::set_instance(e, &DataKey::CancellableEscrow, &assets); } pub(crate) fn cancellable_escrow(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::CancellableEscrow).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::CancellableEscrow).unwrap_or(0) } -pub(crate) fn remove_deposit_request(e: &Env, epoch: u64, controller: &Address) { +pub(crate) fn remove_deposit_request(e: &Env, epoch: u64, holder: &Address) { e.storage() .persistent() - .remove(&DataKey::UserDeposit(epoch, controller.clone())); + .remove(&DataKey::UserDeposit(epoch, holder.clone())); } -pub(crate) fn remove_redeem_request(e: &Env, epoch: u64, controller: &Address) { +pub(crate) fn remove_redeem_request(e: &Env, epoch: u64, holder: &Address) { e.storage() .persistent() - .remove(&DataKey::UserRedeem(epoch, controller.clone())); + .remove(&DataKey::UserRedeem(epoch, holder.clone())); } pub(crate) fn set_pending_mint_shares(e: &Env, shares: i128) { - storage::set_instance(e, &DataKey::PendingMintShares, &shares); + strata_storage::set_instance(e, &DataKey::PendingMintShares, &shares); } pub(crate) fn pending_mint_shares(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::PendingMintShares).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::PendingMintShares).unwrap_or(0) } pub(crate) fn set_pending_burn_shares(e: &Env, shares: i128) { - storage::set_instance(e, &DataKey::PendingBurnShares, &shares); + strata_storage::set_instance(e, &DataKey::PendingBurnShares, &shares); } pub(crate) fn pending_burn_shares(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::PendingBurnShares).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::PendingBurnShares).unwrap_or(0) } pub(crate) fn deposit_cap(e: &Env) -> Option { - storage::get_instance(e, &DataKey::DepositCap) + strata_storage::get_instance(e, &DataKey::DepositCap) } pub(crate) fn set_deposit_cap(e: &Env, cap: Option) { match cap { - Some(val) => storage::set_instance(e, &DataKey::DepositCap, &val), + Some(val) => strata_storage::set_instance(e, &DataKey::DepositCap, &val), None => e.storage().instance().remove(&DataKey::DepositCap), } } diff --git a/contracts/async-vault/src/test/cancel.rs b/contracts/async-vault/src/test/cancel.rs index 8cf93ca2..e65d900d 100644 --- a/contracts/async-vault/src/test/cancel.rs +++ b/contracts/async-vault/src/test/cancel.rs @@ -67,6 +67,33 @@ fn a_priced_epoch_is_not_cancellable() { ); } +#[test] +fn cancelling_without_a_request_finds_no_request_once_a_price_is_known() { + let f = setup(); + let stranger = Address::generate(&f.e); + + let epoch = f.close_epoch(); + f.attest(wad(2)); + refused( + f.vault.try_cancel_deposit(&stranger, &epoch), + VaultError::RequestNotFound, + ); + refused( + f.vault.try_cancel_redeem(&stranger, &epoch), + VaultError::RequestNotFound, + ); + + f.vault.fulfill_epoch(&epoch); + refused( + f.vault.try_cancel_deposit(&stranger, &epoch), + VaultError::RequestNotFound, + ); + refused( + f.vault.try_cancel_redeem(&stranger, &epoch), + VaultError::RequestNotFound, + ); +} + #[test] fn the_pause_never_blocks_a_cancellation() { let f = setup(); @@ -100,7 +127,6 @@ fn the_same_investor_can_request_again_after_cancelling() { fn refundable_escrow_is_not_free_reserve() { let f = setup(); let inv = f.investor(1_000); - f.vault.set_custodian(&f.custodian, &f.admin); let before = f.vault.free_reserve(); f.vault.request_deposit(&inv, &400); @@ -172,6 +198,29 @@ fn a_redemption_that_pays_nothing_returns_the_shares_at_claim() { assert_eq!(f.shares(&holder), 1); } +#[test] +fn a_delisted_holders_redemption_that_pays_nothing_waits_until_relisted() { + let f = setup(); + let holder = f.holder(1); + + let epoch = f.vault.request_redeem(&holder, &1); + f.close_epoch(); + f.attest(wad(4) / 10); + f.vault.fulfill_epoch(&epoch); + + // 1 share at 0.4 pays zero assets. + f.identity.allow(&holder, &false); + refused( + f.vault.try_claim_redeem(&holder, &epoch), + VaultError::HolderNotEligible, + ); + assert_eq!(f.shares(&holder), 0); + + f.identity.allow(&holder, &true); + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 0); + assert_eq!(f.shares(&holder), 1); +} + /// A refund after pricing is an extra payout, so it waits for free reserve /// rather than taking money owed to a priced exit. #[test] @@ -187,7 +236,6 @@ fn a_dust_refund_never_takes_committed_money() { f.vault.fulfill_epoch(&epoch); // Move every free unit out, leaving only what the redeemer is owed. - f.vault.set_custodian(&f.custodian, &f.admin); let free = f.vault.free_reserve(); if free > 0 { f.vault.deploy_to_custodian(&f.treasury, &free); @@ -282,14 +330,59 @@ fn a_sealed_epoch_cancels_once_the_feed_goes_stale() { } #[test] -fn a_sealed_epoch_cancels_while_the_ripcord_is_raised() { +fn a_sealed_epoch_cancels_while_the_nav_is_paused() { let f = setup(); let holder = f.holder(200); let epoch = f.vault.request_redeem(&holder, &200); f.close_epoch(); - f.oracle.raise_ripcord(&f.guardian); + f.oracle.pause_nav(&f.guardian); assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); assert_eq!(f.shares(&holder), 200); } + +/// A paused vault cannot price, so a readable price no longer closes the +/// window: the investor may leave while the pause lasts, and the window closes +/// again once governance lifts it. +#[test] +fn a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price() { + let f = setup(); + let first = f.investor(1_000); + let second = f.investor(1_000); + let epoch = f.vault.request_deposit(&first, &400); + f.vault.request_deposit(&second, &400); + f.close_epoch(); + f.attest(wad(2)); + refused( + f.vault.try_cancel_deposit(&first, &epoch), + VaultError::PriceAvailable, + ); + + f.vault.pause(&f.guardian); + assert_eq!(f.vault.cancel_deposit(&first, &epoch), 400); + + f.vault.unpause(&f.admin); + refused( + f.vault.try_cancel_deposit(&second, &epoch), + VaultError::PriceAvailable, + ); +} + +/// The window closes on the first readable price after the close and reopens +/// if that price lapses before the epoch is priced. +#[test] +fn cancellation_reopens_when_the_price_that_closed_it_expires() { + let f = setup(); + let inv = f.investor(1_000); + let epoch = f.vault.request_deposit(&inv, &400); + f.close_epoch(); + f.attest(wad(2)); + refused( + f.vault.try_cancel_deposit(&inv, &epoch), + VaultError::PriceAvailable, + ); + + f.advance(3_601); // past the freshness window + assert_eq!(f.vault.cancel_deposit(&inv, &epoch), 400); +} diff --git a/contracts/async-vault/src/test/clawback.rs b/contracts/async-vault/src/test/clawback.rs new file mode 100644 index 00000000..87891509 --- /dev/null +++ b/contracts/async-vault/src/test/clawback.rs @@ -0,0 +1,125 @@ +//! The asset's issuer takes part of the vault's balance: what is still paid, +//! what is refused by name, and how funding the gap lifts each refusal. + +use super::*; + +#[test] +fn a_cancellation_the_balance_cannot_cover_is_refused_by_name() { + let f = setup(); + let a = f.investor(1_000); + f.vault.request_deposit(&a, &100); + f.claw_back(60); + + refused( + f.vault.try_cancel_deposit(&a, &1), + VaultError::EscrowNotCovered, + ); + assert_eq!(f.balance(&a), 900); + assert!(f.vault.get_deposit_request(&1, &a).is_some()); + assert_eq!(f.vault.uncovered(), 60); + + f.vault.fund(&f.unverified_investor(60), &60); + assert_eq!(f.vault.cancel_deposit(&a, &1), 100); + assert_eq!(f.balance(&a), 1_000); +} + +#[test] +fn cancellations_are_paid_in_the_order_they_come_while_the_balance_lasts() { + let f = setup(); + let (a, b) = (f.investor(1_000), f.investor(1_000)); + f.vault.request_deposit(&a, &30); + f.vault.request_deposit(&b, &70); + f.claw_back(70); + + assert_eq!(f.vault.cancel_deposit(&a, &1), 30); + refused( + f.vault.try_cancel_deposit(&b, &1), + VaultError::EscrowNotCovered, + ); + f.vault.fund(&f.unverified_investor(70), &70); + assert_eq!(f.vault.cancel_deposit(&b, &1), 70); +} + +#[test] +fn no_deposit_is_taken_while_the_balance_is_below_the_escrow() { + let f = setup(); + let (a, b) = (f.investor(1_000), f.investor(1_000)); + f.vault.request_deposit(&a, &100); + f.claw_back(1); + + refused( + f.vault.try_request_deposit(&b, &50), + VaultError::EscrowNotCovered, + ); + assert_eq!(f.balance(&b), 1_000); + f.vault.fund(&f.unverified_investor(1), &1); + f.vault.request_deposit(&b, &50); +} + +#[test] +fn a_priced_redemption_waits_while_the_issuer_has_taken_the_escrow() { + let f = setup(); + let holder = f.holder(100); + f.vault.request_redeem(&holder, &100); + let exit = f.close_epoch(); + let c = f.investor(1_000); + f.vault.request_deposit(&c, &300); + f.fulfill_epoch_at(exit, wad(2)); + f.claw_back(300); + + refused( + f.vault.try_claim_redeem(&holder, &exit), + VaultError::ClaimNotCovered, + ); + assert_eq!(f.vault.uncovered(), 300); + f.vault.fund(&f.unverified_investor(300), &300); + assert_eq!(f.vault.claim_redeem(&holder, &exit), 200); + assert_eq!(f.vault.cancel_deposit(&c, &(exit + 1)), 300); +} + +#[test] +fn a_refund_below_one_share_waits_while_the_issuer_has_taken_the_escrow() { + let f = setup(); + let dust = f.investor(1_000); + f.vault.request_deposit(&dust, &1); + let priced = f.fulfill_epoch(wad(2)); + let pending = f.investor(1_000); + f.vault.request_deposit(&pending, &100); + f.claw_back(2); + + refused( + f.vault.try_claim_deposit(&dust, &priced), + VaultError::ClaimNotCovered, + ); + f.vault.fund(&f.unverified_investor(2), &2); + assert_eq!(f.vault.claim_deposit(&dust, &priced), 0); + assert_eq!(f.balance(&dust), 1_000); +} + +#[test] +fn nothing_goes_to_the_custodian_while_the_issuer_has_taken_the_escrow() { + let f = setup(); + f.holder(100); + let pending = f.investor(1_000); + f.vault.request_deposit(&pending, &100); + f.claw_back(250); + + refused( + f.vault.try_deploy_to_custodian(&f.treasury, &1), + VaultError::ReserveCommittedToExits, + ); + assert_eq!(f.vault.free_reserve(), 0); + assert_eq!(f.vault.uncovered(), 50); +} + +#[test] +fn pricing_ignores_a_shortfall_and_mints_the_full_deposit() { + let f = setup(); + let a = f.investor(1_000); + f.vault.request_deposit(&a, &100); + f.claw_back(40); + let epoch = f.fulfill_epoch(wad(1)); + + assert_eq!(f.vault.claim_deposit(&a, &epoch), 100); + assert_eq!(f.vault.liquid_reserve(), 60); +} diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 3ed1148d..18b1ae96 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -1,41 +1,168 @@ +use soroban_sdk::{contract, contractimpl}; +use stellar_tokens::rwa::{compliance::ComplianceHook, RWAError, RWA}; + use super::*; #[test] -fn a_non_allowlisted_investor_cannot_claim_shares() { +fn an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves() { let f = setup(); let stranger = f.unverified_investor(1_000); - f.vault.request_deposit(&stranger, &400); - f.fulfill_epoch(wad(2)); + refused( + f.vault.try_request_deposit(&stranger, &400), + VaultError::NotAllowlisted, + ); - assert!(f.vault.try_claim_deposit(&stranger, &1).is_err()); - assert_eq!(f.shares(&stranger), 0); - assert!(!f.vault.get_deposit_request(&1, &stranger).unwrap().claimed); - assert_eq!(f.balance(&f.vault.address), 400); + assert_eq!(f.balance(&stranger), 1_000); + assert_eq!(f.balance(&f.vault.address), 0); + assert_eq!(f.vault.cancellable_escrow(), 0); + assert_eq!(f.vault.get_epoch(&1).unwrap().total_deposited, 0); + assert_eq!(f.vault.get_deposit_request(&1, &stranger), None); } #[test] -fn allowlisting_after_the_fact_lets_the_claim_through() { +fn a_delisted_investor_cannot_request_a_deposit() { let f = setup(); - let investor = f.unverified_investor(1_000); + let investor = f.investor(1_000); + f.identity.allow(&investor, &false); + + refused( + f.vault.try_request_deposit(&investor, &400), + VaultError::NotAllowlisted, + ); + assert_eq!(f.balance(&investor), 1_000); +} + +#[test] +fn an_allowlisted_investor_still_requests_a_deposit() { + let f = setup(); + let investor = f.investor(1_000); + + assert_eq!(f.vault.request_deposit(&investor, &400), 1); + assert_eq!(f.balance(&f.vault.address), 400); +} +#[test] +fn relisting_after_pricing_lets_the_claim_through() { + let f = setup(); + let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); - assert!(f.vault.try_claim_deposit(&investor, &1).is_err()); + f.identity.allow(&investor, &false); - f.identity.allow(&investor, &true, &f.compliance); + f.identity.allow(&investor, &true); assert_eq!(f.vault.claim_deposit(&investor, &1), 200); assert_eq!(f.shares(&investor), 200); } +/// Delisted after pricing, she cannot cancel, and a wind-down still pays her slice. +#[test] +fn a_holder_delisted_after_pricing_is_paid_through_a_wind_down() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + assert_eq!(f.vault.cancellable_escrow(), 0); + assert_eq!(f.vault.pending_mint_shares(), 200); + f.identity.allow(&investor, &false); + + refused( + f.vault.try_cancel_deposit(&investor, &epoch), + VaultError::AlreadyPriced, + ); + + f.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + assert_eq!(f.vault.finalize_wind_down_round(), 400); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[contract] +pub(super) struct RefusesEveryMint; + +#[contractimpl] +impl RefusesEveryMint { + pub fn can_create(_e: Env, _to: Address, _amount: i128, _token: Address) -> bool { + false + } +} + +/// A compliance module can refuse the mint of a priced deposit even for an +/// allowlisted holder. The kit cannot stop it; it is a trust assumption. +#[test] +fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + + let module = f.e.register(RefusesEveryMint, ()); + f.add_compliance_module(ComplianceHook::CanCreate, &module); + + assert_eq!( + f.vault.try_claim_deposit(&investor, &epoch).err(), + Some(Ok(soroban_sdk::Error::from_contract_error(306))) + ); + refused( + f.vault.try_cancel_deposit(&investor, &epoch), + VaultError::AlreadyPriced, + ); +} + +#[test] +fn an_unbound_compliance_swap_is_refused_and_every_move_goes_on() { + let f = setup(); + let holder = f.holder(10); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &100); + f.vault.request_redeem(&holder, &4); + f.fulfill_epoch(wad(2)); + + let unbound = ComplianceClient::new(&f.e, &f.e.register(Compliance, (f.admin.clone(),))); + unbound.bind_vault(&f.vault.address); + unbound.finish_setup(); + assert_eq!( + f.share + .try_propose_change(&share_token::TokenChange::Compliance(unbound.address)) + .err(), + Some(Ok(share_token::ShareTokenError::ComplianceNotBound.into())) + ); + + f.share.transfer(&holder, &investor, &1); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 50); + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 8); + assert!(f.vault.health().compliance_bound); +} + +#[test] +fn a_verifier_that_cannot_answer_counts_as_a_failed_check() { + let f = setup(); + let investor = f.investor(1_000); + + // A swap refuses it; this stands in for a verifier that stops answering later. + let silent = Address::generate(&f.e); + f.e.as_contract(&f.share.address, || { + RWA::set_identity_verifier(&f.e, &silent) + }); + assert_eq!(f.share.identity_verifier(), silent); + + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::NotAllowlisted, + ); +} + #[test] fn a_delisted_holder_can_still_queue_an_exit() { let f = setup(); let user = f.holder(500); let epoch = f.vault.current_epoch(); - f.identity.allow(&user, &false, &f.compliance); + f.identity.allow(&user, &false); assert_eq!(f.vault.request_redeem(&user, &200), epoch); assert_eq!(f.shares(&f.vault.address), 200); @@ -48,7 +175,7 @@ fn a_delisted_holder_can_still_claim_their_exit() { let epoch = f.vault.request_redeem(&user, &200); f.fulfill_epoch(wad(2)); - f.identity.allow(&user, &false, &f.compliance); + f.identity.allow(&user, &false); assert_eq!(f.vault.claim_redeem(&user, &epoch), 400); assert_eq!(f.balance(&user), 400); @@ -61,7 +188,7 @@ fn a_delisted_investor_can_cancel_deposit_and_receive_asset() { let epoch = f.vault.request_deposit(&investor, &400); // Investor gets delisted - f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&investor, &false); // Delisted investor can still cancel deposit and receive settlement asset assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); @@ -75,10 +202,12 @@ fn a_delisted_holder_cannot_cancel_redemption_back_to_shares() { let epoch = f.vault.request_redeem(&user, &200); // Delisted before pricing - f.identity.allow(&user, &false, &f.compliance); + f.identity.allow(&user, &false); - // Cannot return shares to a delisted account (refused by share transfer compliance) - assert!(f.vault.try_cancel_redeem(&user, &epoch).is_err()); + refused( + f.vault.try_cancel_redeem(&user, &epoch), + VaultError::NotAllowlisted, + ); assert_eq!(f.shares(&user), 300); // Exit proceeds through cash claim once fulfilled @@ -88,23 +217,21 @@ fn a_delisted_holder_cannot_cancel_redemption_back_to_shares() { } #[test] -fn a_frozen_holder_cannot_cancel_redemption_back_to_shares() { +fn a_frozen_holder_is_paid_only_after_the_freeze_lifts() { let f = setup(); let user = f.holder(500); let epoch = f.vault.request_redeem(&user, &200); - - // Freeze address f.share.set_address_frozen(&user, &true, &f.compliance); - assert!(f.share.is_frozen(&user)); - // Cannot return shares to a frozen account - assert!(f.vault.try_cancel_redeem(&user, &epoch).is_err()); + refused(f.vault.try_cancel_redeem(&user, &epoch), VaultError::Frozen); + f.fulfill_epoch(wad(2)); + refused(f.vault.try_claim_redeem(&user, &epoch), VaultError::Frozen); + assert_eq!(f.vault.committed(), 400); assert_eq!(f.shares(&user), 300); - // Exit proceeds through cash claim once fulfilled - f.fulfill_epoch(wad(2)); + f.share.set_address_frozen(&user, &false, &f.compliance); assert_eq!(f.vault.claim_redeem(&user, &epoch), 400); - assert_eq!(f.balance(&user), 400); + assert_eq!(f.vault.committed(), 0); } #[test] @@ -118,48 +245,174 @@ fn a_frozen_or_delisted_holder_cannot_transfer_shares() { assert_eq!(f.shares(&recipient), 100); // Delisted sender cannot transfer - f.identity.allow(&user, &false, &f.compliance); + f.identity.allow(&user, &false); assert!(f.share.try_transfer(&user, &recipient, &100).is_err()); // Restore allowlist, then freeze sender - f.identity.allow(&user, &true, &f.compliance); + f.identity.allow(&user, &true); f.share.set_address_frozen(&user, &true, &f.compliance); assert!(f.share.try_transfer(&user, &recipient, &100).is_err()); } #[test] -fn a_frozen_investor_on_the_allowlist_is_still_minted() { +fn rules_with_no_modules_accept_any_pair_yet_the_token_still_checks_both_sides() { + let f = setup(); + let rules = ComplianceClient::new(&f.e, &f.share.compliance()); + let holder = f.holder(500); + let listed = f.investor(0); + let unlisted = f.unverified_investor(0); + let frozen = f.investor(0); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + let delisted_sender = f.holder(500); + f.identity.allow(&delisted_sender, &false); + let frozen_sender = f.holder(500); + f.share + .set_address_frozen(&frozen_sender, &true, &f.compliance); + let spender = Address::generate(&f.e); + let live_until = f.e.ledger().sequence() + 1_000; + + for (from, to, error) in [ + (&holder, &unlisted, RWAError::IdentityVerificationFailed), + (&holder, &frozen, RWAError::AddressFrozen), + ( + &delisted_sender, + &listed, + RWAError::IdentityVerificationFailed, + ), + (&frozen_sender, &listed, RWAError::AddressFrozen), + ] { + assert!(rules.can_transfer(from, to, &1, &f.share.address)); + assert_eq!(f.share.try_transfer(from, to, &1), Err(Ok(error.into()))); + f.share.approve(from, &spender, &1, &live_until); + assert_eq!( + f.share.try_transfer_from(&spender, from, to, &1), + Err(Ok(error.into())) + ); + } + + f.share.transfer(&holder, &listed, &1); + assert_eq!(f.shares(&listed), 1); +} + +#[test] +fn a_frozen_investor_cannot_request_a_deposit() { + let f = setup(); + let investor = f.investor(1_000); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_request_deposit(&investor, &400), + VaultError::Frozen, + ); + assert_eq!(f.balance(&investor), 1_000); +} + +#[test] +fn a_frozen_investor_is_not_minted_until_unfrozen() { let f = setup(); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); - f.share.set_address_frozen(&investor, &true, &f.compliance); + refused(f.vault.try_claim_deposit(&investor, &1), VaultError::Frozen); + assert_eq!(f.shares(&investor), 0); + assert!(!f.vault.get_deposit_request(&1, &investor).unwrap().claimed); + + f.share.set_address_frozen(&investor, &false, &f.compliance); assert_eq!(f.vault.claim_deposit(&investor, &1), 200); assert_eq!(f.shares(&investor), 200); } #[test] -fn a_frozen_holder_can_still_queue_an_exit() { +fn a_frozen_investors_zero_share_refund_is_held() { let f = setup(); - let user = f.holder(500); - let epoch = f.vault.current_epoch(); + let investor = f.investor(10); + let epoch = f.vault.request_deposit(&investor, &1); + f.fulfill_epoch(wad(2)); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.balance(&investor), 9); + assert!(f.vault.get_deposit_request(&epoch, &investor).is_some()); + f.share.set_address_frozen(&investor, &false, &f.compliance); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + assert_eq!(f.balance(&investor), 10); +} + +#[test] +fn a_frozen_holder_cannot_queue_an_exit() { + let f = setup(); + let user = f.holder(500); f.share.set_address_frozen(&user, &true, &f.compliance); - assert_eq!(f.vault.request_redeem(&user, &200), epoch); - assert_eq!(f.shares(&f.vault.address), 200); + refused(f.vault.try_request_redeem(&user, &200), VaultError::Frozen); + assert_eq!(f.shares(&user), 500); + assert_eq!(f.shares(&f.vault.address), 0); } #[test] -fn queueing_an_exit_releases_a_partial_freeze() { +fn a_partial_freeze_limits_an_exit_to_the_free_shares() { let f = setup(); let user = f.holder(500); f.share.freeze_partial_tokens(&user, &400, &f.compliance); - f.vault.request_redeem(&user, &300); + refused(f.vault.try_request_redeem(&user, &101), VaultError::Frozen); + f.vault.request_redeem(&user, &100); + + assert_eq!(f.shares(&user), 400); + assert_eq!(f.share.get_frozen_tokens(&user), 400); +} + +#[test] +fn a_frozen_investors_escrow_stays_until_the_freeze_lifts() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_cancel_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.vault.cancellable_escrow(), 400); + + f.share.set_address_frozen(&investor, &false, &f.compliance); + assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); +} + +#[test] +fn a_frozen_holder_does_not_get_a_worthless_exit_back() { + let f = setup(); + let user = f.holder(1); + let epoch = f.vault.request_redeem(&user, &1); + f.fulfill_epoch(WAD_SCALE / 2); + f.share.set_address_frozen(&user, &true, &f.compliance); + + refused(f.vault.try_claim_redeem(&user, &epoch), VaultError::Frozen); + assert_eq!(f.vault.pending_burn_shares(), 1); + assert!(f.vault.get_redeem_request(&epoch, &user).is_some()); +} + +#[test] +fn a_frozen_holder_is_not_moved_into_the_exit_queue() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + f.identity.allow(&investor, &false); + f.share.set_address_frozen(&investor, &true, &f.compliance); - assert_eq!(f.shares(&user), 200); - assert_eq!(f.share.get_frozen_tokens(&user), 200); + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert!(f + .vault + .get_redeem_request(&f.vault.current_epoch(), &investor) + .is_none()); } diff --git a/contracts/async-vault/src/test/controls.rs b/contracts/async-vault/src/test/controls.rs index 12104a76..75af02cb 100644 --- a/contracts/async-vault/src/test/controls.rs +++ b/contracts/async-vault/src/test/controls.rs @@ -1,3 +1,6 @@ +use stellar_access::access_control::AccessControlError; +use stellar_contract_utils::pausable::PausableError; + use super::*; #[test] @@ -7,7 +10,10 @@ fn a_paused_vault_takes_no_deposits() { f.vault.pause(&f.guardian); assert!(f.vault.paused()); - assert!(f.vault.try_request_deposit(&investor, &100).is_err()); + assert_eq!( + f.vault.try_request_deposit(&investor, &100), + Err(Ok(PausableError::EnforcedPause.into())) + ); f.vault.unpause(&f.admin); f.vault.request_deposit(&investor, &100); @@ -19,10 +25,22 @@ fn only_the_guardian_pauses() { let f = setup(); let stranger = Address::generate(&f.e); - assert!(f.vault.try_pause(&stranger).is_err()); - assert!(f.vault.try_pause(&f.admin).is_err()); - assert!(f.vault.try_pause(&f.manager).is_err()); - assert!(f.vault.try_pause(&f.treasury).is_err()); + assert_eq!( + f.vault.try_pause(&stranger), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + assert_eq!( + f.vault.try_pause(&f.admin), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + assert_eq!( + f.vault.try_pause(&f.manager), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + assert_eq!( + f.vault.try_pause(&f.treasury), + Err(Ok(AccessControlError::Unauthorized.into())) + ); assert!(!f.vault.paused()); f.vault.pause(&f.guardian); @@ -35,8 +53,8 @@ fn the_guardian_pauses_but_only_governance_unpauses() { f.vault.pause(&f.guardian); f.e.set_auths(&[]); - assert!(f.vault.try_unpause(&f.guardian).is_err()); - assert!(f.vault.try_unpause(&f.admin).is_err()); + refused(f.vault.try_unpause(&f.guardian), VaultError::AdminRequired); + missing_auth(f.vault.try_unpause(&f.admin)); assert!(f.vault.paused()); f.e.mock_all_auths(); @@ -44,6 +62,17 @@ fn the_guardian_pauses_but_only_governance_unpauses() { assert!(!f.vault.paused()); } +#[test] +fn a_caller_other_than_the_admin_cannot_unpause_even_when_it_signs() { + let f = setup(); + f.vault.pause(&f.guardian); + + for who in [f.guardian.clone(), Address::generate(&f.e)] { + refused(f.vault.try_unpause(&who), VaultError::AdminRequired); + } + assert!(f.vault.paused()); +} + #[test] fn a_paused_vault_still_lets_investors_claim() { let f = setup(); @@ -80,6 +109,29 @@ fn a_paused_vault_still_pays_redemptions() { assert_eq!(f.vault.claim_redeem(&user, &epoch), 400); } +#[test] +fn a_paused_vault_still_funds_and_deploys_to_custodian() { + let f = setup(); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + f.vault.pause(&f.guardian); + + let free = f.vault.free_reserve(); + assert!(free > 0); + let vault_before = f.balance(&f.vault.address); + f.vault.deploy_to_custodian(&f.treasury, &free); + assert_eq!(f.balance(&f.custodian), free); + assert_eq!(f.balance(&f.vault.address), vault_before - free); + + let funder = f.unverified_investor(1_000); + let vault_before_fund = f.balance(&f.vault.address); + f.vault.fund(&funder, &100); + assert_eq!(f.balance(&funder), 900); + assert_eq!(f.balance(&f.vault.address), vault_before_fund + 100); + assert!(f.vault.paused()); +} + #[test] fn a_paused_vault_does_not_fulfill_epochs() { let f = setup(); @@ -87,12 +139,70 @@ fn a_paused_vault_does_not_fulfill_epochs() { f.attest(wad(2)); f.vault.pause(&f.guardian); - assert!(f.vault.try_fulfill_epoch(&1).is_err()); + assert_eq!( + f.vault.try_fulfill_epoch(&1), + Err(Ok(PausableError::EnforcedPause.into())) + ); f.vault.unpause(&f.admin); assert_eq!(f.vault.fulfill_epoch(&1), wad(2)); } +#[test] +fn the_token_admin_pauses_transfers_at_once_after_setup() { + let f = setup(); + let holder = f.holder(500); + let other = f.investor(0); + let spender = Address::generate(&f.e); + f.share + .approve(&holder, &spender, &10, &(f.e.ledger().sequence() + 1_000)); + assert!(!f.share.setup_open()); + + f.share.pause(&f.admin); + assert!(f.share.paused()); + assert!(f.share.pending_changes().is_empty()); + assert_eq!( + f.share.try_transfer(&holder, &other, &1), + Err(Ok(PausableError::EnforcedPause.into())) + ); + assert_eq!( + f.share.try_transfer_from(&spender, &holder, &other, &1), + Err(Ok(PausableError::EnforcedPause.into())) + ); + + f.share.unpause(&f.admin); + f.share.transfer(&holder, &other, &1); + f.share.transfer_from(&spender, &holder, &other, &1); + assert_eq!(f.shares(&other), 2); +} + +#[test] +fn a_token_pause_leaves_deposits_exits_and_cancellations_open() { + let f = setup(); + let redeemer = f.holder(500); + let leaver = f.holder(500); + let depositor = f.investor(1_000); + let quitter = f.investor(1_000); + f.share.pause(&f.admin); + + let epoch = f.vault.request_deposit(&depositor, &400); + assert_eq!(f.vault.request_redeem(&redeemer, &200), epoch); + assert_eq!(f.vault.request_redeem(&leaver, &100), epoch); + assert_eq!(f.shares(&f.vault.address), 300); + + f.vault.request_deposit(&quitter, &300); + assert_eq!(f.vault.cancel_deposit(&quitter, &epoch), 300); + assert_eq!(f.vault.cancel_redeem(&leaver, &epoch), 100); + assert_eq!(f.shares(&leaver), 500); + + f.fulfill_epoch(wad(2)); + assert_eq!(f.vault.claim_deposit(&depositor, &epoch), 200); + assert_eq!(f.shares(&depositor), 200); + assert_eq!(f.vault.claim_redeem(&redeemer, &epoch), 400); + assert_eq!(f.balance(&redeemer), 400); + assert!(f.share.paused()); +} + #[test] fn every_authority_is_readable() { let f = setup(); @@ -108,13 +218,23 @@ fn governance_can_replace_the_guardian() { let f = setup(); let next = Address::generate(&f.e); - f.vault - .grant_role(&next, &symbol_short!("guardian"), &f.admin); - f.vault - .revoke_role(&f.guardian, &symbol_short!("guardian"), &f.admin); + let grant = f.vault.propose_change(&VaultChange::GrantRole( + symbol_short!("guardian"), + next.clone(), + )); + let revoke = f.vault.propose_change(&VaultChange::RevokeRole( + symbol_short!("guardian"), + f.guardian.clone(), + )); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&grant); + f.vault.apply_change(&revoke); assert_eq!(f.vault.guardian(), Some(next.clone())); - assert!(f.vault.try_pause(&f.guardian).is_err()); + assert_eq!( + f.vault.try_pause(&f.guardian), + Err(Ok(AccessControlError::Unauthorized.into())) + ); f.vault.pause(&next); assert!(f.vault.paused()); } @@ -145,17 +265,11 @@ fn a_cooldown_holds_the_standing_price() { // Inside the cooldown a new attestation is refused, so the epoch prices at // the record already standing rather than one the attester cannot post. f.advance(60); - assert!(f - .oracle - .try_attest( - &NavReport { - nav_per_share: wad(1), - timestamp: 0, - expires_at: 1_000_000, - }, - &f.attester - ) - .is_err()); + assert_eq!( + f.oracle + .try_attest(&nav_input(&f.e, wad(1), 1_000_000), &f.attester), + Err(Ok(nav_oracle::OracleError::CooldownActive.into())) + ); f.vault.fulfill_epoch(&epoch); assert_eq!(f.vault.get_epoch(&epoch).unwrap().share_price, wad(2)); @@ -176,17 +290,11 @@ fn a_cooldown_delays_pricing_until_the_attester_can_post() { // Inside the cooldown the attester cannot post, so the epoch has no // valuation younger than its close and waits. - assert!(f - .oracle - .try_attest( - &NavReport { - nav_per_share: wad(1), - timestamp: 0, - expires_at: 1_000_000, - }, - &f.attester - ) - .is_err()); + assert_eq!( + f.oracle + .try_attest(&nav_input(&f.e, wad(1), 1_000_000), &f.attester), + Err(Ok(nav_oracle::OracleError::CooldownActive.into())) + ); refused( f.vault.try_fulfill_epoch(&epoch), VaultError::AttestationBeforeClose, @@ -201,23 +309,6 @@ fn a_cooldown_delays_pricing_until_the_attester_can_post() { assert_eq!(f.vault.get_epoch(&epoch).unwrap().share_price, wad(1)); } -#[test] -fn a_stale_feed_refuses_to_price_until_reattested() { - let f = setup(); - let holder = f.holder(100); - - f.attest(wad(1)); - f.vault.request_redeem(&holder, &100); - let epoch = f.close_epoch(); - - f.advance(7_200); // past the 3600s freshness window - refused(f.vault.try_fulfill_epoch(&epoch), VaultError::FeedNotValid); - - f.attest(wad(1)); - f.vault.fulfill_epoch(&epoch); - assert_eq!(f.vault.get_epoch(&epoch).unwrap().share_price, wad(1)); -} - #[test] fn governance_cannot_renounce_itself_out_of_the_vault() { let f = setup(); @@ -235,7 +326,9 @@ fn governance_hands_over_in_two_steps_instead() { let f = setup(); let next = Address::generate(&f.e); - f.vault.transfer_admin_role(&next, &1_000); + let id = f.vault.propose_change(&VaultChange::Admin(next.clone())); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); f.vault.accept_admin_transfer(); assert_eq!(f.vault.governance(), Some(next.clone())); diff --git a/contracts/async-vault/src/test/conversions.rs b/contracts/async-vault/src/test/conversions.rs index 05cbb679..afcfe785 100644 --- a/contracts/async-vault/src/test/conversions.rs +++ b/contracts/async-vault/src/test/conversions.rs @@ -46,7 +46,7 @@ fn a_conversion_that_cannot_fit_i128_is_rejected() { f.vault.request_deposit(&whale, &(i128::MAX / 2)); let epoch = f.close_epoch(); - f.attest(1); + f.attest(MIN_ANSWER); refused( f.vault.try_fulfill_epoch(&epoch), @@ -190,24 +190,17 @@ fn wind_down_accumulator_never_pays_more_than_it_credits() { )| { let floor = |x: i128, y: i128, d: i128| checked_mul_div_floor(&env, &x, &y, &d).unwrap(); let snapshot: i128 = entitlements.iter().sum(); - let mut acc = 0i128; + let mut distributed = 0i128; let mut owed = 0i128; let mut paid = [0i128; 3]; for (round, &pot) in pots.iter().enumerate() { - let delta = floor(pot, WAD_SCALE, snapshot); - if delta == 0 { - continue; - } - let acc_after = acc + delta; - let credited = floor(snapshot, acc_after, WAD_SCALE) - floor(snapshot, acc, WAD_SCALE); - prop_assert!(credited <= pot); - acc = acc_after; - owed += credited; + distributed += pot; + owed += pot; for (i, &claims_now) in claims[round].iter().enumerate() { if claims_now { - let earned = floor(entitlements[i], acc, WAD_SCALE); + let earned = floor(entitlements[i], distributed, snapshot); prop_assert!(earned >= paid[i]); paid[i] = earned; } @@ -216,9 +209,10 @@ fn wind_down_accumulator_never_pays_more_than_it_credits() { } for i in 0..3 { - paid[i] = floor(entitlements[i], acc, WAD_SCALE); + paid[i] = floor(entitlements[i], distributed, snapshot); } prop_assert!(paid.iter().sum::() <= owed); + prop_assert!(owed - paid.iter().sum::() < 3); }); } diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs new file mode 100644 index 00000000..c5a25efd --- /dev/null +++ b/contracts/async-vault/src/test/delisted.rs @@ -0,0 +1,332 @@ +use proptest::prelude::*; +use soroban_sdk::{testutils::Events as _, Event as _}; + +use super::*; +use crate::{DepositConvertedToExit, EpochReconciled, RedemptionSurrendered}; + +/// A 400 deposit priced at 2.0 for 200 shares, then delisted before the claim. +fn delisted_with_a_priced_deposit(f: &Fixture) -> (Address, u64) { + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + f.identity.allow(&investor, &false); + (investor, epoch) +} + +#[test] +fn a_delisted_holders_priced_deposit_joins_their_redemption_in_the_open_epoch() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + let open = f.vault.current_epoch(); + + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + DepositConvertedToExit { + holder: investor.clone(), + epoch, + into_epoch: open, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); + assert_eq!(f.shares(&investor), 0); + assert_eq!(f.shares(&f.vault.address), 200); + assert_eq!(f.vault.pending_mint_shares(), 0); + assert_eq!( + f.vault.get_redeem_request(&open, &investor).unwrap().shares, + 200 + ); + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::AlreadyClaimed, + ); +} + +/// A fall since her deposit priced is hers, as it would be for anyone leaving. +#[test] +fn the_delisted_holder_is_paid_at_the_next_epochs_price() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.claim_deposit(&investor, &epoch); + + let exit = f.fulfill_epoch(wad(8) / 5); + + assert_eq!(f.vault.claim_redeem(&investor, &exit), 320); + assert_eq!(f.balance(&investor), 600 + 320); +} + +#[test] +fn the_delisted_holder_exits_at_the_same_price_as_everyone_in_that_epoch() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.claim_deposit(&investor, &epoch); + f.vault.request_redeem(&other, &200); + + let exit = f.fulfill_epoch(wad(8) / 5); + + assert_eq!(f.vault.claim_redeem(&investor, &exit), 320); + assert_eq!(f.vault.claim_redeem(&other, &exit), 320); +} + +#[test] +fn a_conversion_adds_to_a_redemption_already_open_in_the_epoch() { + let f = setup(); + let investor = f.holder(100); + StellarAssetClient::new(&f.e, &f.asset).mint(&investor, &400); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + let open = f.vault.current_epoch(); + f.vault.request_redeem(&investor, &100); + f.identity.allow(&investor, &false); + + f.vault.claim_deposit(&investor, &epoch); + + assert_eq!( + f.vault.get_redeem_request(&open, &investor).unwrap().shares, + 300 + ); + assert_eq!( + f.vault.get_epoch(&open).unwrap().total_shares_redeeming, + 300 + ); + assert_eq!(f.vault.get_epoch_settlement(&open).unwrap().redeems_open, 1); +} + +#[test] +fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + DepositConvertedToExit { + holder: investor.clone(), + epoch, + into_epoch: 0, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); + assert_eq!(f.vault.finalize_wind_down_round(), 400); + + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn in_a_wind_down_the_shares_are_credited_after_the_first_round() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + assert_eq!(f.vault.finalize_wind_down_round(), 400); + + f.vault.claim_deposit(&investor, &epoch); + + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +/// Without the credit in the snapshot, the first round would owe more than the vault holds. +#[test] +fn a_credit_before_the_first_round_leaves_every_holder_their_share() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + f.vault.claim_deposit(&investor, &epoch); + + assert_eq!(f.vault.finalize_wind_down_round(), 800); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); + assert_eq!(f.balance(&f.vault.address), 0); +} + +#[test] +fn a_frozen_and_delisted_holders_claim_is_held() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.vault.pending_mint_shares(), 200); + assert!( + !f.vault + .get_deposit_request(&epoch, &investor) + .unwrap() + .claimed + ); +} + +/// Converted while the wind-down was only proposed, the redemption will never be priced. +#[test] +fn a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(); + f.vault.claim_deposit(&investor, &epoch); + let open = f.vault.current_epoch(); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + + assert_eq!(f.vault.cancel_redeem(&investor, &open), 200); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [RedemptionSurrendered { + holder: investor.clone(), + epoch: open, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address)] + ); + assert_eq!(f.vault.get_redeem_request(&open, &investor), None); + assert_eq!(f.shares(&f.vault.address), 0); + assert_eq!(f.vault.finalize_wind_down_round(), 800); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); + assert_eq!(f.balance(&f.vault.address), 0); +} + +#[test] +fn a_redemption_surrendered_after_the_first_round_keeps_the_rounds_solvent() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(); + f.vault.claim_deposit(&investor, &epoch); + let open = f.vault.current_epoch(); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + assert_eq!(f.vault.finalize_wind_down_round(), 800); + + f.vault.cancel_redeem(&investor, &open); + + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn a_delisted_holders_unpriced_redemption_joins_the_wind_down_when_cancelled() { + let f = setup(); + let investor = f.holder(200); + let other = f.holder(200); + let epoch = f.vault.request_redeem(&investor, &200); + f.identity.allow(&investor, &false); + f.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + + assert_eq!(f.vault.cancel_redeem(&investor, &epoch), 200); + + assert_eq!(f.vault.finalize_wind_down_round(), 800); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); +} + +#[test] +fn a_conversion_waits_while_the_vault_is_delisted() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &1_000); + f.fulfill_epoch(wad(2)); + f.identity.allow(&investor, &false); + f.identity.allow(&f.vault.address, &false); + + assert_eq!( + f.vault.try_claim_deposit(&investor, &epoch), + Err(Ok(soroban_sdk::Error::from_contract_error(304))) + ); + assert!(f.vault.get_deposit_request(&epoch, &investor).is_some()); + + f.identity.allow(&f.vault.address, &true); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + let open = f.vault.current_epoch(); + assert_eq!( + f.vault.get_redeem_request(&open, &investor).unwrap().shares, + 500 + ); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(16))] + + /// Conversions in any order among the other claims still settle the epoch. + #[test] + fn conversions_settle_the_epoch_in_any_order( + deposits in proptest::collection::vec(1i128..5_000, 1..5), + delisted in proptest::collection::vec(any::(), 4), + order in proptest::collection::vec(any::(), 4), + price_tenths in 5i128..=20, + ) { + let f = setup(); + let investors: std::vec::Vec
      = deposits + .iter() + .map(|amount| { + let who = f.investor(*amount); + f.vault.request_deposit(&who, amount); + who + }) + .collect(); + let epoch = f.fulfill_epoch(wad(1) * price_tenths / 10); + let open = f.vault.current_epoch(); + + let mut claims: std::vec::Vec<(u16, usize)> = + (0..investors.len()).map(|i| (order[i], i)).collect(); + claims.sort(); + let mut converted = 0i128; + for (_, i) in claims { + let who = &investors[i]; + if delisted[i] { + f.identity.allow(who, &false); + } + let queued = || f.vault.get_redeem_request(&open, who).map_or(0, |r| r.shares); + let before = queued(); + let paid = f.vault.claim_deposit(who, &epoch); + if delisted[i] { + prop_assert_eq!(paid, 0); + converted += queued() - before; + } + } + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + prop_assert!(s.reconciled); + prop_assert_eq!(f.vault.pending_mint_shares(), 0); + prop_assert_eq!( + f.vault.get_epoch(&open).unwrap().total_shares_redeeming, + converted + ); + prop_assert_eq!(f.shares(&f.vault.address), converted); + } +} diff --git a/contracts/async-vault/src/test/deposit.rs b/contracts/async-vault/src/test/deposit.rs index af1351cf..7260c333 100644 --- a/contracts/async-vault/src/test/deposit.rs +++ b/contracts/async-vault/src/test/deposit.rs @@ -45,7 +45,10 @@ fn a_second_request_from_the_same_investor_is_rejected() { let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); - assert!(f.vault.try_request_deposit(&investor, &100).is_err()); + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::RequestOutstanding, + ); assert_eq!(f.vault.get_epoch(&1).unwrap().total_deposited, 400); assert_eq!(f.balance(&investor), 600); @@ -56,8 +59,14 @@ fn request_deposit_rejects_a_non_positive_amount() { let f = setup(); let investor = f.investor(1_000); - assert!(f.vault.try_request_deposit(&investor, &0).is_err()); - assert!(f.vault.try_request_deposit(&investor, &-1).is_err()); + refused( + f.vault.try_request_deposit(&investor, &0), + VaultError::InvalidAmount, + ); + refused( + f.vault.try_request_deposit(&investor, &-1), + VaultError::InvalidAmount, + ); assert_eq!(f.balance(&investor), 1_000); } @@ -68,7 +77,10 @@ fn request_deposit_rejects_a_total_that_would_overflow() { let other = f.investor(1_000); f.vault.request_deposit(&whale, &(i128::MAX - 10)); - assert!(f.vault.try_request_deposit(&other, &11).is_err()); + refused( + f.vault.try_request_deposit(&other, &11), + VaultError::AmountTooLarge, + ); assert_eq!( f.vault.get_epoch(&1).unwrap().total_deposited, @@ -82,7 +94,7 @@ fn request_deposit_needs_the_investor_authorisation() { let investor = f.investor(1_000); f.e.set_auths(&[]); - assert!(f.vault.try_request_deposit(&investor, &100).is_err()); + missing_auth(f.vault.try_request_deposit(&investor, &100)); assert_eq!(f.balance(&investor), 1_000); } @@ -158,7 +170,10 @@ fn claiming_an_unfulfilled_epoch_is_rejected() { let user = f.investor(1_000); f.vault.request_deposit(&user, &400); - assert!(f.vault.try_claim_deposit(&user, &1).is_err()); + refused( + f.vault.try_claim_deposit(&user, &1), + VaultError::EpochNotFulfilled, + ); assert_eq!(f.shares(&user), 0); } @@ -168,7 +183,28 @@ fn claiming_without_a_request_is_rejected() { let stranger = Address::generate(&f.e); f.fulfill_epoch(wad(2)); - assert!(f.vault.try_claim_deposit(&stranger, &1).is_err()); + refused( + f.vault.try_claim_deposit(&stranger, &1), + VaultError::RequestNotFound, + ); +} + +#[test] +fn claiming_without_a_request_finds_no_request_before_pricing() { + let f = setup(); + let stranger = Address::generate(&f.e); + let epoch = f.vault.current_epoch(); + + refused( + f.vault.try_claim_deposit(&stranger, &epoch), + VaultError::RequestNotFound, + ); + + f.close_epoch(); + refused( + f.vault.try_claim_deposit(&stranger, &epoch), + VaultError::RequestNotFound, + ); } #[test] @@ -185,6 +221,28 @@ fn a_deposit_below_one_share_is_refunded_at_claim() { assert_eq!(f.vault.get_deposit_request(&1, &user), None); } +#[test] +fn a_refund_after_pricing_leaves_the_priced_total_unchanged() { + let f = setup(); + let user = f.investor(1_000); + f.vault.request_deposit(&user, &1); + let epoch = f.fulfill_epoch(wad(2)); + + assert_eq!(f.vault.claim_deposit(&user, &epoch), 0); + + assert_eq!(f.vault.get_epoch(&epoch).unwrap().total_deposited, 1); + assert!(f.vault.get_epoch_settlement(&epoch).unwrap().reconciled); +} + +#[test] +fn a_cancellation_takes_the_deposit_out_of_its_epoch() { + let f = setup(); + let user = f.investor(1_000); + f.vault.request_deposit(&user, &400); + f.vault.cancel_deposit(&user, &1); + assert_eq!(f.vault.get_epoch(&1).unwrap().total_deposited, 0); +} + #[test] fn claiming_needs_the_investor_authorisation() { let f = setup(); @@ -194,7 +252,7 @@ fn claiming_needs_the_investor_authorisation() { f.fulfill_epoch(wad(2)); f.e.set_auths(&[]); - assert!(f.vault.try_claim_deposit(&user, &1).is_err()); + missing_auth(f.vault.try_claim_deposit(&user, &1)); } #[test] @@ -230,3 +288,61 @@ fn deposits_after_fulfilling_land_in_the_new_epoch() { ); assert_eq!(f.balance(&f.vault.address), 700); } + +#[test] +fn escrow_that_would_overflow_is_refused_by_name() { + let f = setup(); + let investor = f.investor(1); + // Held in full, so only the escrow sum can refuse. + StellarAssetClient::new(&f.e, &f.asset).mint(&f.vault.address, &i128::MAX); + f.e.as_contract(&f.vault.address, || { + crate::state::set_cancellable_escrow(&f.e, i128::MAX) + }); + + refused( + f.vault.try_request_deposit(&investor, &1), + VaultError::AmountTooLarge, + ); +} + +#[test] +fn a_deposit_is_refused_while_the_feed_is_stale() { + let f = setup(); + let investor = f.investor(1_000); + f.advance(f.oracle.config().freshness_duration); + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::FeedNotValid, + ); + assert_eq!(f.balance(&investor), 1_000); + assert_eq!(f.vault.cancellable_escrow(), 0); + + f.attest(wad(1)); + f.vault.request_deposit(&investor, &100); +} + +#[test] +fn a_deposit_is_refused_while_the_nav_is_paused() { + let f = setup(); + let investor = f.investor(1_000); + f.oracle.pause_nav(&f.guardian); + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::FeedNotValid, + ); + f.oracle.set_nav_paused(&false); + f.vault.request_deposit(&investor, &100); +} + +#[test] +fn a_deposit_is_refused_before_the_first_report() { + let f = setup_unfinished(); + f.vault.finish_setup(); + let investor = f.investor(1_000); + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::FeedNotValid, + ); + f.attest(wad(1)); + f.vault.request_deposit(&investor, &100); +} diff --git a/contracts/async-vault/src/test/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs index a0332b54..7f836314 100644 --- a/contracts/async-vault/src/test/deposit_cap.rs +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -1,3 +1,5 @@ +use soroban_sdk::IntoVal; + use super::*; #[test] @@ -15,15 +17,15 @@ fn governance_sets_updates_and_clears_cap() { let f = setup(); // Set cap - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); assert_eq!(f.vault.deposit_cap(), Some(1_000)); // Update cap - f.vault.set_deposit_cap(&Some(2_000), &f.admin); + f.vault.set_deposit_cap(&Some(2_000)); assert_eq!(f.vault.deposit_cap(), Some(2_000)); // Clear cap - f.vault.set_deposit_cap(&None, &f.admin); + f.vault.set_deposit_cap(&None); assert_eq!(f.vault.deposit_cap(), None); } @@ -31,7 +33,7 @@ fn governance_sets_updates_and_clears_cap() { fn negative_cap_rejected() { let f = setup(); refused( - f.vault.try_set_deposit_cap(&Some(-1), &f.admin), + f.vault.try_set_deposit_cap(&Some(-1)), VaultError::InvalidAmount, ); } @@ -41,26 +43,17 @@ fn unauthorized_caller_rejected() { let f = setup(); let rando = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_set_deposit_cap(&Some(1_000), &rando).is_err()); - assert!(f - .vault - .try_set_deposit_cap(&Some(1_000), &f.manager) - .is_err()); - assert!(f - .vault - .try_set_deposit_cap(&Some(1_000), &f.guardian) - .is_err()); - assert!(f - .vault - .try_set_deposit_cap(&Some(1_000), &f.treasury) - .is_err()); + for who in [&rando, &f.manager, &f.guardian, &f.treasury] { + f.sign_only_as(who, "set_deposit_cap", (Some(1_000_i128),).into_val(&f.e)); + assert!(f.vault.try_set_deposit_cap(&Some(1_000)).is_err()); + } + assert_eq!(f.vault.deposit_cap(), None); } #[test] fn boundary_value_enforcement() { let f = setup(); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); let investor1 = f.investor(2_000); let investor2 = f.investor(2_000); @@ -85,7 +78,7 @@ fn boundary_value_enforcement() { #[test] fn cancellation_frees_capacity() { let f = setup(); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); let investor1 = f.investor(1_000); let investor2 = f.investor(1_000); @@ -110,7 +103,7 @@ fn cancellation_frees_capacity() { #[test] fn deployed_capital_and_multi_epoch_accounting() { let f = setup(); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); let investor1 = f.investor(1_000); let investor2 = f.investor(1_000); @@ -122,7 +115,6 @@ fn deployed_capital_and_multi_epoch_accounting() { f.vault.fulfill_epoch(&1); // Deploy 800 to custodian - f.vault.set_custodian(&f.custodian, &f.admin); f.vault.deploy_to_custodian(&f.treasury, &800); assert_eq!(f.vault.net_deployed(), 800); @@ -133,7 +125,7 @@ fn deployed_capital_and_multi_epoch_accounting() { ); // Governance raises cap to 1_500 - f.vault.set_deposit_cap(&Some(1_500), &f.admin); + f.vault.set_deposit_cap(&Some(1_500)); // Now investor2 can deposit 500 let epoch2 = f.vault.request_deposit(&investor2, &500); @@ -143,7 +135,7 @@ fn deployed_capital_and_multi_epoch_accounting() { #[test] fn redemptions_free_capacity() { let f = setup(); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); let investor1 = f.investor(1_000); let investor2 = f.investor(1_000); @@ -178,7 +170,7 @@ fn redemptions_free_capacity() { #[test] fn zero_cap_blocks_all_deposits() { let f = setup(); - f.vault.set_deposit_cap(&Some(0), &f.admin); + f.vault.set_deposit_cap(&Some(0)); let investor = f.investor(100); refused( @@ -186,3 +178,81 @@ fn zero_cap_blocks_all_deposits() { VaultError::DepositCapExceeded, ); } + +/// 1,000 deposited, priced and sent to the custodian; the cap is then 1,500. +fn capital_deployed_under_a_cap(f: &Fixture) -> Address { + let investor = f.investor(2_000); + f.vault.set_deposit_cap(&Some(1_000)); + let epoch = f.vault.request_deposit(&investor, &1_000); + f.close_epoch(); + f.fulfill_epoch_at(epoch, wad(1)); + f.vault.deploy_to_custodian(&f.treasury, &1_000); + f.vault.set_deposit_cap(&Some(1_500)); + investor +} + +#[test] +fn capital_funded_back_counts_once_toward_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + + f.vault.fund(&f.custodian, &500); + + assert_eq!(f.vault.net_deployed(), 500); + f.vault.request_deposit(&investor, &500); +} + +#[test] +fn a_plain_transfer_back_counts_twice_toward_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + + TokenClient::new(&f.e, &f.asset).transfer(&f.custodian, &f.vault.address, &500); + + assert_eq!(f.vault.net_deployed(), 1_000); + refused( + f.vault.try_request_deposit(&investor, &1), + VaultError::DepositCapExceeded, + ); +} + +#[test] +fn a_gain_paid_out_to_leavers_leaves_room_above_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + f.vault.claim_deposit(&investor, &1); + StellarAssetClient::new(&f.e, &f.asset).mint(&f.custodian, &1_000); + f.vault.fund(&f.custodian, &2_000); + let exit = f.vault.request_redeem(&investor, &1_000); + f.fulfill_epoch(wad(2)); + f.vault.claim_redeem(&investor, &exit); + + assert_eq!(f.vault.net_deployed(), -1_000); + assert_eq!(f.balance(&f.vault.address), 0); + let newcomer = f.investor(3_000); + f.vault.request_deposit(&newcomer, &2_500); + refused( + f.vault.try_request_deposit(&newcomer, &1), + VaultError::DepositCapExceeded, + ); +} + +#[test] +fn a_loss_at_the_custodian_still_counts_toward_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + f.vault.claim_deposit(&investor, &1); + f.vault.fund(&f.custodian, &500); + let exit = f.vault.request_redeem(&investor, &1_000); + f.fulfill_epoch(wad(1) / 2); + f.vault.claim_redeem(&investor, &exit); + + assert_eq!(f.vault.net_deployed(), 500); + assert_eq!(f.balance(&f.vault.address), 0); + let newcomer = f.investor(1_500); + f.vault.request_deposit(&newcomer, &1_000); + refused( + f.vault.try_request_deposit(&newcomer, &1), + VaultError::DepositCapExceeded, + ); +} diff --git a/contracts/async-vault/src/test/epochs.rs b/contracts/async-vault/src/test/epochs.rs index 4c876bcc..55ba6910 100644 --- a/contracts/async-vault/src/test/epochs.rs +++ b/contracts/async-vault/src/test/epochs.rs @@ -1,3 +1,5 @@ +use stellar_access::access_control::AccessControlError; + use super::*; #[test] @@ -91,18 +93,26 @@ fn an_open_epoch_cannot_be_fulfilled() { let f = setup(); f.attest(wad(2)); - assert!(f.vault.try_fulfill_epoch(&1).is_err()); + refused(f.vault.try_fulfill_epoch(&1), VaultError::EpochNotPending); assert_eq!(f.vault.get_epoch(&1).unwrap().status, EpochStatus::Open); } #[test] fn a_sealed_epoch_cannot_be_closed_again() { let f = setup(); - f.close_epoch(); - - assert!(f.vault.try_fulfill_epoch(&1).is_err()); - assert_eq!(f.vault.get_epoch(&1).unwrap().status, EpochStatus::Pending); - assert_eq!(f.vault.current_epoch(), 2); + let sealed = f.close_epoch(); + let record = f.vault.get_epoch(&sealed).unwrap(); + f.advance(60); + // No entrypoint points the vault back at a sealed epoch. + f.e.as_contract(&f.vault.address, || { + crate::state::set_current_epoch(&f.e, sealed) + }); + + refused( + f.vault.try_close_epoch(&f.manager), + VaultError::EpochNotOpen, + ); + assert_eq!(f.vault.get_epoch(&sealed).unwrap(), record); } #[test] @@ -110,7 +120,10 @@ fn a_fulfilled_epoch_cannot_be_fulfilled_again() { let f = setup(); let epoch = f.fulfill_epoch(wad(2)); - assert!(f.vault.try_fulfill_epoch(&epoch).is_err()); + refused( + f.vault.try_fulfill_epoch(&epoch), + VaultError::EpochNotPending, + ); assert_eq!(f.vault.get_epoch(&epoch).unwrap().share_price, wad(2)); } @@ -136,19 +149,16 @@ fn epochs_can_be_fulfilled_out_of_order_after_closing() { } #[test] -#[should_panic(expected = "#2000")] fn a_non_manager_cannot_close() { let f = setup(); let impostor = Address::generate(&f.e); - f.vault.close_epoch(&impostor); -} - -#[test] -fn the_admin_is_not_the_manager() { - let f = setup(); - - assert!(f.vault.try_close_epoch(&f.admin).is_err()); + for who in [&impostor, &f.admin] { + assert_eq!( + f.vault.try_close_epoch(who), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + } assert_eq!(f.vault.get_epoch(&1).unwrap().status, EpochStatus::Open); } @@ -157,14 +167,13 @@ fn closing_needs_the_manager_authorisation() { let f = setup(); f.e.set_auths(&[]); - assert!(f.vault.try_close_epoch(&f.manager).is_err()); + missing_auth(f.vault.try_close_epoch(&f.manager)); assert_eq!(f.vault.current_epoch(), 1); } #[test] fn anyone_may_fulfill_a_closed_epoch() { let f = setup(); - let stranger = Address::generate(&f.e); let epoch = f.close_epoch(); f.attest(wad(2)); @@ -175,5 +184,4 @@ fn anyone_may_fulfill_a_closed_epoch() { f.vault.get_epoch(&epoch).unwrap().status, EpochStatus::Fulfilled ); - assert_eq!(f.shares(&stranger), 0); } diff --git a/contracts/async-vault/src/test/events.rs b/contracts/async-vault/src/test/events.rs index 38caa3bf..70e23331 100644 --- a/contracts/async-vault/src/test/events.rs +++ b/contracts/async-vault/src/test/events.rs @@ -1,7 +1,10 @@ use soroban_sdk::{testutils::Events as _, Event as _}; use super::*; -use crate::{DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled}; +use crate::{ + DepositCancelled, DepositClaimed, DepositRefunded, DepositRequested, EpochClosed, + EpochFulfilled, EpochReconciled, RedeemCancelled, +}; #[test] fn requesting_a_deposit_publishes_the_request() { @@ -13,7 +16,7 @@ fn requesting_a_deposit_publishes_the_request() { assert_eq!( f.e.events().all().filter_by_contract(&f.vault.address), [DepositRequested { - controller: user, + holder: user, epoch: 1, amount: 400, } @@ -62,7 +65,7 @@ fn pricing_an_epoch_publishes_the_share_price() { } #[test] -fn claiming_a_deposit_publishes_the_shares_paid() { +fn claiming_the_last_deposit_publishes_the_shares_paid_and_the_settlement() { let f = setup(); let user = f.investor(1_000); f.vault.request_deposit(&user, &400); @@ -72,11 +75,89 @@ fn claiming_a_deposit_publishes_the_shares_paid() { assert_eq!( f.e.events().all().filter_by_contract(&f.vault.address), - [DepositClaimed { - controller: user, + [ + DepositClaimed { + holder: user, + epoch: 1, + amount: 400, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch: 1, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); +} + +#[test] +fn cancelling_a_deposit_publishes_the_cancellation() { + let f = setup(); + let user = f.investor(1_000); + f.vault.request_deposit(&user, &400); + + f.vault.cancel_deposit(&user, &1); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [DepositCancelled { + holder: user, epoch: 1, amount: 400, - shares: 200, + } + .to_xdr(&f.e, &f.vault.address)] + ); +} + +#[test] +fn a_deposit_priced_at_zero_shares_publishes_a_refund_not_a_cancellation() { + let f = setup(); + let inv = f.investor(10); + + let epoch = f.vault.request_deposit(&inv, &1); + f.close_epoch(); + f.attest(wad(4)); + f.vault.fulfill_epoch(&epoch); + + assert_eq!(f.vault.claim_deposit(&inv, &epoch), 0); + + let events = f.e.events().all().filter_by_contract(&f.vault.address); + assert_eq!( + events.events()[0], + DepositRefunded { + holder: inv.clone(), + epoch: 1, + amount: 1, + } + .to_xdr(&f.e, &f.vault.address) + ); + assert!(!events.events().contains( + &DepositCancelled { + holder: inv, + epoch: 1, + amount: 1, + } + .to_xdr(&f.e, &f.vault.address) + )); +} + +#[test] +fn cancelling_a_redemption_publishes_the_cancellation() { + let f = setup(); + let holder = f.holder(1_000); + + let epoch = f.vault.request_redeem(&holder, &100); + f.vault.cancel_redeem(&holder, &epoch); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [RedeemCancelled { + holder, + epoch, + shares: 100, } .to_xdr(&f.e, &f.vault.address)] ); diff --git a/contracts/async-vault/src/test/governance.rs b/contracts/async-vault/src/test/governance.rs new file mode 100644 index 00000000..8770aab0 --- /dev/null +++ b/contracts/async-vault/src/test/governance.rs @@ -0,0 +1,423 @@ +use soroban_sdk::{testutils::Events as _, Event as _, IntoVal}; +use stellar_access::{access_control::AccessControlError, role_transfer::RoleTransferError}; +use stellar_contract_utils::pausable::PausableError; + +use super::*; +use crate::{ChangeApplied, CustodianSet, ADMIN_ACCEPT_LEDGERS}; + +/// Proposes a change, waits one delay and applies it. +fn land(f: &Fixture, change: VaultChange) { + let id = f.vault.propose_change(&change); + f.advance(f.vault.upgrade_delay()); + f.vault.apply_change(&id); +} + +#[test] +fn the_custodian_changes_through_the_timelock() { + let f = setup(); + let next = Address::generate(&f.e); + + refused(f.vault.try_set_custodian(&next), VaultError::UseTimelock); + + let id = f + .vault + .propose_change(&VaultChange::Custodian(next.clone())); + f.advance(MIN_UPGRADE_DELAY - 1); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); + f.advance(1); + f.vault.apply_change(&id); + + assert_eq!(f.vault.custodian(), Some(next)); +} + +#[test] +fn applying_a_custodian_change_publishes_both_events() { + let f = setup(); + let next = Address::generate(&f.e); + let change = VaultChange::Custodian(next.clone()); + let id = f.vault.propose_change(&change); + f.advance(MIN_UPGRADE_DELAY); + + f.vault.apply_change(&id); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + CustodianSet { custodian: next }.to_xdr(&f.e, &f.vault.address), + ChangeApplied { id, change }.to_xdr(&f.e, &f.vault.address), + ] + ); +} + +#[test] +fn a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied() { + let f = setup(); + let change = VaultChange::Custodian(f.vault.address.clone()); + + refused( + f.vault.try_propose_change(&change), + VaultError::InvalidCustodian, + ); + + // Skips the proposal's check, as a change queued by an earlier version would. + let id = f.e.as_contract(&f.vault.address, || { + strata_timelock::queue::propose::(&f.e, &change).unwrap() + }); + f.advance(MIN_UPGRADE_DELAY); + + refused(f.vault.try_apply_change(&id), VaultError::InvalidCustodian); + assert_eq!(f.vault.custodian(), Some(f.custodian.clone())); +} + +#[test] +fn raising_the_notice_is_instant_and_lowering_waits() { + let f = setup(); + + f.vault.set_notice(&DAY_DURATION); + assert_eq!(f.vault.notice(), DAY_DURATION); + + refused(f.vault.try_set_notice(&3_600), VaultError::UseTimelock); + land(&f, VaultChange::Notice(3_600)); + + assert_eq!(f.vault.notice(), 3_600); +} + +#[test] +fn a_notice_change_still_respects_its_bounds() { + let f = setup(); + + refused( + f.vault + .try_propose_change(&VaultChange::Notice(MIN_UPGRADE_DELAY + 1)), + VaultError::NoticeAboveUpgradeDelay, + ); +} + +#[test] +fn a_notice_change_checks_the_upgrade_delay_again_when_it_applies() { + let f = setup(); + land(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); + let notice = f + .vault + .propose_change(&VaultChange::Notice(20 * DAY_DURATION)); + let delay = f + .vault + .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); + f.advance(30 * DAY_DURATION); + + // The delay falls below the notice the first change would set. + f.vault.apply_change(&delay); + + refused( + f.vault.try_apply_change(¬ice), + VaultError::NoticeAboveUpgradeDelay, + ); + assert_eq!(f.vault.notice(), 0); +} + +#[test] +fn raising_the_wind_down_delay_is_instant_and_lowering_waits() { + let f = setup(); + + f.vault.set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY)); + refused( + f.vault.try_set_wind_down_delay(&MIN_WIND_DOWN_DELAY), + VaultError::UseTimelock, + ); + land(&f, VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY)); + + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); +} + +#[test] +fn a_wind_down_delay_below_the_minimum_cannot_be_proposed() { + let f = setup(); + + refused( + f.vault + .try_propose_change(&VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1)), + VaultError::WindDownDelayTooShort, + ); +} + +#[test] +fn grants_and_manager_revokes_wait_for_the_timelock() { + let f = setup(); + let next = Address::generate(&f.e); + + refused( + f.vault + .try_grant_role(&next, &symbol_short!("treasury"), &f.admin), + VaultError::UseTimelock, + ); + refused( + f.vault + .try_revoke_role(&f.manager, &symbol_short!("manager"), &f.admin), + VaultError::UseTimelock, + ); + + land( + &f, + VaultChange::GrantRole(symbol_short!("treasury"), next.clone()), + ); + assert!(f + .vault + .has_role(&next, &symbol_short!("treasury")) + .is_some()); +} + +#[test] +fn a_manager_revoke_lands_through_the_timelock() { + let f = setup(); + + land( + &f, + VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), + ); + + assert_eq!(f.vault.manager(), None); + assert_eq!( + f.vault.try_close_epoch(&f.manager), + Err(Ok(AccessControlError::Unauthorized.into())) + ); +} + +#[test] +fn revoking_treasury_or_guardian_stays_instant() { + let f = setup(); + + f.vault + .revoke_role(&f.treasury, &symbol_short!("treasury"), &f.admin); + f.vault + .revoke_role(&f.guardian, &symbol_short!("guardian"), &f.admin); + + assert_eq!(f.vault.treasury(), None); + assert_eq!(f.vault.guardian(), None); +} + +#[test] +fn only_the_admin_revokes_a_role() { + let f = setup(); + let stranger = Address::generate(&f.e); + + assert_eq!( + f.vault + .try_revoke_role(&f.treasury, &symbol_short!("treasury"), &stranger), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + assert_eq!( + f.vault + .try_revoke_role(&f.treasury, &symbol_short!("treasury"), &f.guardian), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + assert_eq!(f.vault.treasury(), Some(f.treasury.clone())); +} + +#[test] +fn no_role_ever_gets_a_role_admin() { + for f in [setup_unfinished(), setup()] { + refused( + f.vault + .try_set_role_admin(&symbol_short!("treasury"), &symbol_short!("guardian")), + VaultError::UseTimelock, + ); + } +} + +#[test] +fn an_admin_hand_over_waits_but_cancelling_one_does_not() { + let f = setup(); + let next = Address::generate(&f.e); + + refused( + f.vault + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + VaultError::UseTimelock, + ); + + land(&f, VaultChange::Admin(next.clone())); + f.vault.transfer_admin_role(&next, &0); + + assert_eq!( + f.vault.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { + let f = setup(); + let next = Address::generate(&f.e); + let id = f.vault.propose_change(&VaultChange::Admin(next.clone())); + f.advance(MIN_UPGRADE_DELAY); + + // One admin signature covers the call; the hand-over inside asks for none. + f.sign_only_as(&f.admin, "apply_change", (id,).into_val(&f.e)); + f.vault.apply_change(&id); + + f.e.mock_all_auths(); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); + f.vault.accept_admin_transfer(); + assert_eq!(f.vault.governance(), Some(next)); +} + +#[test] +fn the_hand_over_offer_is_open_on_its_last_ledger() { + let f = setup(); + let next = Address::generate(&f.e); + land(&f, VaultChange::Admin(next.clone())); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.vault.accept_admin_transfer(); + + assert_eq!(f.vault.governance(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = setup(); + land(&f, VaultChange::Admin(Address::generate(&f.e))); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1); + + assert_eq!( + f.vault.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = setup(); + let next = Address::generate(&f.e); + let custodian = f.vault.propose_change(&VaultChange::Custodian(next)); + let delay = f + .vault + .propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION)); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&delay); + + // The notice rises past the seven days the custodian change was given. + f.vault.set_notice(&(20 * DAY_DURATION)); + f.advance(30 * DAY_DURATION); + + refused( + f.vault.try_apply_change(&custodian), + VaultError::UpgradeDelayBelowNotice, + ); + assert_eq!(f.vault.custodian(), Some(f.custodian.clone())); +} + +#[test] +fn the_deposit_cap_stays_instant_both_ways() { + let f = setup(); + + f.vault.set_deposit_cap(&Some(1_000)); + assert_eq!(f.vault.deposit_cap(), Some(1_000)); + f.vault.set_deposit_cap(&Some(500)); + assert_eq!(f.vault.deposit_cap(), Some(500)); + f.vault.set_deposit_cap(&None); + assert_eq!(f.vault.deposit_cap(), None); +} + +#[test] +fn a_pending_role_change_lands_after_a_pause() { + let f = setup(); + let next = Address::generate(&f.e); + let id = f.vault.propose_change(&VaultChange::GrantRole( + symbol_short!("guardian"), + next.clone(), + )); + f.vault.pause(&f.guardian); + f.advance(MIN_UPGRADE_DELAY); + + assert_eq!( + f.vault.try_apply_change(&id), + Err(Ok(PausableError::EnforcedPause.into())) + ); + + f.vault.unpause(&f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + assert!(f + .vault + .has_role(&next, &symbol_short!("guardian")) + .is_some()); +} + +#[test] +fn nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included() { + let f = setup(); + let revoke = f.vault.propose_change(&VaultChange::RevokeRole( + symbol_short!("manager"), + f.manager.clone(), + )); + f.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + + refused( + f.vault.try_apply_change(&revoke), + VaultError::WindDownActive, + ); + refused( + f.vault.try_propose_change(&VaultChange::RevokeRole( + symbol_short!("manager"), + f.manager.clone(), + )), + VaultError::WindDownActive, + ); + refused( + f.vault + .try_revoke_role(&f.manager, &symbol_short!("manager"), &f.admin), + VaultError::UseTimelock, + ); + assert_eq!(f.vault.manager(), Some(f.manager.clone())); + + // Taking an operational role away moves no value, so it stays instant. + f.vault + .revoke_role(&f.treasury, &symbol_short!("treasury"), &f.admin); + assert_eq!(f.vault.treasury(), None); +} + +/// Lands an `Admin` offer for `next`, then activates a wind-down. +fn offer_admin_then_wind_down(f: &Fixture, next: &Address) { + let id = f.vault.propose_change(&VaultChange::Admin(next.clone())); + f.vault.propose_wind_down(); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + f.vault.activate_wind_down(); +} + +#[test] +fn an_admin_hand_over_cannot_be_accepted_during_a_wind_down() { + let f = setup(); + let next = Address::generate(&f.e); + offer_admin_then_wind_down(&f, &next); + + refused( + f.vault.try_accept_admin_transfer(), + VaultError::WindDownActive, + ); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn cancelling_a_hand_over_stays_open_during_a_wind_down() { + let f = setup(); + let next = Address::generate(&f.e); + offer_admin_then_wind_down(&f, &next); + + f.vault.transfer_admin_role(&next, &0); + + assert_eq!( + f.vault.try_transfer_admin_role(&next, &0), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); +} diff --git a/contracts/async-vault/src/test/guards.rs b/contracts/async-vault/src/test/guards.rs new file mode 100644 index 00000000..e8a0bc86 --- /dev/null +++ b/contracts/async-vault/src/test/guards.rs @@ -0,0 +1,799 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::InvokeError; +use stellar_tokens::rwa::compliance::ComplianceHook; + +use super::share_returns::RefusesEveryTransfer; +use super::wind_down::wind_down_now; +use super::*; +use crate::guards; +use crate::lock_policy::{Decision, Exit, Standing}; +use crate::{EpochInfo, WindDownInfo, MIN_EPOCH_DURATION_CEILING, MIN_EPOCH_DURATION_FLOOR}; + +/// Runs `guard` inside the vault, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.vault.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: VaultError, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +fn epoch(f: &Fixture, id: u64) -> EpochInfo { + f.vault.get_epoch(&id).unwrap() +} + +#[test] +fn roles_distinct_refuses_one_account_in_two_seats_kept_apart() { + let f = setup(); + let roles = distinct_roles(&f.e); + holds(&f, || guards::roles_distinct(&f.e, &roles)); + + for shared in [ + VaultRoles { + guardian: roles.treasury.clone(), + ..roles.clone() + }, + VaultRoles { + governance: roles.treasury.clone(), + ..roles.clone() + }, + VaultRoles { + governance: roles.compliance.clone(), + ..roles.clone() + }, + VaultRoles { + treasury: roles.compliance.clone(), + ..roles.clone() + }, + ] { + refuses(&f, VaultError::RolesNotDistinct, || { + guards::roles_distinct(&f.e, &shared) + }); + } +} + +#[test] +fn not_winding_down_refuses_once_the_wind_down_is_active() { + let f = setup(); + holds(&f, || guards::not_winding_down(&f.e)); + + f.vault.propose_wind_down(); + holds(&f, || guards::not_winding_down(&f.e)); + + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + refuses(&f, VaultError::WindDownActive, || { + guards::not_winding_down(&f.e) + }); +} + +#[test] +fn winding_down_refuses_until_the_wind_down_is_active() { + let f = setup(); + refuses(&f, VaultError::WindDownNotActive, || { + guards::winding_down(&f.e) + }); + + f.vault.propose_wind_down(); + refuses(&f, VaultError::WindDownNotActive, || { + guards::winding_down(&f.e) + }); + + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + holds(&f, || guards::winding_down(&f.e)); +} + +#[test] +fn setup_finished_refuses_while_setup_is_open() { + let open = setup_unfinished(); + refuses(&open, VaultError::SetupOpen, || { + guards::setup_finished(&open.e) + }); + + let finished = setup(); + holds(&finished, || guards::setup_finished(&finished.e)); +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let open = setup_unfinished(); + holds(&open, || guards::setup_open(&open.e)); + + let finished = setup(); + refuses(&finished, VaultError::SetupClosed, || { + guards::setup_open(&finished.e) + }); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let open = setup_unfinished(); + holds(&open, || guards::direct_change_allowed(&open.e)); + + let finished = setup(); + refuses(&finished, VaultError::UseTimelock, || { + guards::direct_change_allowed(&finished.e) + }); +} + +#[test] +fn direct_revoke_allowed_refuses_only_the_manager_role_after_setup() { + let open = setup_unfinished(); + holds(&open, || { + guards::direct_revoke_allowed(&open.e, &symbol_short!("manager")) + }); + + let f = setup(); + holds(&f, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("guardian")) + }); + holds(&f, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("treasury")) + }); + refuses(&f, VaultError::UseTimelock, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("manager")) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let open = setup_unfinished(); + holds(&open, || guards::direct_hand_over_allowed(&open.e, 100)); + + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, VaultError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} + +#[test] +fn not_lowered_after_setup_refuses_only_a_lower_value_after_setup() { + let open = setup_unfinished(); + holds(&open, || guards::not_lowered_after_setup(&open.e, 1, 2)); + + let f = setup(); + holds(&f, || guards::not_lowered_after_setup(&f.e, 2, 2)); + holds(&f, || guards::not_lowered_after_setup(&f.e, 3, 2)); + refuses(&f, VaultError::UseTimelock, || { + guards::not_lowered_after_setup(&f.e, 1, 2) + }); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup_unfinished(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.vault + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, VaultError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.vault.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn caller_is_admin_refuses_anyone_but_the_admin() { + let f = setup(); + let stranger = Address::generate(&f.e); + holds(&f, || guards::caller_is_admin(&f.e, &f.admin)); + + for who in [&f.guardian, &f.manager, &stranger] { + refuses(&f, VaultError::AdminRequired, || { + guards::caller_is_admin(&f.e, who) + }); + } +} + +#[test] +fn wiring_complete_refuses_a_miswired_vault() { + let wired = setup_unfinished(); + holds(&wired, || guards::wiring_complete(&wired.e)); + + let unbound = miswired(Miswire::TokenUnbound); + refuses(&unbound, VaultError::SetupIncomplete, || { + guards::wiring_complete(&unbound.e) + }); +} + +#[test] +fn non_negative_cap_refuses_a_negative_cap() { + let f = setup(); + holds(&f, || guards::non_negative_cap(&f.e, None)); + holds(&f, || guards::non_negative_cap(&f.e, Some(0))); + refuses(&f, VaultError::InvalidAmount, || { + guards::non_negative_cap(&f.e, Some(-1)) + }); +} + +#[test] +fn notice_in_bounds_refuses_a_notice_too_long_or_above_the_upgrade_delay() { + let f = setup(); + holds(&f, || guards::notice_in_bounds(&f.e, 0)); + holds(&f, || guards::notice_in_bounds(&f.e, MIN_UPGRADE_DELAY)); + refuses(&f, VaultError::NoticeAboveUpgradeDelay, || { + guards::notice_in_bounds(&f.e, MIN_UPGRADE_DELAY + 1) + }); + refuses(&f, VaultError::NoticeTooLong, || { + guards::notice_in_bounds(&f.e, MAX_NOTICE_SECS + 1) + }); +} + +#[test] +fn wind_down_delay_in_bounds_refuses_a_delay_outside_the_bounds() { + let f = setup(); + holds(&f, || { + guards::wind_down_delay_in_bounds(&f.e, MIN_WIND_DOWN_DELAY) + }); + holds(&f, || { + guards::wind_down_delay_in_bounds(&f.e, MAX_WIND_DOWN_DELAY) + }); + refuses(&f, VaultError::WindDownDelayTooShort, || { + guards::wind_down_delay_in_bounds(&f.e, MIN_WIND_DOWN_DELAY - 1) + }); + refuses(&f, VaultError::WindDownDelayTooLong, || { + guards::wind_down_delay_in_bounds(&f.e, MAX_WIND_DOWN_DELAY + 1) + }); +} + +#[test] +fn upgrade_delay_in_bounds_refuses_a_delay_outside_the_bounds_or_below_the_notice() { + let f = setup(); + holds(&f, || { + guards::upgrade_delay_in_bounds(&f.e, MIN_UPGRADE_DELAY) + }); + holds(&f, || { + guards::upgrade_delay_in_bounds(&f.e, MAX_UPGRADE_DELAY) + }); + refuses(&f, VaultError::UpgradeDelayTooShort, || { + guards::upgrade_delay_in_bounds(&f.e, MIN_UPGRADE_DELAY - 1) + }); + refuses(&f, VaultError::UpgradeDelayTooLong, || { + guards::upgrade_delay_in_bounds(&f.e, MAX_UPGRADE_DELAY + 1) + }); + + f.e.as_contract(&f.vault.address, || { + crate::state::set_notice(&f.e, 10 * DAY_DURATION) + }); + refuses(&f, VaultError::UpgradeDelayBelowNotice, || { + guards::upgrade_delay_in_bounds(&f.e, 8 * DAY_DURATION) + }); +} + +#[test] +fn custodian_not_the_vault_refuses_the_vault_itself() { + let f = setup(); + holds(&f, || guards::custodian_not_the_vault(&f.e, &f.custodian)); + refuses(&f, VaultError::InvalidCustodian, || { + guards::custodian_not_the_vault(&f.e, &f.vault.address) + }); +} + +#[test] +fn holder_not_the_vault_refuses_the_vault_itself() { + let f = setup(); + let investor = f.investor(1_000); + holds(&f, || guards::holder_not_the_vault(&f.e, &investor)); + refuses(&f, VaultError::NoEntitlement, || { + guards::holder_not_the_vault(&f.e, &f.vault.address) + }); +} + +#[test] +fn feed_bound_here_and_answering_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() { + let f = setup(); + holds(&f, || { + guards::feed_bound_here_and_answering(&f.e, &f.oracle.address) + }); + + let unbound = f.e.register( + NavOracleContract, + ( + f.admin.clone(), + f.attester.clone(), + f.guardian.clone(), + open_feed(), + ), + ); + // The compliance contract is bound to this vault but answers no feed view. + for not_a_feed_here in [unbound, f.share.compliance()] { + refuses(&f, VaultError::FeedNotBound, || { + guards::feed_bound_here_and_answering(&f.e, ¬_a_feed_here) + }); + } +} + +#[test] +fn valid_change_checks_each_kind_of_change() { + let f = setup(); + let stranger = Address::generate(&f.e); + for change in [ + VaultChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])), + VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), + VaultChange::Custodian(stranger.clone()), + VaultChange::Notice(0), + VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY), + VaultChange::GrantRole(symbol_short!("guardian"), stranger.clone()), + VaultChange::RevokeRole(symbol_short!("guardian"), stranger.clone()), + VaultChange::Admin(stranger.clone()), + VaultChange::Oracle(f.oracle.address.clone()), + VaultChange::MinEpochDuration(MIN_EPOCH_DURATION_FLOOR), + ] { + holds(&f, || guards::valid_change(&f.e, &change)); + } + + for (change, error) in [ + ( + VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY - 1), + VaultError::UpgradeDelayTooShort, + ), + ( + VaultChange::Notice(MAX_NOTICE_SECS + 1), + VaultError::NoticeTooLong, + ), + ( + VaultChange::WindDownDelay(MAX_WIND_DOWN_DELAY + 1), + VaultError::WindDownDelayTooLong, + ), + ( + VaultChange::Oracle(stranger.clone()), + VaultError::FeedNotBound, + ), + ( + VaultChange::Oracle(f.share.compliance()), + VaultError::FeedNotBound, + ), + ( + VaultChange::Custodian(f.vault.address.clone()), + VaultError::InvalidCustodian, + ), + ( + VaultChange::MinEpochDuration(MIN_EPOCH_DURATION_CEILING + 1), + VaultError::InvalidAmount, + ), + ] { + refuses(&f, error, || guards::valid_change(&f.e, &change)); + } +} + +#[test] +fn not_a_second_code_change_refuses_a_code_change_while_one_waits() { + let f = setup(); + let upgrade = VaultChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + holds(&f, || guards::not_a_second_code_change(&f.e, &upgrade)); + + f.vault + .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); + refuses(&f, VaultError::UpgradeProposalExists, || { + guards::not_a_second_code_change(&f.e, &upgrade) + }); + refuses(&f, VaultError::UpgradeProposalExists, || { + guards::not_a_second_code_change(&f.e, &VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY)) + }); + holds(&f, || { + guards::not_a_second_code_change(&f.e, &VaultChange::Notice(0)) + }); +} + +#[test] +fn no_wind_down_proposed_refuses_a_standing_proposal() { + let f = setup(); + holds(&f, || guards::no_wind_down_proposed(&f.e)); + + f.vault.propose_wind_down(); + refuses(&f, VaultError::WindDownAlreadyProposed, || { + guards::no_wind_down_proposed(&f.e) + }); +} + +#[test] +fn wind_down_proposed_refuses_without_a_standing_proposal() { + let f = setup(); + refuses(&f, VaultError::WindDownNotProposed, || { + guards::wind_down_proposed(&f.e) + }); + + f.vault.propose_wind_down(); + holds(&f, || guards::wind_down_proposed(&f.e)); + + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + refuses(&f, VaultError::WindDownNotProposed, || { + guards::wind_down_proposed(&f.e) + }); +} + +#[test] +fn wind_down_delay_elapsed_refuses_before_the_activation_time() { + let f = setup(); + let now = f.e.ledger().timestamp(); + let proposal = |active_at| WindDownInfo { + status: WindDownStatus::Proposed, + active_at, + round: 0, + }; + + holds(&f, || guards::wind_down_delay_elapsed(&f.e, &proposal(now))); + refuses(&f, VaultError::WindDownDelayNotElapsed, || { + guards::wind_down_delay_elapsed(&f.e, &proposal(now + 1)) + }); +} + +#[test] +fn distribution_started_refuses_before_the_first_round() { + let f = setup(); + let _holder = f.holder(500); + refuses(&f, VaultError::DistributionNotStarted, || { + guards::distribution_started(&f.e) + }); + + wind_down_now(&f); + refuses(&f, VaultError::DistributionNotStarted, || { + guards::distribution_started(&f.e) + }); + + f.vault.finalize_wind_down_round(); + holds(&f, || guards::distribution_started(&f.e)); +} + +#[test] +fn something_to_distribute_refuses_zero_and_below() { + let f = setup(); + holds(&f, || guards::something_to_distribute(&f.e, 1)); + refuses(&f, VaultError::NothingToDistribute, || { + guards::something_to_distribute(&f.e, 0) + }); + refuses(&f, VaultError::NothingToDistribute, || { + guards::something_to_distribute(&f.e, -1) + }); +} + +#[test] +fn has_entitlement_refuses_when_nothing_is_surrendered_or_owed() { + let f = setup(); + holds(&f, || guards::has_entitlement(&f.e, 1, 0)); + holds(&f, || guards::has_entitlement(&f.e, 0, 1)); + refuses(&f, VaultError::NoEntitlement, || { + guards::has_entitlement(&f.e, 0, 0) + }); +} + +#[test] +fn positive_amount_refuses_zero_and_below() { + let f = setup(); + holds(&f, || guards::positive_amount(&f.e, 1)); + refuses(&f, VaultError::InvalidAmount, || { + guards::positive_amount(&f.e, 0) + }); + refuses(&f, VaultError::InvalidAmount, || { + guards::positive_amount(&f.e, -1) + }); +} + +#[test] +fn feed_valid_refuses_a_stale_or_paused_feed() { + let f = setup(); + f.attest(wad(1)); + holds(&f, || guards::feed_valid(&f.e)); + + f.advance(f.oracle.config().freshness_duration + 1); + refuses(&f, VaultError::FeedNotValid, || guards::feed_valid(&f.e)); + + f.attest(wad(1)); + holds(&f, || guards::feed_valid(&f.e)); + f.oracle.pause_nav(&f.guardian); + refuses(&f, VaultError::FeedNotValid, || guards::feed_valid(&f.e)); +} + +#[test] +fn within_cap_refuses_a_deposit_past_the_cap() { + let f = setup(); + holds(&f, || guards::within_cap(&f.e, i128::MAX)); + + f.vault.set_deposit_cap(&Some(1_000)); + let investor = f.investor(400); + f.vault.request_deposit(&investor, &400); + holds(&f, || guards::within_cap(&f.e, 600)); + refuses(&f, VaultError::DepositCapExceeded, || { + guards::within_cap(&f.e, 601) + }); + refuses(&f, VaultError::AmountTooLarge, || { + guards::within_cap(&f.e, i128::MAX) + }); +} + +#[test] +fn within_free_reserve_refuses_more_than_the_free_reserve() { + let f = setup(); + let _holder = f.holder(500); + + holds(&f, || guards::within_free_reserve(&f.e, 1_000)); + refuses(&f, VaultError::ReserveCommittedToExits, || { + guards::within_free_reserve(&f.e, 1_001) + }); +} + +#[test] +fn no_open_deposit_refuses_a_second_request_in_the_epoch() { + let f = setup(); + let investor = f.investor(1_000); + let current = f.vault.current_epoch(); + holds(&f, || guards::no_open_deposit(&f.e, current, &investor)); + + f.vault.request_deposit(&investor, &400); + refuses(&f, VaultError::RequestOutstanding, || { + guards::no_open_deposit(&f.e, current, &investor) + }); + holds(&f, || guards::no_open_deposit(&f.e, current + 1, &investor)); +} + +#[test] +fn no_open_redeem_refuses_a_second_request_in_the_epoch() { + let f = setup(); + let holder = f.holder(500); + let current = f.vault.current_epoch(); + holds(&f, || guards::no_open_redeem(&f.e, current, &holder)); + + f.vault.request_redeem(&holder, &100); + refuses(&f, VaultError::RequestOutstanding, || { + guards::no_open_redeem(&f.e, current, &holder) + }); + holds(&f, || guards::no_open_redeem(&f.e, current + 1, &holder)); +} + +#[test] +fn not_claimed_refuses_a_claimed_request() { + let f = setup(); + holds(&f, || guards::not_claimed(&f.e, false)); + refuses(&f, VaultError::AlreadyClaimed, || { + guards::not_claimed(&f.e, true) + }); +} + +/// Beyond the whole balance the guard stands aside: the token's own check +/// refuses that request. +#[test] +fn needs_no_frozen_shares_refuses_a_request_that_reaches_frozen_shares() { + let f = setup(); + let holder = f.holder(500); + f.share.freeze_partial_tokens(&holder, &400, &f.compliance); + + holds(&f, || guards::needs_no_frozen_shares(&f.e, &holder, 100)); + refuses(&f, VaultError::Frozen, || { + guards::needs_no_frozen_shares(&f.e, &holder, 101) + }); + refuses(&f, VaultError::Frozen, || { + guards::needs_no_frozen_shares(&f.e, &holder, 500) + }); + holds(&f, || guards::needs_no_frozen_shares(&f.e, &holder, 501)); +} + +#[test] +fn refund_covered_refuses_a_refund_beyond_the_free_reserve() { + let f = setup(); + let _holder = f.holder(500); + + holds(&f, || guards::refund_covered(&f.e, 1_000)); + refuses(&f, VaultError::ClaimNotCovered, || { + guards::refund_covered(&f.e, 1_001) + }); +} + +#[test] +fn payout_covered_refuses_a_payout_beyond_the_liquid_reserve() { + let f = setup(); + let _holder = f.holder(500); + let investor = f.investor(300); + f.vault.request_deposit(&investor, &300); + + holds(&f, || guards::payout_covered(&f.e, 1_000)); + refuses(&f, VaultError::ClaimNotCovered, || { + guards::payout_covered(&f.e, 1_001) + }); +} + +#[test] +fn cancellation_covered_refuses_a_cancellation_beyond_the_balance() { + let f = setup(); + let investor = f.investor(100); + f.vault.request_deposit(&investor, &100); + holds(&f, || guards::cancellation_covered(&f.e, 100)); + + f.claw_back(1); + holds(&f, || guards::cancellation_covered(&f.e, 99)); + refuses(&f, VaultError::EscrowNotCovered, || { + guards::cancellation_covered(&f.e, 100) + }); +} + +#[test] +fn escrow_covered_refuses_while_the_balance_is_below_the_escrow() { + let f = setup(); + let investor = f.investor(100); + f.vault.request_deposit(&investor, &100); + holds(&f, || guards::escrow_covered(&f.e)); + + f.claw_back(1); + refuses(&f, VaultError::EscrowNotCovered, || { + guards::escrow_covered(&f.e) + }); +} + +#[test] +fn eligible_to_receive_refuses_a_holder_the_token_would_not_take() { + let f = setup(); + let listed = f.investor(0); + holds(&f, || guards::eligible_to_receive(&f.e, &listed, 1)); + + let delisted = f.unverified_investor(0); + refuses(&f, VaultError::HolderNotEligible, || { + guards::eligible_to_receive(&f.e, &delisted, 1) + }); + + let frozen = f.investor(0); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + refuses(&f, VaultError::HolderNotEligible, || { + guards::eligible_to_receive(&f.e, &frozen, 1) + }); + + let module = f.e.register(RefusesEveryTransfer, ()); + f.add_compliance_module(ComplianceHook::CanTransfer, &module); + refuses(&f, VaultError::HolderNotEligible, || { + guards::eligible_to_receive(&f.e, &listed, 1) + }); +} + +#[test] +fn epoch_open_refuses_a_sealed_epoch() { + let f = setup(); + let open = epoch(&f, f.vault.current_epoch()); + holds(&f, || guards::epoch_open(&f.e, &open)); + + let sealed = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::EpochNotOpen, || { + guards::epoch_open(&f.e, &sealed) + }); +} + +#[test] +fn epoch_fulfilled_refuses_an_epoch_not_yet_priced() { + let f = setup(); + let id = f.close_epoch(); + let sealed = epoch(&f, id); + refuses(&f, VaultError::EpochNotFulfilled, || { + guards::epoch_fulfilled(&f.e, &sealed) + }); + + f.fulfill_epoch_at(id, wad(1)); + let priced = epoch(&f, id); + holds(&f, || guards::epoch_fulfilled(&f.e, &priced)); +} + +#[test] +fn not_priced_refuses_a_priced_epoch() { + let f = setup(); + let id = f.close_epoch(); + let sealed = epoch(&f, id); + holds(&f, || guards::not_priced(&f.e, &sealed)); + + f.fulfill_epoch_at(id, wad(1)); + let priced = epoch(&f, id); + refuses(&f, VaultError::AlreadyPriced, || { + guards::not_priced(&f.e, &priced) + }); +} + +#[test] +fn no_price_available_refuses_a_sealed_epoch_the_vault_could_price() { + let f = setup(); + let open = epoch(&f, f.vault.current_epoch()); + holds(&f, || guards::no_price_available(&f.e, &open)); + + let id = f.close_epoch(); + let sealed = epoch(&f, id); + holds(&f, || guards::no_price_available(&f.e, &sealed)); + + f.attest(wad(1)); + refuses(&f, VaultError::PriceAvailable, || { + guards::no_price_available(&f.e, &sealed) + }); + + f.vault.pause(&f.guardian); + holds(&f, || guards::no_price_available(&f.e, &sealed)); + f.vault.unpause(&f.admin); + + wind_down_now(&f); + f.attest(wad(1)); + holds(&f, || guards::no_price_available(&f.e, &sealed)); +} + +#[test] +fn fulfilable_refuses_an_epoch_the_vault_cannot_price_yet() { + let f = setup(); + let open = epoch(&f, f.vault.current_epoch()); + refuses(&f, VaultError::EpochNotPending, || { + guards::fulfilable(&f.e, &open) + }); + + f.advance(f.oracle.config().freshness_duration); + let unpriced = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::FeedNotValid, || { + guards::fulfilable(&f.e, &unpriced) + }); + f.attest(wad(1)); + holds(&f, || guards::fulfilable(&f.e, &unpriced)); + + f.advance(10); + let behind_the_report = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::AttestationBeforeClose, || { + guards::fulfilable(&f.e, &behind_the_report) + }); + + f.vault.set_notice(&3_600); + let within_the_notice = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::NoticeNotElapsed, || { + guards::fulfilable(&f.e, &within_the_notice) + }); +} + +#[test] +fn allowed_to_refuses_anything_but_going_ahead() { + let f = setup(); + let listed = f.investor(0); + let delisted = f.unverified_investor(0); + let frozen = f.investor(0); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + + holds(&f, || { + guards::allowed_to(&f.e, Exit::RequestDeposit, &listed) + }); + refuses(&f, VaultError::NotAllowlisted, || { + guards::allowed_to(&f.e, Exit::RequestDeposit, &delisted) + }); + refuses(&f, VaultError::Frozen, || { + guards::allowed_to(&f.e, Exit::RequestDeposit, &frozen) + }); + + holds(&f, || { + guards::allowed_to(&f.e, Exit::RequestRedeem, &delisted) + }); + refuses(&f, VaultError::Frozen, || { + guards::allowed_to(&f.e, Exit::RequestRedeem, &frozen) + }); + + refuses(&f, VaultError::NotAllowlisted, || { + guards::allowed_to(&f.e, Exit::ClaimDeposit, &delisted) + }); +} + +#[test] +fn not_refused_refuses_a_refusal_or_a_hold() { + let f = setup(); + for decision in [ + Decision::Proceed, + Decision::ConvertToExit, + Decision::Surrender, + ] { + holds(&f, || { + guards::not_refused(&f.e, decision, Standing::Delisted) + }); + } + + refuses(&f, VaultError::NotAllowlisted, || { + guards::not_refused(&f.e, Decision::Refuse, Standing::Delisted) + }); + refuses(&f, VaultError::Frozen, || { + guards::not_refused(&f.e, Decision::Hold, Standing::Frozen) + }); +} diff --git a/contracts/async-vault/src/test/health.rs b/contracts/async-vault/src/test/health.rs new file mode 100644 index 00000000..668b42ee --- /dev/null +++ b/contracts/async-vault/src/test/health.rs @@ -0,0 +1,182 @@ +use super::*; +use crate::Health; + +fn all_healthy() -> Health { + Health { + setup_open: false, + token_bound: true, + oracle_bound: true, + sole_token_manager: true, + allowlisted: true, + vault_unfrozen: true, + compliance_bound: true, + decimals_match: true, + addresses_distinct: true, + custodian_set: true, + feed_answers: true, + feed_valid: true, + freshness_covers_notice: true, + } +} + +#[test] +fn a_finished_vault_with_a_fresh_report_is_healthy() { + let f = setup(); + f.attest(wad(1)); + + assert_eq!(f.vault.health(), all_healthy()); +} + +#[test] +fn an_open_setup_is_reported() { + let f = setup_unfinished(); + f.attest(wad(1)); + + assert_eq!( + f.vault.health(), + Health { + setup_open: true, + ..all_healthy() + } + ); +} + +#[test] +fn each_missing_wiring_step_shows_in_its_own_flag() { + let cases = [ + ( + Miswire::TokenUnbound, + Health { + token_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::OracleUnbound, + Health { + oracle_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::NoManagerRole, + Health { + sole_token_manager: false, + ..all_healthy() + }, + ), + ( + Miswire::SecondManager, + Health { + sole_token_manager: false, + ..all_healthy() + }, + ), + ( + Miswire::NotAllowlisted, + Health { + allowlisted: false, + ..all_healthy() + }, + ), + ( + Miswire::VaultFrozen, + Health { + vault_unfrozen: false, + ..all_healthy() + }, + ), + ( + Miswire::ComplianceUnbound, + Health { + compliance_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::ComplianceBoundElsewhere, + Health { + compliance_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::ComplianceSetupOpen, + Health { + compliance_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::AssetDecimalsDiffer, + Health { + decimals_match: false, + ..all_healthy() + }, + ), + ( + Miswire::AssetIsShareToken, + Health { + addresses_distinct: false, + ..all_healthy() + }, + ), + ( + Miswire::OracleNotAFeed, + Health { + feed_answers: false, + feed_valid: false, + freshness_covers_notice: false, + ..all_healthy() + }, + ), + ( + Miswire::NoCustodian, + Health { + custodian_set: false, + ..all_healthy() + }, + ), + ( + Miswire::CustodianIsVault, + Health { + custodian_set: false, + ..all_healthy() + }, + ), + ]; + for (miswire, expected) in cases { + let f = miswired(miswire); + f.attest(wad(1)); + + assert_eq!( + f.vault.health(), + Health { + setup_open: true, + ..expected + }, + "{miswire:?}" + ); + } +} + +#[test] +fn a_feed_with_no_report_or_the_nav_paused_is_not_valid() { + let f = setup_unfinished(); + assert!(!f.vault.health().feed_valid); + + f.attest(wad(1)); + f.oracle.pause_nav(&f.guardian); + assert!(!f.vault.health().feed_valid); +} + +#[test] +fn a_notice_longer_than_a_report_lives_is_reported() { + let f = setup(); + f.attest(wad(1)); + let freshness = f.oracle.config().freshness_duration; + + f.vault.set_notice(&(freshness + 1)); + + assert!(!f.vault.health().freshness_covers_notice); +} diff --git a/contracts/async-vault/src/test/lock_policy.rs b/contracts/async-vault/src/test/lock_policy.rs new file mode 100644 index 00000000..09a8ccfd --- /dev/null +++ b/contracts/async-vault/src/test/lock_policy.rs @@ -0,0 +1,323 @@ +use super::*; +use crate::lock_policy::{self, Decision, Exit, Standing}; + +fn in_and_out_of_a_wind_down(exit: Exit, standing: Standing) -> [Decision; 2] { + [ + lock_policy::decide(exit, standing, false), + lock_policy::decide(exit, standing, true), + ] +} + +#[test] +fn a_listed_holder_may_request_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestDeposit, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_may_not_request_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestDeposit, Standing::Delisted), + [Decision::Refuse; 2] + ); +} + +#[test] +fn a_frozen_holder_may_not_request_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestDeposit, Standing::Frozen), + [Decision::Refuse; 2] + ); +} + +#[test] +fn a_listed_holder_claims_the_shares() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Listed, false), + Decision::Proceed + ); +} + +#[test] +fn a_delisted_holders_claim_joins_the_exit_queue() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Delisted, false), + Decision::ConvertToExit + ); +} + +#[test] +fn a_frozen_holders_claim_is_held() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Frozen, false), + Decision::Hold + ); +} + +#[test] +fn in_a_wind_down_a_listed_holder_claims_the_shares() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Listed, true), + Decision::Proceed + ); +} + +#[test] +fn in_a_wind_down_a_delisted_holders_claim_joins_the_entitlement() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Delisted, true), + Decision::Surrender + ); +} + +#[test] +fn in_a_wind_down_a_frozen_holders_claim_is_held() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Frozen, true), + Decision::Hold + ); +} + +#[test] +fn a_listed_holder_may_cancel_a_redemption_back_to_shares() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Listed, false), + Decision::Proceed + ); +} + +#[test] +fn a_delisted_holder_may_not_cancel_a_redemption_back_to_shares() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Delisted, false), + Decision::Refuse + ); +} + +#[test] +fn a_frozen_holders_redemption_is_held() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Frozen, false), + Decision::Hold + ); +} + +#[test] +fn in_a_wind_down_a_listed_holders_cancelled_redemption_joins_the_entitlement() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Listed, true), + Decision::Surrender + ); +} + +#[test] +fn in_a_wind_down_a_delisted_holders_cancelled_redemption_joins_the_entitlement() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Delisted, true), + Decision::Surrender + ); +} + +#[test] +fn in_a_wind_down_a_frozen_holders_redemption_is_held() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Frozen, true), + Decision::Hold + ); +} + +#[test] +fn a_listed_holder_may_cancel_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::CancelDeposit, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_may_cancel_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::CancelDeposit, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_deposit_cancellation_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::CancelDeposit, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_listed_holder_may_request_a_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_may_request_a_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_redemption_request_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_listed_holder_claims_the_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimRedeem, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_claims_the_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimRedeem, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_redemption_claim_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimRedeem, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_listed_holder_claims_the_wind_down() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimWindDown, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_claims_the_wind_down() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimWindDown, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_wind_down_claim_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimWindDown, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_frozen_holder_is_refused_as_frozen() { + assert_eq!( + lock_policy::refused_with(Standing::Frozen), + VaultError::Frozen + ); +} + +#[test] +fn a_delisted_holder_is_refused_as_not_allowlisted() { + assert_eq!( + lock_policy::refused_with(Standing::Delisted), + VaultError::NotAllowlisted + ); +} + +#[test] +fn standing_is_listed_for_an_allowlisted_holder() { + let f = setup(); + let holder = f.investor(0); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Listed + ); +} + +#[test] +fn standing_is_delisted_for_an_address_that_was_never_allowlisted() { + let f = setup(); + let stranger = f.unverified_investor(0); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &stranger)), + Standing::Delisted + ); +} + +#[test] +fn standing_is_delisted_for_a_holder_listed_then_removed() { + let f = setup(); + let holder = f.investor(0); + f.identity.allow(&holder, &false); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Delisted + ); +} + +#[test] +fn standing_is_frozen_for_a_frozen_holder_on_the_allowlist() { + let f = setup(); + let holder = f.investor(0); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Frozen + ); +} + +#[test] +fn standing_is_frozen_for_a_holder_both_frozen_and_delisted() { + let f = setup(); + let holder = f.investor(0); + f.identity.allow(&holder, &false); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Frozen + ); +} + +#[test] +fn free_shares_is_the_full_balance_when_nothing_is_frozen() { + let f = setup(); + let holder = f.holder(500); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::free_shares(&f.e, &holder)), + 500 + ); +} + +#[test] +fn free_shares_excludes_a_partial_freeze() { + let f = setup(); + let holder = f.holder(500); + f.share.freeze_partial_tokens(&holder, &400, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::free_shares(&f.e, &holder)), + 100 + ); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index a22c194a..ce20b619 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -1,17 +1,29 @@ mod cancel; +mod clawback; mod compliance; mod constructor; mod controls; mod conversions; +mod delisted; mod deposit; mod deposit_cap; mod epochs; mod events; +mod governance; +mod guards; +mod health; +mod lock_policy; mod multi_epoch; mod notice; mod oracle_pricing; +mod oracle_swap; mod pricing; mod redeem; +mod sealing; +mod settlement; +mod setup; +mod share_returns; +mod signatures; mod supply; mod timing; mod treasury; @@ -21,6 +33,7 @@ mod wind_down; extern crate std; +use soroban_sdk::{contract, contractimpl}; pub(crate) use soroban_sdk::{ symbol_short, testutils::{Address as _, Ledger as _}, @@ -28,23 +41,36 @@ pub(crate) use soroban_sdk::{ Address, BytesN, Env, String, }; -pub(crate) use ::compliance::{Compliance, ComplianceClient}; +pub(crate) use ::compliance::{Compliance, ComplianceChange, ComplianceClient}; pub(crate) use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; -pub(crate) use nav_oracle::{NavOracleContract, NavOracleContractClient, NavReport, OracleConfig}; +pub(crate) use nav_oracle::{ + NavInput, NavOracleContract, NavOracleContractClient, OracleConfig, MIN_ANSWER, +}; pub(crate) use share_token::{ShareToken, ShareTokenClient}; pub(crate) use stellar_contract_utils::math::wad::WAD_SCALE; +use stellar_tokens::rwa::compliance::ComplianceHook; +pub(crate) use strata_timelock::DAY_DURATION; pub(crate) use crate::{ - AsyncVault, AsyncVaultClient, DirectUnitPricing, EpochStatus, PricingScheme, UpgradeAction, - VaultError, VaultRoles, WindDownStatus, MAX_NOTICE_SECS, MAX_UPGRADE_DELAY, - MAX_WIND_DOWN_DELAY, MIN_UPGRADE_DELAY, + AsyncVault, AsyncVaultClient, DirectUnitPricing, EpochStatus, PricingScheme, VaultChange, + VaultError, VaultPendingChange, VaultRoles, WindDownStatus, MAX_NOTICE_SECS, MAX_UPGRADE_DELAY, + MAX_WIND_DOWN_DELAY, MIN_UPGRADE_DELAY, MIN_WIND_DOWN_DELAY, }; fn wad(whole: i128) -> i128 { whole * WAD_SCALE } +fn nav_input(e: &Env, nav_per_share: i128, expires_at: u64) -> NavInput { + NavInput { + nav_per_share, + expires_at, + proof_hash: BytesN::from_array(e, &[7; 32]), + proof_uri: String::from_str(e, "ipfs://strata-proof"), + } +} + struct Fixture<'a> { e: Env, vault: AsyncVaultClient<'a>, @@ -72,9 +98,48 @@ impl Fixture<'_> { }); } + /// Signs one call to the vault as `who` and nothing else, so the call's own + /// role check is the only thing that can refuse it. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + use soroban_sdk::testutils::{MockAuth, MockAuthInvoke}; + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.vault.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + + /// The last call asked `who` alone to sign, once, on exactly this call. + fn signed_once_by( + &self, + who: &Address, + fn_name: &str, + args: soroban_sdk::Vec, + ) { + use soroban_sdk::testutils::{AuthorizedFunction, AuthorizedInvocation}; + assert_eq!( + self.e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.vault.address.clone(), + soroban_sdk::Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); + } + fn investor(&self, funded: i128) -> Address { let who = self.unverified_investor(funded); - self.identity.allow(&who, &true, &self.compliance); + self.identity.allow(&who, &true); who } @@ -88,19 +153,32 @@ impl Fixture<'_> { TokenClient::new(&self.e, &self.asset).balance(who) } + /// The asset's issuer takes `amount` out of the vault's balance. + fn claw_back(&self, amount: i128) { + StellarAssetClient::new(&self.e, &self.asset).clawback(&self.vault.address, &amount); + } + fn shares(&self, who: &Address) -> i128 { self.share.balance(who) } + /// Posts a report that the feed's own freshness window bounds, waiting out + /// the cooldown first when the last report is too recent. fn attest(&self, nav_per_share: i128) { - self.oracle.attest( - &NavReport { - nav_per_share, - timestamp: 0, - expires_at: 1_000_000, - }, - &self.attester, - ); + self.wait_out_the_cooldown(); + self.oracle + .attest(&nav_input(&self.e, nav_per_share, u64::MAX), &self.attester); + } + + fn wait_out_the_cooldown(&self) { + let Ok(Ok(latest)) = self.oracle.try_latest() else { + return; + }; + let ready = latest.accepted_at + self.oracle.config().cooldown_secs; + let now = self.e.ledger().timestamp(); + if now < ready { + self.advance(ready - now); + } } fn holder(&self, shares: i128) -> Address { @@ -127,12 +205,19 @@ impl Fixture<'_> { } fn empty_reserve(&self) { - self.vault.set_custodian(&self.custodian, &self.admin); let free = self.vault.free_reserve(); if free > 0 { self.vault.deploy_to_custodian(&self.treasury, &free); } } + + /// After setup, a module lands only through the compliance contract's timelock. + fn add_compliance_module(&self, hook: ComplianceHook, module: &Address) { + let rules = ComplianceClient::new(&self.e, &self.share.compliance()); + let id = rules.propose_change(&ComplianceChange::AddModule(hook, module.clone())); + self.advance(MIN_UPGRADE_DELAY); + rules.apply_change(&id); + } } pub(crate) fn distinct_roles(e: &Env) -> VaultRoles { @@ -156,20 +241,35 @@ pub(crate) fn refused( + result: Result, Result>, +) { + use soroban_sdk::xdr::{ScErrorCode, ScErrorType}; + assert_eq!( + result, + Err(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Context, + ScErrorCode::InvalidAction + ))) + ); +} + pub(crate) fn register_with(e: &Env, roles: VaultRoles) -> Address { let issuer = Address::generate(e); let asset = e.register_stellar_asset_contract_v2(issuer).address(); e.register(AsyncVault, (&asset, &asset, &asset, roles)) } -/// The permissive feed most tests want: no cooldown, no effective cap. +/// The permissive feed most tests want: the shortest cooldown, no effective cap. fn open_feed() -> OracleConfig { OracleConfig { freshness_duration: 3_600, - cooldown_secs: 0, + cooldown_secs: 1, max_up_bps: 10_000, - max_down_bps: Some(10_000), - min_answer: 1, + max_down_bps: 10_000, + min_answer: MIN_ANSWER, max_answer: 1_000 * WAD_SCALE, } } @@ -178,15 +278,103 @@ fn setup<'a>() -> Fixture<'a> { setup_with_feed(open_feed()) } +/// A vault whose wiring is complete but whose own setup is still open: admin +/// changes are instant and the vault takes no request. +fn setup_unfinished<'a>() -> Fixture<'a> { + miswired(Miswire::Nothing) +} + +/// A wiring step a test leaves out or gets wrong, to see `finish_setup` refuse. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Miswire { + Nothing, + TokenUnbound, + TokenBoundElsewhere, + TokenSetupOpen, + OracleUnbound, + OracleBoundElsewhere, + OracleSetupOpen, + NoManagerRole, + OtherManager, + SecondManager, + PreMinted, + NotAllowlisted, + VaultFrozen, + ComplianceUnbound, + ComplianceBoundElsewhere, + ComplianceSetupOpen, + AssetDecimalsDiffer, + AssetIsShareToken, + /// The compliance contract passed as the oracle: bound here, but no feed. + OracleNotAFeed, + NoCustodian, + CustodianIsVault, + EpochAdvanced, + DepositBooked, + RedemptionBooked, + Committed, + SharesOwed, + SharesToBurn, +} + +/// An asset with six decimals, where the share token has seven. +#[contract] +struct SixDecimalAsset; + +#[contractimpl] +impl SixDecimalAsset { + pub fn decimals(_e: Env) -> u32 { + 6 + } +} + +/// A vault left in setup, wired except for `miswire`. +fn miswired<'a>(miswire: Miswire) -> Fixture<'a> { + build( + open_feed(), + |e, args| e.register(ShareToken, args), + miswire, + false, + ) +} + /// Builds the vault against a caller-chosen feed, so cooldown, freshness and the /// deviation cap can be exercised from a vault test rather than only an oracle one. fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { + setup_with(feed, |e, args| e.register(ShareToken, args)) +} + +/// Builds a vault set whose setup has finished, around a token the caller +/// registers, so a test can run the token's built Wasm instead of its native +/// code. +fn setup_with<'a>( + feed: OracleConfig, + register_token: impl FnOnce(&Env, TokenArgs) -> Address, +) -> Fixture<'a> { + build(feed, register_token, Miswire::Nothing, true) +} + +/// The share token's constructor arguments, in order. +type TokenArgs = (String, String, Address, Address, Address, Address); + +/// Wires the set the way the deploy does, custodian included, apart from +/// `miswire`, then finishes the vault's setup when `finish` is set. +fn build<'a>( + feed: OracleConfig, + register_token: impl FnOnce(&Env, TokenArgs) -> Address, + miswire: Miswire, + finish: bool, +) -> Fixture<'a> { let e = Env::default(); e.mock_all_auths(); e.ledger().set_timestamp(10_000); let issuer = Address::generate(&e); - let asset = e.register_stellar_asset_contract_v2(issuer).address(); + let sac = e.register_stellar_asset_contract_v2(issuer); + // A balance can be clawed back only if the flag was set when it was created. + sac.issuer() + .set_flag(soroban_sdk::testutils::IssuerFlags::ClawbackEnabledFlag); + let asset = sac.address(); let manager = Address::generate(&e); let treasury = Address::generate(&e); let guardian = Address::generate(&e); @@ -203,8 +391,8 @@ fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { let share = ShareTokenClient::new( &e, - &e.register( - ShareToken, + ®ister_token( + &e, ( String::from_str(&e, "Strata Vault USDC"), String::from_str(&e, "bvUSDC"), @@ -215,7 +403,9 @@ fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { ), ), ); - compliance.bind_token(&share.address, &admin); + if miswire != Miswire::ComplianceUnbound { + compliance.bind_token(&share.address, &admin); + } let oracle = NavOracleContractClient::new( &e, @@ -225,12 +415,21 @@ fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { ), ); + let vault_asset = match miswire { + Miswire::AssetDecimalsDiffer => e.register(SixDecimalAsset, ()), + Miswire::AssetIsShareToken => share.address.clone(), + _ => asset.clone(), + }; + let vault_oracle = match miswire { + Miswire::OracleNotAFeed => compliance.address.clone(), + _ => oracle.address.clone(), + }; let contract_id = e.register( AsyncVault, ( - &asset, + &vault_asset, &share.address, - &oracle.address, + &vault_oracle, VaultRoles { governance: admin.clone(), manager: manager.clone(), @@ -241,14 +440,113 @@ fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { }, ), ); - share.grant_role(&contract_id, &symbol_short!("manager"), &admin); - // The vault holds escrowed shares, so returning them on cancellation goes - // through the token's checked transfer. That verifies both sides, so the - // vault has to be a recognised holder itself. - identity.allow(&contract_id, &true, &compliance_authority); + match miswire { + Miswire::NoManagerRole => {} + Miswire::OtherManager => { + share.grant_role(&Address::generate(&e), &symbol_short!("manager"), &admin) + } + _ => share.grant_role(&contract_id, &symbol_short!("manager"), &admin), + } + if miswire == Miswire::SecondManager { + share.grant_role(&Address::generate(&e), &symbol_short!("manager"), &admin); + } + if miswire == Miswire::PreMinted { + // The token's admin mints during the token's own setup, then steps back. + let holder = Address::generate(&e); + identity.allow(&holder, &true); + share.grant_role(&admin, &symbol_short!("manager"), &admin); + share.mint(&holder, &100, &admin); + share.revoke_role(&admin, &symbol_short!("manager"), &admin); + } + // Converting a delisted holder's deposit mints into the vault's own escrow. + if miswire != Miswire::NotAllowlisted { + identity.allow(&contract_id, &true); + } + if miswire == Miswire::VaultFrozen { + // Until `bind_vault`, the token does not know this address is its vault. + share.set_address_frozen(&contract_id, &true, &compliance_authority); + } + match miswire { + Miswire::TokenUnbound => {} + Miswire::TokenBoundElsewhere => { + share.bind_vault(®ister_with(&e, distinct_roles(&e))); + share.finish_setup(); + } + Miswire::TokenSetupOpen => share.bind_vault(&contract_id), + _ => { + share.bind_vault(&contract_id); + share.finish_setup(); + } + } + match miswire { + Miswire::OracleUnbound => {} + Miswire::OracleBoundElsewhere => { + oracle.bind_vault(®ister_with(&e, distinct_roles(&e))); + oracle.finish_setup(); + } + Miswire::OracleSetupOpen => oracle.bind_vault(&contract_id), + _ => { + oracle.bind_vault(&contract_id); + oracle.finish_setup(); + } + } + match miswire { + Miswire::ComplianceUnbound => {} + Miswire::ComplianceBoundElsewhere => { + compliance.bind_vault(®ister_with(&e, distinct_roles(&e))); + compliance.finish_setup(); + } + Miswire::ComplianceSetupOpen => compliance.bind_vault(&contract_id), + _ => { + compliance.bind_vault(&contract_id); + compliance.finish_setup(); + } + } + let vault = AsyncVaultClient::new(&e, &contract_id); + match miswire { + Miswire::NoCustodian => {} + // No entrypoint takes the vault as its own custodian, so the test stores it. + Miswire::CustodianIsVault => e.as_contract(&contract_id, || { + crate::state::set_addr(&e, &crate::keys::DataKey::Custodian, &contract_id) + }), + _ => vault.set_custodian(&custodian), + } + // No entrypoint books these during setup, so the test writes the vault's storage. + match miswire { + Miswire::EpochAdvanced => { + e.as_contract(&contract_id, || crate::state::set_current_epoch(&e, 2)) + } + Miswire::DepositBooked => e.as_contract(&contract_id, || { + let mut epoch = crate::state::get_epoch(&e, crate::state::FIRST_EPOCH).unwrap(); + epoch.total_deposited = 100; + crate::state::set_epoch(&e, crate::state::FIRST_EPOCH, &epoch); + }), + Miswire::RedemptionBooked => e.as_contract(&contract_id, || { + let mut epoch = crate::state::get_epoch(&e, crate::state::FIRST_EPOCH).unwrap(); + epoch.total_shares_redeeming = 100; + crate::state::set_epoch(&e, crate::state::FIRST_EPOCH, &epoch); + }), + Miswire::Committed => e.as_contract(&contract_id, || crate::state::set_committed(&e, 100)), + Miswire::SharesOwed => e.as_contract(&contract_id, || { + crate::state::set_pending_mint_shares(&e, 100) + }), + Miswire::SharesToBurn => e.as_contract(&contract_id, || { + crate::state::set_pending_burn_shares(&e, 100) + }), + _ => {} + } + if finish { + vault.finish_setup(); + // Deposits need a valid report; a second on, so the next sealed epoch is not priced by it. + oracle.attest( + &nav_input(&e, oracle.config().max_answer / 2, u64::MAX), + &attester, + ); + e.ledger().set_timestamp(e.ledger().timestamp() + 1); + } Fixture { - vault: AsyncVaultClient::new(&e, &contract_id), + vault, share, identity, oracle, diff --git a/contracts/async-vault/src/test/multi_epoch.rs b/contracts/async-vault/src/test/multi_epoch.rs index 3bbe2c0c..afc46ead 100644 --- a/contracts/async-vault/src/test/multi_epoch.rs +++ b/contracts/async-vault/src/test/multi_epoch.rs @@ -46,16 +46,16 @@ fn a_younger_epoch_can_be_priced_first() { ); } -/// One aggregate floor is credited at pricing, one floor per claimant is debited. -/// The sum of floors is at most the floor of the sum, so a residue is left behind -/// that no path releases. Three odd share counts lose half a unit each; the odd -/// aggregate loses only half. +/// Each claim floors, so the epoch pays out less than its total credited. Once +/// the last claim settles, the shortfall leaves `committed` and stays in the +/// reserve: nothing is owed for it, and nobody is paid it. #[test] -fn the_rounding_residue_is_never_released() { +fn the_rounding_residue_stays_in_the_reserve_once_the_epoch_settles() { let f = setup(); let a = f.holder(101); let b = f.holder(103); let c = f.holder(105); + let held = f.balance(&f.vault.address); f.vault.request_redeem(&a, &101); f.vault.request_redeem(&b, &103); @@ -72,15 +72,17 @@ fn the_rounding_residue_is_never_released() { f.vault.claim_redeem(&b, &epoch); // floor(103 * 1.5) = 154 f.vault.claim_redeem(&c, &epoch); // floor(105 * 1.5) = 157 - // 151 + 154 + 157 = 462, one short of the 463 credited. - assert_eq!(f.vault.committed(), 1); + // 151 + 154 + 157 = 462: the unit left over is no longer owed. + assert_eq!(f.vault.committed(), 0); + assert_eq!(f.balance(&f.vault.address), held - 462); + assert_eq!(f.vault.free_reserve(), held - 462); } -/// One controller may hold a live redemption in two epochs at once, because the +/// One holder may hold a live redemption in two epochs at once, because the /// outstanding-request check is keyed per epoch. Claims are taken newest first /// here, which today is allowed. #[test] -fn one_controller_redeems_across_two_epochs() { +fn one_holder_redeems_across_two_epochs() { let f = setup(); let a = f.holder(500); @@ -174,13 +176,19 @@ fn a_later_epochs_deposit_does_not_fund_an_earlier_exit() { f.vault.fulfill_epoch(&exits); // Nothing on hand, so the claim waits. - assert!(f.vault.try_claim_redeem(&a, &exits).is_err()); + refused( + f.vault.try_claim_redeem(&a, &exits), + VaultError::ClaimNotCovered, + ); // A deposit into the open epoch is still cancellable, so it changes nothing. f.vault.request_deposit(&late, &400); assert_eq!(f.vault.cancellable_escrow(), 400); assert_eq!(f.vault.uncovered(), 200); - assert!(f.vault.try_claim_redeem(&a, &exits).is_err()); + refused( + f.vault.try_claim_redeem(&a, &exits), + VaultError::ClaimNotCovered, + ); // Money the custodian returns is not cancellable, so it does fund the exit. f.vault.fund(&f.custodian, &200); @@ -227,7 +235,10 @@ fn a_claim_waits_until_the_reserve_covers_it() { f.attest(wad(2)); f.vault.fulfill_epoch(&epoch); - assert!(f.vault.try_claim_redeem(&a, &epoch).is_err()); + refused( + f.vault.try_claim_redeem(&a, &epoch), + VaultError::ClaimNotCovered, + ); f.vault.fund(&f.custodian, &200); assert_eq!(f.vault.claim_redeem(&a, &epoch), 200); @@ -253,7 +264,10 @@ fn a_covered_claim_pays_while_a_larger_one_waits() { // 840 owed in total, only 100 on hand. f.vault.fund(&f.custodian, &100); - assert!(f.vault.try_claim_redeem(&big, &epoch).is_err()); + refused( + f.vault.try_claim_redeem(&big, &epoch), + VaultError::ClaimNotCovered, + ); assert_eq!(f.vault.claim_redeem(&small, &epoch), 40); } @@ -264,7 +278,6 @@ fn the_treasury_cannot_deploy_while_anything_is_uncovered() { let f = setup(); let a = f.holder(200); - f.vault.set_custodian(&f.custodian, &f.admin); f.vault.request_redeem(&a, &200); let epoch = f.close_epoch(); f.attest(wad(3)); @@ -273,5 +286,8 @@ fn the_treasury_cannot_deploy_while_anything_is_uncovered() { // 600 owed against 400 held. assert_eq!(f.vault.uncovered(), 200); assert_eq!(f.vault.free_reserve(), 0); - assert!(f.vault.try_deploy_to_custodian(&f.treasury, &1).is_err()); + refused( + f.vault.try_deploy_to_custodian(&f.treasury, &1), + VaultError::ReserveCommittedToExits, + ); } diff --git a/contracts/async-vault/src/test/notice.rs b/contracts/async-vault/src/test/notice.rs index 3541e753..93cf35b1 100644 --- a/contracts/async-vault/src/test/notice.rs +++ b/contracts/async-vault/src/test/notice.rs @@ -11,7 +11,7 @@ fn a_fresh_vault_has_no_notice() { fn governance_sets_the_notice() { let f = setup(); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); assert_eq!(f.vault.notice(), 3_600); } @@ -21,11 +21,11 @@ fn setting_the_notice_needs_governance_authorisation() { let f = setup(); f.e.set_auths(&[]); - assert!(f.vault.try_set_notice(&3_600, &f.admin).is_err()); + assert!(f.vault.try_set_notice(&3_600).is_err()); assert_eq!(f.vault.notice(), 0); f.e.mock_all_auths(); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); assert_eq!(f.vault.notice(), 3_600); } @@ -41,7 +41,7 @@ fn an_open_epoch_has_no_timestamps() { #[test] fn closing_records_the_moment_and_the_wait() { let f = setup(); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); let closed_at = f.e.ledger().timestamp(); let id = f.close_epoch(); @@ -54,11 +54,11 @@ fn closing_records_the_moment_and_the_wait() { #[test] fn a_later_notice_does_not_move_an_epoch_that_closed() { let f = setup(); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); let id = f.close_epoch(); let before = f.vault.get_epoch(&id).unwrap().priceable_at; - f.vault.set_notice(&86_400, &f.admin); + f.vault.set_notice(&DAY_DURATION); assert_eq!(f.vault.get_epoch(&id).unwrap().priceable_at, before); } @@ -67,7 +67,7 @@ fn a_later_notice_does_not_move_an_epoch_that_closed() { fn an_epoch_cannot_be_priced_inside_its_notice() { let f = setup(); let holder = f.holder(100); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); f.vault.request_redeem(&holder, &100); let epoch = f.close_epoch(); f.attest(wad(2)); @@ -82,7 +82,7 @@ fn an_epoch_cannot_be_priced_inside_its_notice() { fn it_is_priced_once_the_notice_elapses() { let f = setup(); let holder = f.holder(100); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); f.vault.request_redeem(&holder, &100); let epoch = f.close_epoch(); @@ -110,7 +110,7 @@ fn a_notice_of_zero_prices_as_soon_as_the_valuation_lands() { fn the_notice_is_reported_before_the_valuation() { let f = setup(); let holder = f.holder(100); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); f.vault.request_redeem(&holder, &100); f.advance(60); let epoch = f.close_epoch(); @@ -127,7 +127,7 @@ fn the_notice_is_reported_before_the_valuation() { fn the_notice_does_not_reopen_the_cancellation_window() { let f = setup(); let investor = f.investor(1_000); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); f.vault.request_deposit(&investor, &1_000); let epoch = f.close_epoch(); f.attest(wad(2)); @@ -149,7 +149,7 @@ fn a_notice_beyond_the_maximum_is_refused() { let f = setup(); refused( - f.vault.try_set_notice(&(MAX_NOTICE_SECS + 1), &f.admin), + f.vault.try_set_notice(&(MAX_NOTICE_SECS + 1)), VaultError::NoticeTooLong, ); assert_eq!(f.vault.notice(), 0); @@ -160,22 +160,43 @@ fn a_notice_above_the_upgrade_delay_is_refused() { let f = setup(); refused( - f.vault.try_set_notice(&(MIN_UPGRADE_DELAY + 1), &f.admin), + f.vault.try_set_notice(&(MIN_UPGRADE_DELAY + 1)), VaultError::NoticeAboveUpgradeDelay, ); assert_eq!(f.vault.notice(), 0); } +#[test] +fn an_epoch_whose_notice_would_end_past_the_clock_stays_open() { + let f = setup(); + f.vault.set_notice(&3_600); + f.e.ledger().set_timestamp(u64::MAX - 1_000); + let before = f.vault.current_epoch(); + + refused( + f.vault.try_close_epoch(&f.manager), + VaultError::AmountTooLarge, + ); + + assert_eq!(f.vault.current_epoch(), before); + assert_eq!( + f.vault.get_epoch(&before).unwrap().status, + EpochStatus::Open + ); +} + #[test] fn a_notice_at_the_maximum_is_accepted() { let f = setup(); // The delay must be raised first, since notice cannot outgrow the upgrade delay. - f.vault.propose_upgrade_delay(&MAX_NOTICE_SECS, &f.admin); + let id = f + .vault + .propose_change(&VaultChange::UpgradeDelay(MAX_NOTICE_SECS)); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + f.vault.apply_change(&id); - f.vault.set_notice(&MAX_NOTICE_SECS, &f.admin); + f.vault.set_notice(&MAX_NOTICE_SECS); assert_eq!(f.vault.notice(), MAX_NOTICE_SECS); } diff --git a/contracts/async-vault/src/test/oracle_pricing.rs b/contracts/async-vault/src/test/oracle_pricing.rs index 2e4a9aae..d1ec49ec 100644 --- a/contracts/async-vault/src/test/oracle_pricing.rs +++ b/contracts/async-vault/src/test/oracle_pricing.rs @@ -1,8 +1,11 @@ use super::*; +use strata_bindings::OracleState; + #[test] fn fulfill_is_rejected_before_the_first_attestation() { - let f = setup(); + let f = setup_unfinished(); + f.vault.finish_setup(); f.close_epoch(); refused(f.vault.try_fulfill_epoch(&1), VaultError::FeedNotValid); @@ -10,15 +13,15 @@ fn fulfill_is_rejected_before_the_first_attestation() { } #[test] -fn fulfill_is_rejected_while_the_ripcord_is_raised() { +fn fulfill_is_rejected_while_the_nav_is_paused() { let f = setup(); f.close_epoch(); f.attest(wad(2)); - f.oracle.set_ripcord(&true, &f.admin); + f.oracle.pause_nav(&f.guardian); refused(f.vault.try_fulfill_epoch(&1), VaultError::FeedNotValid); - f.oracle.set_ripcord(&false, &f.admin); + f.oracle.set_nav_paused(&false); assert_eq!(f.vault.fulfill_epoch(&1), wad(2)); } @@ -28,10 +31,14 @@ fn fulfill_is_rejected_once_the_feed_goes_stale() { f.close_epoch(); f.attest(wad(2)); - f.e.ledger().set_timestamp(10_000 + 3_601); + f.advance(3_601); assert!(f.oracle.is_stale()); refused(f.vault.try_fulfill_epoch(&1), VaultError::FeedNotValid); assert_eq!(f.vault.get_epoch(&1).unwrap().status, EpochStatus::Pending); + + f.attest(wad(1)); + f.vault.fulfill_epoch(&1); + assert_eq!(f.vault.get_epoch(&1).unwrap().share_price, wad(1)); } #[test] @@ -49,18 +56,6 @@ fn the_epoch_is_priced_at_the_attestation() { assert_eq!(f.oracle.nav_per_share(), 3 * WAD_SCALE / 2); } -#[test] -fn a_paused_feed_is_refused_by_the_vault_not_the_oracle() { - let f = setup(); - let holder = f.holder(100); - f.vault.request_redeem(&holder, &100); - let epoch = f.close_epoch(); - - f.oracle.raise_ripcord(&f.guardian); - - refused(f.vault.try_fulfill_epoch(&epoch), VaultError::FeedNotValid); -} - #[test] fn an_epoch_is_not_priced_against_a_valuation_older_than_its_close() { let f = setup(); @@ -99,12 +94,17 @@ fn a_valuation_taken_in_the_closing_ledger_prices_the_epoch() { let holder = f.holder(100); f.vault.request_redeem(&holder, &100); + f.advance(60); let epoch = f.close_epoch(); f.attest(wad(3)); f.vault.fulfill_epoch(&epoch); assert_eq!(f.vault.get_epoch(&epoch).unwrap().share_price, wad(3)); + assert_eq!( + f.oracle.attested_at(), + f.vault.get_epoch(&epoch).unwrap().closed_at + ); } #[test] @@ -120,3 +120,44 @@ fn a_stale_valuation_no_longer_closes_the_cancellation_window() { // not readable and the escape hatch is still open. assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 1_000); } + +#[test] +fn a_priced_redemption_still_pays_while_the_nav_is_paused() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.fulfill_epoch(wad(2)); + f.oracle.pause_nav(&f.guardian); + assert_eq!(f.oracle.state(), OracleState::Paused); + assert_eq!(f.vault.uncovered(), 0); + + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 400); + assert_eq!(f.balance(&holder), 400); +} + +#[test] +fn a_priced_redemption_still_pays_once_the_feed_goes_stale() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.fulfill_epoch(wad(2)); + f.advance(f.oracle.config().freshness_duration + 1); + assert_eq!(f.oracle.state(), OracleState::Stale); + assert_eq!(f.vault.uncovered(), 0); + + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 400); + assert_eq!(f.balance(&holder), 400); +} + +#[test] +fn a_priced_deposit_still_claims_while_the_nav_is_paused() { + let f = setup(); + let investor = f.investor(400); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + f.oracle.pause_nav(&f.guardian); + assert_eq!(f.oracle.state(), OracleState::Paused); + + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 200); + assert_eq!(f.shares(&investor), 200); +} diff --git a/contracts/async-vault/src/test/oracle_swap.rs b/contracts/async-vault/src/test/oracle_swap.rs new file mode 100644 index 00000000..41ff73d3 --- /dev/null +++ b/contracts/async-vault/src/test/oracle_swap.rs @@ -0,0 +1,387 @@ +use soroban_sdk::xdr::{ScErrorCode, ScErrorType}; +use soroban_sdk::{contract, contractimpl, Symbol}; + +use super::*; + +/// A feed whose answer to "which vault?" a test can change after proposing it. +#[contract] +struct FeedStub; + +#[contractimpl] +impl FeedStub { + pub fn report(e: Env, vault: Option
      ) { + e.storage().instance().set(&symbol_short!("vault"), &vault); + } + + pub fn state(_e: Env) -> strata_bindings::OracleState { + strata_bindings::OracleState::Valid + } + + pub fn freshness_duration(_e: Env) -> u64 { + 3_600 + } +} + +#[contractimpl(contracttrait)] +impl strata_bindings::VaultBinding for FeedStub { + fn vault(e: &Env) -> Option
      { + e.storage() + .instance() + .get(&symbol_short!("vault")) + .unwrap_or(None) + } + + fn setup_open(_e: &Env) -> bool { + false + } +} + +/// A feed that passes the swap check, then traps on the view a test names or +/// answers the share price a test sets. +#[contract] +struct BrokenFeed; + +#[contractimpl] +impl BrokenFeed { + pub fn __constructor(e: Env, vault: Address) { + e.storage().instance().set(&symbol_short!("vault"), &vault); + } + + pub fn trap(e: Env, view: Symbol) { + e.storage().instance().set(&symbol_short!("trap"), &view); + } + + pub fn answer_nav(e: Env, nav: i128) { + e.storage().instance().set(&symbol_short!("nav"), &nav); + } + + pub fn state(e: Env) -> strata_bindings::OracleState { + trap_on(&e, symbol_short!("state")); + strata_bindings::OracleState::Valid + } + + pub fn freshness_duration(_e: Env) -> u64 { + 3_600 + } + + pub fn attested_at(e: Env) -> u64 { + trap_on(&e, symbol_short!("attested")); + e.ledger().timestamp() + } + + pub fn nav_per_share(e: Env) -> i128 { + trap_on(&e, symbol_short!("nav")); + e.storage() + .instance() + .get(&symbol_short!("nav")) + .unwrap_or(WAD_SCALE) + } +} + +#[contractimpl(contracttrait)] +impl strata_bindings::VaultBinding for BrokenFeed { + fn vault(e: &Env) -> Option
      { + e.storage().instance().get(&symbol_short!("vault")) + } + + fn setup_open(_e: &Env) -> bool { + false + } +} + +fn trap_on(e: &Env, view: Symbol) { + let trap: Option = e.storage().instance().get(&symbol_short!("trap")); + if trap == Some(view) { + panic!("the feed traps"); + } +} + +/// Installs the feed the way governance would: propose, wait, apply. +fn swap_to_a_broken_feed<'a>(f: &'a Fixture) -> BrokenFeedClient<'a> { + let feed = BrokenFeedClient::new(&f.e, &f.e.register(BrokenFeed, (f.vault.address.clone(),))); + let id = f + .vault + .propose_change(&VaultChange::Oracle(feed.address.clone())); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + assert_eq!(f.vault.oracle(), feed.address); + feed +} + +fn swap_to_a_feed_that_traps_on(f: &Fixture, view: Symbol) { + swap_to_a_broken_feed(f).trap(&view); +} + +fn swap_to_a_feed_that_prices_at(f: &Fixture, nav: i128) { + swap_to_a_broken_feed(f).answer_nav(&nav); +} + +fn unbound_feed<'a>(f: &Fixture<'a>) -> NavOracleContractClient<'a> { + NavOracleContractClient::new( + &f.e, + &f.e.register( + NavOracleContract, + ( + f.admin.clone(), + f.attester.clone(), + f.guardian.clone(), + open_feed(), + ), + ), + ) +} + +fn second_feed<'a>(f: &Fixture<'a>) -> NavOracleContractClient<'a> { + let feed = unbound_feed(f); + feed.bind_vault(&f.vault.address); + feed.finish_setup(); + feed +} + +fn attest_on(f: &Fixture, feed: &NavOracleContractClient, nav_per_share: i128) { + feed.attest(&nav_input(&f.e, nav_per_share, u64::MAX), &f.attester); +} + +#[test] +fn the_oracle_changes_only_through_the_timelock() { + let f = setup(); + let next = second_feed(&f); + + let id = f + .vault + .propose_change(&VaultChange::Oracle(next.address.clone())); + f.advance(MIN_UPGRADE_DELAY - 1); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); + assert_eq!(f.vault.oracle(), f.oracle.address); + + f.advance(1); + f.vault.apply_change(&id); + assert_eq!(f.vault.oracle(), next.address); +} + +#[test] +fn the_next_epoch_prices_from_the_new_feed() { + let f = setup(); + let next = second_feed(&f); + let id = f + .vault + .propose_change(&VaultChange::Oracle(next.address.clone())); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + attest_on(&f, &next, wad(2)); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &400); + let epoch = f.close_epoch(); + + f.attest(wad(2)); + f.advance(next.config().cooldown_secs); + attest_on(&f, &next, wad(4)); + f.vault.fulfill_epoch(&epoch); + + assert_eq!(f.vault.get_epoch(&epoch).unwrap().share_price, wad(4)); +} + +#[test] +fn a_sealed_epoch_is_priced_by_the_feed_standing_at_fulfilment() { + let f = setup(); + let next = second_feed(&f); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &400); + let epoch = f.close_epoch(); + + let id = f + .vault + .propose_change(&VaultChange::Oracle(next.address.clone())); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + attest_on(&f, &next, wad(4)); + f.vault.fulfill_epoch(&epoch); + + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 100); +} + +#[test] +fn a_feed_outside_this_vault_is_never_proposed() { + let f = setup(); + let open = unbound_feed(&f); + open.bind_vault(&f.vault.address); + let elsewhere = unbound_feed(&f); + elsewhere.bind_vault(®ister_with(&f.e, distinct_roles(&f.e))); + elsewhere.finish_setup(); + + for feed in [ + Address::generate(&f.e), + unbound_feed(&f).address, + open.address, + elsewhere.address, + ] { + refused( + f.vault.try_propose_change(&VaultChange::Oracle(feed)), + VaultError::FeedNotBound, + ); + } + assert_eq!(f.vault.pending_changes().len(), 0); +} + +#[test] +fn a_feed_that_stops_naming_this_vault_is_never_installed() { + let f = setup(); + let stub = FeedStubClient::new(&f.e, &f.e.register(FeedStub, ())); + stub.report(&Some(f.vault.address.clone())); + let id = f + .vault + .propose_change(&VaultChange::Oracle(stub.address.clone())); + + stub.report(&Some(Address::generate(&f.e))); + f.advance(MIN_UPGRADE_DELAY); + + refused(f.vault.try_apply_change(&id), VaultError::FeedNotBound); + assert_eq!(f.vault.oracle(), f.oracle.address); +} + +#[test] +fn a_contract_that_is_not_a_feed_is_refused_when_proposed_and_when_applied() { + let f = setup(); + // Bound to this vault with its setup closed, but it answers no feed view. + let change = VaultChange::Oracle(f.share.compliance()); + + refused( + f.vault.try_propose_change(&change), + VaultError::FeedNotBound, + ); + + // Skips the proposal's check, as a change queued by an earlier version would. + let id = f.e.as_contract(&f.vault.address, || { + strata_timelock::queue::propose::(&f.e, &change).unwrap() + }); + f.advance(MIN_UPGRADE_DELAY); + + refused(f.vault.try_apply_change(&id), VaultError::FeedNotBound); + assert_eq!(f.vault.oracle(), f.oracle.address); +} + +#[test] +fn a_sealed_deposit_cancels_when_the_feed_traps() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.close_epoch(); + swap_to_a_feed_that_traps_on(&f, symbol_short!("attested")); + + assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); + assert_eq!(f.balance(&investor), 1_000); +} + +#[test] +fn a_sealed_redemption_cancels_when_the_feed_traps() { + let f = setup(); + let holder = f.holder(200); + let epoch = f.vault.request_redeem(&holder, &200); + f.close_epoch(); + swap_to_a_feed_that_traps_on(&f, symbol_short!("attested")); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 200); +} + +#[test] +fn a_feed_that_traps_on_its_attestation_refuses_fulfilment() { + let f = setup(); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &400); + let epoch = f.close_epoch(); + swap_to_a_feed_that_traps_on(&f, symbol_short!("attested")); + + refused(f.vault.try_fulfill_epoch(&epoch), VaultError::FeedNotValid); +} + +#[test] +fn a_feed_that_traps_on_its_price_refuses_fulfilment() { + let f = setup(); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &400); + let epoch = f.close_epoch(); + swap_to_a_feed_that_traps_on(&f, symbol_short!("nav")); + + refused(f.vault.try_fulfill_epoch(&epoch), VaultError::FeedNotValid); +} + +/// Deposits read only the feed's state, so they still land; cancelling is the way out. +#[test] +fn a_deposit_still_lands_and_can_leave_when_the_feed_traps() { + let f = setup(); + swap_to_a_feed_that_traps_on(&f, symbol_short!("attested")); + let investor = f.investor(1_000); + + let epoch = f.vault.request_deposit(&investor, &400); + assert_eq!(f.balance(&investor), 600); + f.close_epoch(); + + assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); + assert_eq!(f.balance(&investor), 1_000); +} + +#[test] +fn a_feed_that_traps_on_its_state_neither_prices_nor_blocks_a_cancel() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.close_epoch(); + swap_to_a_feed_that_traps_on(&f, symbol_short!("state")); + + refused(f.vault.try_fulfill_epoch(&epoch), VaultError::FeedNotValid); + assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); + + let trapped = + soroban_sdk::Error::from_type_and_code(ScErrorType::Context, ScErrorCode::InvalidAction); + assert_eq!( + f.vault.try_request_deposit(&investor, &400), + Err(Ok(trapped)) + ); +} + +/// A deposit and a redemption sealed in one epoch, as `(investor, holder, epoch)`. +fn seal_both_sides(f: &Fixture) -> (Address, Address, u64) { + let holder = f.holder(200); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.vault.request_redeem(&holder, &200); + f.close_epoch(); + (investor, holder, epoch) +} + +#[test] +fn both_sides_of_a_sealed_epoch_cancel_when_the_feed_traps_on_its_price() { + let f = setup(); + let (investor, holder, epoch) = seal_both_sides(&f); + swap_to_a_feed_that_traps_on(&f, symbol_short!("nav")); + + assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); +} + +#[test] +fn both_sides_of_a_sealed_epoch_cancel_when_the_feed_prices_at_zero_or_less() { + for nav in [0, -WAD_SCALE] { + let f = setup(); + let (investor, holder, epoch) = seal_both_sides(&f); + swap_to_a_feed_that_prices_at(&f, nav); + + assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + } +} + +#[test] +fn a_feed_that_prices_at_zero_or_less_refuses_fulfilment() { + for nav in [0, -WAD_SCALE] { + let f = setup(); + let (_, _, epoch) = seal_both_sides(&f); + swap_to_a_feed_that_prices_at(&f, nav); + + refused( + f.vault.try_fulfill_epoch(&epoch), + VaultError::InvalidSharePrice, + ); + } +} diff --git a/contracts/async-vault/src/test/redeem.rs b/contracts/async-vault/src/test/redeem.rs index b95d8c62..cbf04bd2 100644 --- a/contracts/async-vault/src/test/redeem.rs +++ b/contracts/async-vault/src/test/redeem.rs @@ -27,8 +27,14 @@ fn request_redeem_rejects_a_non_positive_amount() { let f = setup(); let user = f.holder(500); - assert!(f.vault.try_request_redeem(&user, &0).is_err()); - assert!(f.vault.try_request_redeem(&user, &-1).is_err()); + refused( + f.vault.try_request_redeem(&user, &0), + VaultError::InvalidAmount, + ); + refused( + f.vault.try_request_redeem(&user, &-1), + VaultError::InvalidAmount, + ); assert_eq!(f.shares(&user), 500); } @@ -38,7 +44,10 @@ fn a_second_redeem_request_in_the_same_epoch_is_rejected() { let user = f.holder(500); f.vault.request_redeem(&user, &200); - assert!(f.vault.try_request_redeem(&user, &100).is_err()); + refused( + f.vault.try_request_redeem(&user, &100), + VaultError::RequestOutstanding, + ); assert_eq!(f.shares(&user), 300); } @@ -48,7 +57,7 @@ fn request_redeem_needs_the_holder_authorisation() { let user = f.holder(500); f.e.set_auths(&[]); - assert!(f.vault.try_request_redeem(&user, &200).is_err()); + missing_auth(f.vault.try_request_redeem(&user, &200)); assert_eq!(f.shares(&user), 500); } @@ -57,7 +66,11 @@ fn redeeming_more_shares_than_held_is_rejected() { let f = setup(); let user = f.holder(500); - assert!(f.vault.try_request_redeem(&user, &501).is_err()); + // Nothing is frozen, so the token's own balance check applies, not `Frozen`. + assert_eq!( + f.vault.try_request_redeem(&user, &501).err(), + Some(Ok(soroban_sdk::Error::from_contract_error(300))) + ); assert_eq!(f.shares(&user), 500); } @@ -88,7 +101,10 @@ fn a_redeem_cannot_be_claimed_twice() { f.fulfill_epoch(wad(2)); f.vault.claim_redeem(&user, &epoch); - assert!(f.vault.try_claim_redeem(&user, &epoch).is_err()); + refused( + f.vault.try_claim_redeem(&user, &epoch), + VaultError::AlreadyClaimed, + ); assert_eq!(f.balance(&user), 400); } @@ -98,10 +114,31 @@ fn claiming_a_redeem_before_the_epoch_is_fulfilled_is_rejected() { let user = f.holder(500); let epoch = f.vault.request_redeem(&user, &200); - assert!(f.vault.try_claim_redeem(&user, &epoch).is_err()); + refused( + f.vault.try_claim_redeem(&user, &epoch), + VaultError::EpochNotFulfilled, + ); assert_eq!(f.shares(&f.vault.address), 200); } +#[test] +fn claiming_a_redeem_without_a_request_finds_no_request_before_pricing() { + let f = setup(); + let stranger = Address::generate(&f.e); + let epoch = f.vault.current_epoch(); + + refused( + f.vault.try_claim_redeem(&stranger, &epoch), + VaultError::RequestNotFound, + ); + + f.close_epoch(); + refused( + f.vault.try_claim_redeem(&stranger, &epoch), + VaultError::RequestNotFound, + ); +} + #[test] fn the_epoch_prices_but_the_claim_waits_for_the_cash() { let f = setup(); @@ -121,7 +158,10 @@ fn the_epoch_prices_but_the_claim_waits_for_the_cash() { ); assert_eq!(f.vault.committed(), 2_000); assert_eq!(f.vault.uncovered(), 1_000); - assert!(f.vault.try_claim_redeem(&user, &epoch).is_err()); + refused( + f.vault.try_claim_redeem(&user, &epoch), + VaultError::ClaimNotCovered, + ); } #[test] diff --git a/contracts/async-vault/src/test/sealing.rs b/contracts/async-vault/src/test/sealing.rs new file mode 100644 index 00000000..a888a652 --- /dev/null +++ b/contracts/async-vault/src/test/sealing.rs @@ -0,0 +1,373 @@ +use soroban_sdk::{testutils::Events as _, Event as _, IntoVal}; +use stellar_access::access_control::AccessControlError; + +use super::*; +use crate::{ + ChangeApplied, MinEpochDurationSet, MIN_EPOCH_DURATION_CEILING, MIN_EPOCH_DURATION_FLOOR, +}; + +/// Proposes a change and applies it once the delay has run. +fn land(f: &Fixture, change: VaultChange) { + let id = f.vault.propose_change(&change); + f.advance(f.vault.upgrade_delay()); + f.vault.apply_change(&id); +} + +/// What a vault upgraded in place from a release that kept no epoch start holds. +fn forget_the_epoch_start(f: &Fixture) { + f.e.as_contract(&f.vault.address, || { + f.e.storage() + .instance() + .remove(&crate::keys::DataKey::EpochOpenedAt) + }); +} + +#[test] +fn the_first_epoch_starts_when_setup_finishes() { + let f = setup_unfinished(); + assert_eq!(f.vault.epoch_opened_at(), None); + + f.advance(DAY_DURATION); + f.vault.finish_setup(); + + assert_eq!(f.vault.epoch_opened_at(), Some(f.e.ledger().timestamp())); +} + +#[test] +fn a_seal_starts_the_next_epoch() { + let f = setup(); + f.advance(3_600); + let sealed_at = f.e.ledger().timestamp(); + + let sealed = f.close_epoch(); + + assert_eq!(f.vault.get_epoch(&sealed).unwrap().closed_at, sealed_at); + assert_eq!(f.vault.epoch_opened_at(), Some(sealed_at)); +} + +#[test] +fn no_minimum_is_set_until_governance_sets_one() { + let f = setup(); + assert_eq!(f.vault.min_epoch_duration(), None); +} + +#[test] +fn the_minimum_is_instant_during_setup() { + let f = setup_unfinished(); + f.vault.set_min_epoch_duration(&DAY_DURATION); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [MinEpochDurationSet { secs: DAY_DURATION }.to_xdr(&f.e, &f.vault.address)] + ); + assert_eq!(f.vault.min_epoch_duration(), Some(DAY_DURATION)); +} + +#[test] +fn the_minimum_stays_between_one_and_thirty_days() { + assert_eq!(MIN_EPOCH_DURATION_FLOOR, DAY_DURATION); + assert_eq!(MIN_EPOCH_DURATION_CEILING, 30 * DAY_DURATION); + let f = setup_unfinished(); + refused( + f.vault + .try_set_min_epoch_duration(&(MIN_EPOCH_DURATION_FLOOR - 1)), + VaultError::InvalidAmount, + ); + refused( + f.vault + .try_set_min_epoch_duration(&(MIN_EPOCH_DURATION_CEILING + 1)), + VaultError::InvalidAmount, + ); + f.vault.set_min_epoch_duration(&MIN_EPOCH_DURATION_FLOOR); + f.vault.set_min_epoch_duration(&MIN_EPOCH_DURATION_CEILING); + assert_eq!( + f.vault.min_epoch_duration(), + Some(MIN_EPOCH_DURATION_CEILING) + ); +} + +#[test] +fn setting_the_minimum_needs_the_admin() { + let f = setup_unfinished(); + f.e.set_auths(&[]); + assert!(f.vault.try_set_min_epoch_duration(&DAY_DURATION).is_err()); + assert_eq!(f.vault.min_epoch_duration(), None); +} + +#[test] +fn after_setup_the_minimum_changes_only_through_the_timelock() { + let f = setup(); + refused( + f.vault.try_set_min_epoch_duration(&DAY_DURATION), + VaultError::UseTimelock, + ); + let change = VaultChange::MinEpochDuration(2 * DAY_DURATION); + let id = f.vault.propose_change(&change); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); + f.advance(f.vault.upgrade_delay()); + f.vault.apply_change(&id); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + MinEpochDurationSet { + secs: 2 * DAY_DURATION + } + .to_xdr(&f.e, &f.vault.address), + ChangeApplied { id, change }.to_xdr(&f.e, &f.vault.address), + ] + ); + assert_eq!(f.vault.min_epoch_duration(), Some(2 * DAY_DURATION)); +} + +#[test] +fn a_minimum_out_of_bounds_is_refused_when_proposed() { + let f = setup(); + refused( + f.vault + .try_propose_change(&VaultChange::MinEpochDuration(0)), + VaultError::InvalidAmount, + ); + refused( + f.vault.try_propose_change(&VaultChange::MinEpochDuration( + MIN_EPOCH_DURATION_CEILING + 1, + )), + VaultError::InvalidAmount, + ); +} + +#[test] +fn an_upgraded_vault_counts_its_open_epoch_from_the_minimum_landing() { + let f = setup(); + forget_the_epoch_start(&f); + assert_eq!(f.vault.epoch_opened_at(), None); + land(&f, VaultChange::MinEpochDuration(DAY_DURATION)); + assert_eq!(f.vault.epoch_opened_at(), Some(f.e.ledger().timestamp())); +} + +#[test] +fn a_minimum_landing_keeps_a_recorded_start() { + let f = setup(); + let started = f.vault.epoch_opened_at(); + land(&f, VaultChange::MinEpochDuration(DAY_DURATION)); + assert_eq!(f.vault.epoch_opened_at(), started); +} + +/// A finished vault whose minimum was set during setup; its first epoch starts at `finish_setup`. +fn with_minimum<'a>(secs: u64) -> Fixture<'a> { + let f = setup_unfinished(); + f.vault.set_min_epoch_duration(&secs); + f.vault.finish_setup(); + f +} + +fn advance_to(f: &Fixture, at: u64) { + f.advance(at - f.e.ledger().timestamp()); +} + +fn opened_at(f: &Fixture) -> u64 { + f.vault.epoch_opened_at().unwrap() +} + +#[test] +fn an_outsider_cannot_seal_before_the_minimum() { + let f = with_minimum(DAY_DURATION); + let outsider = Address::generate(&f.e); + let open = f.vault.current_epoch(); + advance_to(&f, opened_at(&f) + DAY_DURATION - 1); + + refused( + f.vault.try_close_epoch(&outsider), + VaultError::MinEpochDurationNotElapsed, + ); + assert_eq!(f.vault.current_epoch(), open); +} + +#[test] +fn an_outsider_seals_once_the_minimum_has_run() { + let f = with_minimum(DAY_DURATION); + let outsider = Address::generate(&f.e); + let open = f.vault.current_epoch(); + advance_to(&f, opened_at(&f) + DAY_DURATION); + + assert_eq!(f.vault.close_epoch(&outsider), open); + f.signed_once_by(&outsider, "close_epoch", (outsider.clone(),).into_val(&f.e)); + assert_eq!(f.vault.current_epoch(), open + 1); + assert_eq!( + f.vault.get_epoch(&open).unwrap().status, + EpochStatus::Pending + ); + assert_eq!(f.vault.epoch_opened_at(), Some(f.e.ledger().timestamp())); +} + +#[test] +fn the_manager_seals_before_the_minimum() { + let f = with_minimum(MIN_EPOCH_DURATION_CEILING); + let open = f.vault.current_epoch(); + + assert_eq!(f.close_epoch(), open); +} + +#[test] +fn without_a_minimum_only_the_manager_seals() { + let f = setup(); + let outsider = Address::generate(&f.e); + let open = f.vault.current_epoch(); + f.advance(2 * DAY_DURATION); + + assert_eq!( + f.vault.try_close_epoch(&outsider), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + assert_eq!(f.close_epoch(), open); +} + +#[test] +fn an_outsider_seal_waits_the_notice_like_any_other() { + let f = with_minimum(DAY_DURATION); + f.vault.set_notice(&DAY_DURATION); + let outsider = Address::generate(&f.e); + advance_to(&f, opened_at(&f) + DAY_DURATION); + let sealed = f.vault.close_epoch(&outsider); + + refused( + f.vault.try_fulfill_epoch(&sealed), + VaultError::NoticeNotElapsed, + ); + f.advance(DAY_DURATION); + f.fulfill_epoch_at(sealed, wad(2)); + assert_eq!( + f.vault.get_epoch(&sealed).unwrap().status, + EpochStatus::Fulfilled + ); +} + +#[test] +fn a_wind_down_refuses_an_outsider_seal() { + let f = with_minimum(DAY_DURATION); + let outsider = Address::generate(&f.e); + super::wind_down::wind_down_now(&f); + + refused( + f.vault.try_close_epoch(&outsider), + VaultError::WindDownActive, + ); +} + +#[test] +fn a_pause_does_not_stop_an_outsider_seal() { + let f = with_minimum(DAY_DURATION); + let outsider = Address::generate(&f.e); + let open = f.vault.current_epoch(); + f.vault.pause(&f.guardian); + f.advance(DAY_DURATION); + + assert!(f.vault.paused()); + assert_eq!(f.vault.close_epoch(&outsider), open); +} + +#[test] +fn each_seal_restarts_the_minimum() { + let f = with_minimum(2 * DAY_DURATION); + let outsider = Address::generate(&f.e); + f.advance(DAY_DURATION); + f.close_epoch(); + let started = f.e.ledger().timestamp(); + let open = f.vault.current_epoch(); + + assert_eq!(f.vault.epoch_opened_at(), Some(started)); + advance_to(&f, started + 2 * DAY_DURATION - 1); + refused( + f.vault.try_close_epoch(&outsider), + VaultError::MinEpochDurationNotElapsed, + ); + f.advance(1); + assert_eq!(f.vault.close_epoch(&outsider), open); +} + +#[test] +fn a_raised_minimum_counts_from_the_epochs_start() { + let f = with_minimum(DAY_DURATION); + let outsider = Address::generate(&f.e); + let open = f.vault.current_epoch(); + let start = opened_at(&f); + land(&f, VaultChange::MinEpochDuration(10 * DAY_DURATION)); + + refused( + f.vault.try_close_epoch(&outsider), + VaultError::MinEpochDurationNotElapsed, + ); + advance_to(&f, start + 10 * DAY_DURATION); + assert_eq!(f.vault.close_epoch(&outsider), open); +} + +#[test] +fn with_no_manager_anyone_still_seals_after_the_minimum() { + let f = with_minimum(DAY_DURATION); + let outsider = Address::generate(&f.e); + let open = f.vault.current_epoch(); + land( + &f, + VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), + ); + + assert_eq!(f.vault.manager(), None); + assert_eq!(f.vault.close_epoch(&outsider), open); +} + +#[test] +fn an_outsider_cannot_seal_during_setup() { + let f = setup_unfinished(); + let outsider = Address::generate(&f.e); + f.vault.set_min_epoch_duration(&DAY_DURATION); + f.advance(2 * DAY_DURATION); + + refused( + f.vault.try_close_epoch(&outsider), + VaultError::MinEpochDurationNotElapsed, + ); +} + +#[test] +fn an_outsider_seal_needs_the_outsiders_signature() { + let f = with_minimum(DAY_DURATION); + let outsider = Address::generate(&f.e); + let open = f.vault.current_epoch(); + advance_to(&f, opened_at(&f) + DAY_DURATION); + f.e.set_auths(&[]); + + assert!(f.vault.try_close_epoch(&outsider).is_err()); + assert_eq!(f.vault.current_epoch(), open); +} + +#[test] +fn an_upgraded_vault_lets_anyone_seal_a_minimum_after_it_lands() { + let f = setup(); + let outsider = Address::generate(&f.e); + let open = f.vault.current_epoch(); + forget_the_epoch_start(&f); + f.advance(2 * DAY_DURATION); + + assert_eq!( + f.vault.try_close_epoch(&outsider), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + land(&f, VaultChange::MinEpochDuration(DAY_DURATION)); + let landed = f.e.ledger().timestamp(); + advance_to(&f, landed + DAY_DURATION - 1); + refused( + f.vault.try_close_epoch(&outsider), + VaultError::MinEpochDurationNotElapsed, + ); + advance_to(&f, landed + DAY_DURATION); + assert_eq!(f.vault.close_epoch(&outsider), open); +} + +#[test] +fn lowering_the_minimum_also_waits_the_timelock() { + let f = with_minimum(10 * DAY_DURATION); + refused( + f.vault.try_set_min_epoch_duration(&DAY_DURATION), + VaultError::UseTimelock, + ); + land(&f, VaultChange::MinEpochDuration(DAY_DURATION)); + assert_eq!(f.vault.min_epoch_duration(), Some(DAY_DURATION)); +} diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs new file mode 100644 index 00000000..ad3d15e9 --- /dev/null +++ b/contracts/async-vault/src/test/settlement.rs @@ -0,0 +1,290 @@ +use proptest::prelude::*; +use soroban_sdk::{testutils::Events as _, Event as _}; +use stellar_tokens::rwa::compliance::ComplianceHook; + +use super::*; +use crate::{EpochFulfilled, EpochReconciled, RedeemClaimed}; + +/// Three assets a share: deposits of 4 and 5 promise floor(9 / 3) = 3 shares, +/// but each claim floors to 1. +#[test] +fn the_last_deposit_claim_clears_the_share_remainder() { + let f = setup(); + let a = f.investor(4); + let b = f.investor(5); + f.vault.request_deposit(&a, &4); + f.vault.request_deposit(&b, &5); + let epoch = f.fulfill_epoch(wad(3)); + assert_eq!(f.vault.pending_mint_shares(), 3); + + assert_eq!(f.vault.claim_deposit(&a, &epoch), 1); + assert_eq!(f.vault.pending_mint_shares(), 2); + assert_eq!(f.vault.claim_deposit(&b, &epoch), 1); + + assert_eq!(f.vault.pending_mint_shares(), 0); + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert!(s.reconciled); + assert_eq!((s.shares_promised, s.shares_settled), (3, 2)); +} + +/// At 1.5 assets a share, 101 and 103 shares promise floor(306) = 306 assets +/// but pay floor(151.5) + floor(154.5) = 305. +#[test] +fn the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once() { + let f = setup(); + let a = f.holder(101); + let b = f.holder(103); + f.vault.request_redeem(&a, &101); + f.vault.request_redeem(&b, &103); + let epoch = f.fulfill_epoch(wad(3) / 2); + assert_eq!(f.vault.committed(), 306); + + f.vault.claim_redeem(&a, &epoch); + assert!(!f.vault.get_epoch_settlement(&epoch).unwrap().reconciled); + assert_eq!(f.vault.committed(), 155); + + f.vault.claim_redeem(&b, &epoch); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + RedeemClaimed { + holder: b, + epoch, + shares: 103, + assets: 154, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 1, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); + assert_eq!(f.vault.committed(), 0); +} + +#[test] +fn an_epoch_with_no_requests_settles_when_it_is_priced() { + let f = setup(); + let epoch = f.close_epoch(); + f.attest(wad(2)); + + f.vault.fulfill_epoch(&epoch); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + EpochFulfilled { + epoch, + share_price: wad(2), + total_deposited: 0, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); + assert!(f.vault.get_epoch_settlement(&epoch).unwrap().reconciled); +} + +#[test] +fn a_cancelled_request_does_not_hold_the_epoch_open() { + let f = setup(); + let a = f.investor(400); + let epoch = f.vault.request_deposit(&a, &400); + f.vault.cancel_deposit(&a, &epoch); + + f.fulfill_epoch(wad(2)); + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!(s.deposits_open, 0); + assert!(s.reconciled); +} + +/// At two assets a share, deposits of 1 and 3 promise floor(4 / 2) = 2 shares. +/// The first claim floors to nothing and is refunded; the second gets 1. +#[test] +fn a_deposit_refunded_at_claim_counts_as_settled() { + let f = setup(); + let a = f.investor(1); + let b = f.investor(3); + let epoch = f.vault.request_deposit(&a, &1); + f.vault.request_deposit(&b, &3); + f.fulfill_epoch(wad(2)); + + assert_eq!(f.vault.claim_deposit(&a, &epoch), 0); + assert_eq!(f.vault.claim_deposit(&b, &epoch), 1); + + assert_eq!(f.vault.pending_mint_shares(), 0); + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!((s.shares_promised, s.shares_settled), (2, 1)); + assert!(s.reconciled); +} + +#[test] +fn a_redemption_that_pays_nothing_counts_as_settled() { + let f = setup(); + let a = f.holder(1); + let epoch = f.vault.request_redeem(&a, &1); + f.fulfill_epoch(wad(1) / 2); + + assert_eq!(f.vault.claim_redeem(&a, &epoch), 0); + + assert_eq!(f.shares(&a), 1); + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!( + (s.assets_promised, s.assets_settled, s.redeems_open), + (0, 0, 0) + ); + assert!(s.reconciled); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6070)")] +fn a_total_that_would_go_below_zero_fails_loudly() { + let f = setup(); + f.e.as_contract(&f.vault.address, || crate::state::reduce(&f.e, 1, 2)); +} + +#[test] +fn a_settlement_total_that_would_overflow_fails_by_name() { + let f = setup(); + let epoch = f.vault.current_epoch(); + let full = crate::EpochSettlement { + deposits_open: u32::MAX, + redeems_open: u32::MAX, + shares_settled: i128::MAX, + assets_settled: i128::MAX, + ..Default::default() + }; + f.e.as_contract(&f.vault.address, || { + strata_storage::set_persistent(&f.e, &crate::keys::DataKey::Settlement(epoch), &full) + }); + + let steps: [&dyn Fn(); 4] = [ + &|| crate::settlement::on_deposit_requested(&f.e, epoch), + &|| crate::settlement::on_redeem_requested(&f.e, epoch), + &|| crate::settlement::on_deposit_claimed(&f.e, epoch, 1), + &|| crate::settlement::on_redeem_claimed(&f.e, epoch, 1), + ]; + for step in steps { + assert_eq!( + f.e.try_as_contract::<(), VaultError>(&f.vault.address, step), + Err(Ok(VaultError::AmountTooLarge)) + ); + } +} + +/// A request that can never settle holds its epoch open, so the remainder +/// stays in the totals. Here the redemption pays nothing and the holder, since +/// delisted, cannot take the shares back. +#[test] +fn a_request_that_cannot_settle_keeps_its_epoch_open() { + let f = setup(); + let a = f.holder(1); + let b = f.holder(3); + let epoch = f.vault.request_redeem(&a, &1); + f.vault.request_redeem(&b, &3); + f.fulfill_epoch(wad(1) / 2); + f.identity.allow(&a, &false); + + refused( + f.vault.try_claim_redeem(&a, &epoch), + VaultError::HolderNotEligible, + ); + assert_eq!(f.vault.claim_redeem(&b, &epoch), 1); + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!(s.redeems_open, 1); + assert!(!s.reconciled); + assert_eq!(f.vault.committed(), s.assets_promised - s.assets_settled); +} + +/// The other case that can never settle: a compliance module refuses a +/// priced deposit's mint even for an allowlisted investor. Unlike a refused +/// redemption, this is not rounding dust: the mint reverts, so the whole +/// entitlement this deposit promised — all 200 shares, for 400 assets at a +/// price of 2 — stays in `pending_mint_shares` until the mint is allowed. +#[test] +fn a_refused_mint_keeps_a_priced_deposit_epoch_open() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + + let module = f.e.register(super::compliance::RefusesEveryMint, ()); + f.add_compliance_module(ComplianceHook::CanCreate, &module); + + assert!(f.vault.try_claim_deposit(&investor, &epoch).is_err()); + + assert_eq!(f.vault.pending_mint_shares(), 200); + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!((s.shares_promised, s.shares_settled), (200, 0)); + assert_eq!(s.deposits_open, 1); + assert!(!s.reconciled); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + + /// Any mix of deposits, cancellations, redemptions, refunds and + /// zero-asset claims, claimed in any order, leaves nothing behind once + /// every request settles. + #[test] + fn a_settled_epoch_leaves_no_remainder_in_the_totals( + deposits in proptest::collection::vec(prop_oneof![Just(1i128), 1i128..5_000], 1..5), + cancels in proptest::collection::vec(any::(), 5), + redeems in proptest::collection::vec(prop_oneof![Just(1i128), 1i128..500], 0..4), + price_tenths in 5i128..=20, + order in proptest::collection::vec(any::(), 8), + ) { + let f = setup(); + let holders: std::vec::Vec<(Address, i128)> = + redeems.iter().map(|shares| (f.holder(*shares), *shares)).collect(); + let mut investors = std::vec::Vec::new(); + for (i, amount) in deposits.iter().enumerate() { + let who = f.investor(*amount); + let epoch = f.vault.request_deposit(&who, amount); + if cancels[i] { + f.vault.cancel_deposit(&who, &epoch); + } else { + investors.push(who); + } + } + for (who, shares) in &holders { + f.vault.request_redeem(who, shares); + } + let epoch = f.fulfill_epoch(wad(1) * price_tenths / 10); + + let mut claims: std::vec::Vec<(u16, bool, &Address)> = investors + .iter() + .map(|who| (true, who)) + .chain(holders.iter().map(|(who, _)| (false, who))) + .enumerate() + .map(|(i, (is_deposit, who))| (order[i], is_deposit, who)) + .collect(); + claims.sort_by_key(|(key, _, _)| *key); + for (_, is_deposit, who) in claims { + if is_deposit { + f.vault.claim_deposit(who, &epoch); + } else { + f.vault.claim_redeem(who, &epoch); + } + } + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + prop_assert!(s.reconciled); + prop_assert!(s.shares_settled <= s.shares_promised); + prop_assert!(s.assets_settled <= s.assets_promised); + prop_assert_eq!(f.vault.pending_mint_shares(), 0); + prop_assert_eq!(f.vault.committed(), 0); + prop_assert_eq!(f.vault.cancellable_escrow(), 0); + } +} diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs new file mode 100644 index 00000000..b770a55d --- /dev/null +++ b/contracts/async-vault/src/test/setup.rs @@ -0,0 +1,334 @@ +use soroban_sdk::{contract, contractimpl, testutils::Events as _, vec, Event as _, Vec}; +use stellar_access::role_transfer::RoleTransferError; + +use super::*; +use crate::SetupFinished; + +#[test] +fn a_deposit_before_setup_finishes_is_refused() { + let f = setup_unfinished(); + let investor = f.investor(1_000); + + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::SetupOpen, + ); + assert!(f.vault.setup_open()); +} + +#[test] +fn a_redemption_before_setup_finishes_is_refused() { + let f = setup_unfinished(); + let investor = f.investor(1_000); + refused( + f.vault.try_request_redeem(&investor, &100), + VaultError::SetupOpen, + ); +} + +#[test] +fn no_epoch_is_sealed_or_priced_before_setup_finishes() { + let f = setup_unfinished(); + refused(f.vault.try_close_epoch(&f.manager), VaultError::SetupOpen); + refused(f.vault.try_fulfill_epoch(&1), VaultError::SetupOpen); + assert_eq!(f.vault.current_epoch(), 1); +} + +/// Shares minted during the token's setup, escrowed for redemption, then +/// burned out of the vault. +#[test] +fn a_redemption_planted_during_setup_never_reaches_a_depositor() { + let f = miswired(Miswire::TokenSetupOpen); + let planter = f.investor(1); + f.share + .grant_role(&f.admin, &symbol_short!("manager"), &f.admin); + f.share.mint(&planter, &100, &f.admin); + refused( + f.vault.try_request_redeem(&planter, &100), + VaultError::SetupOpen, + ); + f.share.burn(&planter, &100, &f.admin); + f.share + .revoke_role(&f.admin, &symbol_short!("manager"), &f.admin); + f.share.finish_setup(); + f.vault.finish_setup(); + + let investor = f.investor(1_000); + f.attest(wad(1)); + f.vault.request_deposit(&investor, &100); + let entry = f.fulfill_epoch(wad(1)); + assert_eq!(f.vault.claim_deposit(&investor, &entry), 100); + f.vault.request_redeem(&investor, &100); + let exit = f.fulfill_epoch(wad(1)); + assert_eq!(f.vault.claim_redeem(&investor, &exit), 100); + assert_eq!(f.balance(&investor), 1_000); + assert_eq!(f.shares(&planter), 0); +} + +#[test] +fn setup_finishes_after_cash_is_funded_during_it() { + let f = setup_unfinished(); + let funder = f.unverified_investor(100); + f.vault.fund(&funder, &100); + f.vault.finish_setup(); + assert!(!f.vault.setup_open()); +} + +#[test] +fn setup_finishes_once_the_wiring_is_complete() { + let f = setup_unfinished(); + + f.vault.finish_setup(); + + assert!(!f.vault.setup_open()); + f.attest(wad(1)); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &100); +} + +#[test] +fn finishing_setup_publishes_it() { + let f = setup_unfinished(); + + f.vault.finish_setup(); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [SetupFinished {}.to_xdr(&f.e, &f.vault.address)] + ); +} + +#[test] +fn setup_never_finishes_twice() { + let f = setup(); + + refused(f.vault.try_finish_setup(), VaultError::SetupClosed); +} + +#[test] +fn every_wiring_step_is_checked_before_setup_finishes() { + for miswire in [ + Miswire::TokenUnbound, + Miswire::TokenBoundElsewhere, + Miswire::TokenSetupOpen, + Miswire::OracleUnbound, + Miswire::OracleBoundElsewhere, + Miswire::OracleSetupOpen, + Miswire::NoManagerRole, + Miswire::OtherManager, + Miswire::SecondManager, + Miswire::PreMinted, + Miswire::NotAllowlisted, + Miswire::VaultFrozen, + Miswire::ComplianceUnbound, + Miswire::ComplianceBoundElsewhere, + Miswire::ComplianceSetupOpen, + Miswire::AssetDecimalsDiffer, + Miswire::AssetIsShareToken, + Miswire::OracleNotAFeed, + Miswire::NoCustodian, + Miswire::CustodianIsVault, + Miswire::EpochAdvanced, + Miswire::DepositBooked, + Miswire::RedemptionBooked, + Miswire::Committed, + Miswire::SharesOwed, + Miswire::SharesToBurn, + ] { + let f = miswired(miswire); + + assert_eq!( + f.vault.try_finish_setup(), + Err(Ok(VaultError::SetupIncomplete.into())), + "{miswire:?}" + ); + assert!(f.vault.setup_open(), "{miswire:?}"); + } +} + +/// A compliance contract from outside the kit, with no binding view. +#[contract] +struct ExternalRules; + +#[contractimpl] +impl ExternalRules { + pub fn link(e: Env, token: Address) { + e.storage().instance().set(&symbol_short!("token"), &token); + } + + pub fn linked_tokens(e: Env) -> Vec
      { + let token: Address = e.storage().instance().get(&symbol_short!("token")).unwrap(); + vec![&e, token] + } +} + +/// Its own governance is a trust assumption the vault cannot check. +#[test] +fn a_compliance_contract_from_outside_the_kit_is_accepted() { + let f = setup_with( + open_feed(), + |e, (name, symbol, admin, authority, _, verifier)| { + let rules = e.register(ExternalRules, ()); + let token = e.register( + ShareToken, + (name, symbol, admin, authority, rules.clone(), verifier), + ); + ExternalRulesClient::new(e, &rules).link(&token); + token + }, + ); + + assert!(!f.vault.setup_open()); + assert!(f.vault.health().compliance_bound); +} + +/// A token that cannot answer `wiring`'s calls must not trap the vault: every +/// check it needs the token for reads false, and `finish_setup` refuses +/// cleanly instead of aborting the transaction. +#[test] +fn a_token_that_cannot_answer_fails_its_checks_instead_of_trapping() { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + + let issuer = Address::generate(&e); + let asset = e.register_stellar_asset_contract_v2(issuer).address(); + let admin = Address::generate(&e); + let manager = Address::generate(&e); + let treasury = Address::generate(&e); + let guardian = Address::generate(&e); + let compliance_authority = Address::generate(&e); + let attester = Address::generate(&e); + let broken_token = Address::generate(&e); + + let oracle = NavOracleContractClient::new( + &e, + &e.register( + NavOracleContract, + ( + admin.clone(), + attester.clone(), + guardian.clone(), + open_feed(), + ), + ), + ); + + let contract_id = e.register( + AsyncVault, + ( + &asset, + &broken_token, + &oracle.address, + VaultRoles { + governance: admin.clone(), + manager, + treasury, + guardian: guardian.clone(), + compliance: compliance_authority, + attester, + }, + ), + ); + oracle.bind_vault(&contract_id); + oracle.finish_setup(); + + let vault = AsyncVaultClient::new(&e, &contract_id); + + let health = vault.health(); + assert!(health.oracle_bound); + assert!(!health.token_bound); + assert!(!health.sole_token_manager); + assert!(!health.allowlisted); + assert!(!health.vault_unfrozen); + assert!(!health.compliance_bound); + assert!(!health.decimals_match); + + refused(vault.try_finish_setup(), VaultError::SetupIncomplete); +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = setup_unfinished(); + let next = Address::generate(&f.e); + f.vault + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused(f.vault.try_finish_setup(), VaultError::AdminTransferPending); + + f.vault.transfer_admin_role(&next, &0); + f.vault.finish_setup(); + assert!(f.vault.try_accept_admin_transfer().is_err()); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn an_expired_hand_over_does_not_hold_setup_open() { + let f = setup_unfinished(); + f.vault + .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); + f.e.ledger().with_mut(|l| l.sequence_number += 11); + + f.vault.finish_setup(); + + assert!(!f.vault.setup_open()); + assert_eq!( + f.vault.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn only_governance_finishes_setup() { + let f = setup_unfinished(); + let stranger = Address::generate(&f.e); + + for who in [&stranger, &f.manager] { + f.sign_only_as(who, "finish_setup", Vec::new(&f.e)); + assert!(f.vault.try_finish_setup().is_err()); + } + assert!(f.vault.setup_open()); +} + +#[test] +fn admin_changes_are_instant_during_setup() { + let f = setup_unfinished(); + let next = Address::generate(&f.e); + + f.vault.set_custodian(&f.custodian); + f.vault.set_notice(&3_600); + f.vault.set_notice(&0); + f.vault.set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY)); + f.vault.set_wind_down_delay(&MIN_WIND_DOWN_DELAY); + f.vault + .grant_role(&next, &symbol_short!("guardian"), &f.admin); + f.vault + .revoke_role(&f.manager, &symbol_short!("manager"), &f.admin); + + assert_eq!(f.vault.custodian(), Some(f.custodian.clone())); + assert_eq!(f.vault.notice(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); + assert!(f + .vault + .has_role(&next, &symbol_short!("guardian")) + .is_some()); + assert_eq!(f.vault.manager(), None); +} + +#[test] +fn nothing_is_proposed_during_setup() { + let f = setup_unfinished(); + + refused( + f.vault + .try_propose_change(&VaultChange::Custodian(f.custodian.clone())), + VaultError::SetupOpen, + ); + refused( + f.vault + .try_propose_change(&VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1)), + VaultError::SetupOpen, + ); + assert_eq!(f.vault.pending_changes().len(), 0); +} diff --git a/contracts/async-vault/src/test/share_returns.rs b/contracts/async-vault/src/test/share_returns.rs new file mode 100644 index 00000000..bba8dbc5 --- /dev/null +++ b/contracts/async-vault/src/test/share_returns.rs @@ -0,0 +1,138 @@ +use soroban_sdk::{contract, contractimpl}; +use stellar_contract_utils::pausable::PausableError; +use stellar_tokens::rwa::compliance::ComplianceHook; + +use super::*; + +#[contract] +pub(super) struct RefusesEveryTransfer; + +#[contractimpl] +impl RefusesEveryTransfer { + pub fn can_transfer( + _e: Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + false + } +} + +#[test] +fn a_delisted_vault_still_returns_escrowed_shares() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + + f.identity.allow(&f.vault.address, &false); + assert!(!f.vault.health().allowlisted); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); +} + +#[test] +fn a_delisted_holder_cannot_take_shares_back() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.identity.allow(&holder, &false); + + refused( + f.vault.try_cancel_redeem(&holder, &epoch), + VaultError::NotAllowlisted, + ); + assert_eq!(f.shares(&holder), 300); + assert!(f.vault.get_redeem_request(&epoch, &holder).is_some()); +} + +#[test] +fn a_frozen_holder_cannot_take_shares_back() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + refused( + f.vault.try_cancel_redeem(&holder, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.shares(&holder), 300); +} + +#[test] +fn a_return_the_compliance_rules_refuse_stays_escrowed() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + let module = f.e.register(RefusesEveryTransfer, ()); + f.add_compliance_module(ComplianceHook::CanTransfer, &module); + + refused( + f.vault.try_cancel_redeem(&holder, &epoch), + VaultError::HolderNotEligible, + ); + assert_eq!(f.shares(&f.vault.address), 200); + assert!(f.vault.get_redeem_request(&epoch, &holder).is_some()); +} + +#[test] +fn a_token_pause_stops_trading_but_not_a_share_return() { + let f = setup(); + let holder = f.holder(500); + let other = f.holder(10); + let epoch = f.vault.request_redeem(&holder, &200); + f.share.pause(&f.admin); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); + assert_eq!( + f.share.try_transfer(&holder, &other, &1), + Err(Ok(PausableError::EnforcedPause.into())) + ); + assert_eq!(f.shares(&holder), 500); +} + +#[test] +fn a_token_pause_does_not_stop_a_worthless_exit_coming_back() { + let f = setup(); + let holder = f.holder(10); + let epoch = f.vault.request_redeem(&holder, &1); + // Priced at half a unit per share, one share pays nothing. + f.fulfill_epoch(WAD_SCALE / 2); + f.share.pause(&f.admin); + + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 0); + assert_eq!(f.shares(&holder), 10); +} + +#[test] +fn a_partial_freeze_does_not_stop_a_return() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.share.freeze_partial_tokens(&holder, &100, &f.compliance); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); + assert_eq!(f.share.get_frozen_tokens(&holder), 100); +} + +#[test] +fn a_delisted_holder_with_a_worthless_exit_keeps_the_request() { + let f = setup(); + let holder = f.holder(10); + let epoch = f.vault.request_redeem(&holder, &1); + // Priced at half a unit per share, one share pays nothing. + f.fulfill_epoch(WAD_SCALE / 2); + f.identity.allow(&holder, &false); + + refused( + f.vault.try_claim_redeem(&holder, &epoch), + VaultError::HolderNotEligible, + ); + assert_eq!(f.vault.pending_burn_shares(), 1); + assert!(f.vault.get_redeem_request(&epoch, &holder).is_some()); +} diff --git a/contracts/async-vault/src/test/signatures.rs b/contracts/async-vault/src/test/signatures.rs new file mode 100644 index 00000000..fa95e65f --- /dev/null +++ b/contracts/async-vault/src/test/signatures.rs @@ -0,0 +1,193 @@ +//! What each privileged call asks to sign: one signature per account it needs. + +use soroban_sdk::{IntoVal, Vec}; + +use super::*; + +#[test] +fn each_setup_call_asks_the_admin_for_one_signature() { + let f = setup_unfinished(); + let holder = Address::generate(&f.e); + let treasury = symbol_short!("treasury"); + let delay = 2 * MIN_WIND_DOWN_DELAY; + let live_until = f.e.ledger().sequence() + 100; + + f.vault.set_custodian(&f.custodian); + f.signed_once_by( + &f.admin, + "set_custodian", + (f.custodian.clone(),).into_val(&f.e), + ); + f.vault.set_notice(&3_600); + f.signed_once_by(&f.admin, "set_notice", (3_600_u64,).into_val(&f.e)); + f.vault.set_wind_down_delay(&delay); + f.signed_once_by(&f.admin, "set_wind_down_delay", (delay,).into_val(&f.e)); + f.vault.set_deposit_cap(&Some(1_000)); + f.signed_once_by( + &f.admin, + "set_deposit_cap", + (Some(1_000_i128),).into_val(&f.e), + ); + f.vault.set_min_epoch_duration(&DAY_DURATION); + f.signed_once_by( + &f.admin, + "set_min_epoch_duration", + (DAY_DURATION,).into_val(&f.e), + ); + f.vault.grant_role(&holder, &treasury, &f.admin); + f.signed_once_by( + &f.admin, + "grant_role", + (holder.clone(), treasury.clone(), f.admin.clone()).into_val(&f.e), + ); + f.vault.revoke_role(&holder, &treasury, &f.admin); + f.signed_once_by( + &f.admin, + "revoke_role", + (holder.clone(), treasury, f.admin.clone()).into_val(&f.e), + ); + f.vault.transfer_admin_role(&holder, &live_until); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), live_until).into_val(&f.e), + ); + f.vault.transfer_admin_role(&holder, &0); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder, 0_u32).into_val(&f.e), + ); + f.vault.finish_setup(); + f.signed_once_by(&f.admin, "finish_setup", Vec::new(&f.e)); +} + +#[test] +fn each_admin_call_after_setup_asks_the_admin_for_one_signature() { + let f = setup(); + let next = Address::generate(&f.e); + + let change = VaultChange::Custodian(next.clone()); + let id = f.vault.propose_change(&change); + f.signed_once_by(&f.admin, "propose_change", (change,).into_val(&f.e)); + f.vault.cancel_change(&id); + f.signed_once_by(&f.admin, "cancel_change", (id,).into_val(&f.e)); + + let id = f.vault.propose_change(&VaultChange::Admin(next.clone())); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); + + f.vault.pause(&f.guardian); + f.vault.unpause(&f.admin); + f.signed_once_by(&f.admin, "unpause", (f.admin.clone(),).into_val(&f.e)); + + f.vault.propose_wind_down(); + f.signed_once_by(&f.admin, "propose_wind_down", Vec::new(&f.e)); + f.vault.cancel_wind_down_proposal(); + f.signed_once_by(&f.admin, "cancel_wind_down_proposal", Vec::new(&f.e)); + + f.vault.accept_admin_transfer(); + f.signed_once_by(&next, "accept_admin_transfer", Vec::new(&f.e)); +} + +#[test] +fn each_role_call_asks_its_holder_for_one_signature() { + let f = setup(); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &400); + + let epoch = f.vault.close_epoch(&f.manager); + f.signed_once_by( + &f.manager, + "close_epoch", + (f.manager.clone(),).into_val(&f.e), + ); + f.fulfill_epoch_at(epoch, wad(2)); + let free = f.vault.free_reserve(); + f.vault.deploy_to_custodian(&f.treasury, &free); + f.signed_once_by( + &f.treasury, + "deploy_to_custodian", + (f.treasury.clone(), free).into_val(&f.e), + ); + f.vault.pause(&f.guardian); + f.signed_once_by(&f.guardian, "pause", (f.guardian.clone(),).into_val(&f.e)); + + let guardian = symbol_short!("guardian"); + f.vault.renounce_role(&guardian, &f.guardian); + f.signed_once_by( + &f.guardian, + "renounce_role", + (guardian, f.guardian.clone()).into_val(&f.e), + ); +} + +#[test] +fn cancel_deposit_asks_the_holder_for_one_signature() { + let f = setup(); + let holder = f.investor(1_000); + let epoch = f.vault.request_deposit(&holder, &400); + + f.e.set_auths(&[]); + missing_auth(f.vault.try_cancel_deposit(&holder, &epoch)); + + f.e.mock_all_auths(); + assert_eq!(f.vault.cancel_deposit(&holder, &epoch), 400); + f.signed_once_by( + &holder, + "cancel_deposit", + (holder.clone(), epoch).into_val(&f.e), + ); +} + +#[test] +fn cancel_redeem_asks_the_holder_for_one_signature() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + + f.e.set_auths(&[]); + missing_auth(f.vault.try_cancel_redeem(&holder, &epoch)); + + f.e.mock_all_auths(); + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + f.signed_once_by( + &holder, + "cancel_redeem", + (holder.clone(), epoch).into_val(&f.e), + ); +} + +#[test] +fn claim_redeem_asks_the_holder_for_one_signature() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.fulfill_epoch(wad(2)); + + f.e.set_auths(&[]); + missing_auth(f.vault.try_claim_redeem(&holder, &epoch)); + + f.e.mock_all_auths(); + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 400); + f.signed_once_by( + &holder, + "claim_redeem", + (holder.clone(), epoch).into_val(&f.e), + ); +} + +#[test] +fn claim_wind_down_asks_the_holder_for_one_signature() { + let f = setup(); + let holder = f.holder(500); + let pot = super::wind_down::first_round_now(&f); + + f.e.set_auths(&[]); + missing_auth(f.vault.try_claim_wind_down(&holder)); + + f.e.mock_all_auths(); + assert_eq!(f.vault.claim_wind_down(&holder), pot); + f.signed_once_by(&holder, "claim_wind_down", (holder.clone(),).into_val(&f.e)); +} diff --git a/contracts/async-vault/src/test/supply.rs b/contracts/async-vault/src/test/supply.rs index 01b2a1be..dbe46bbc 100644 --- a/contracts/async-vault/src/test/supply.rs +++ b/contracts/async-vault/src/test/supply.rs @@ -1,3 +1,5 @@ +use soroban_sdk::{contract, contractimpl}; + use super::*; #[test] @@ -94,3 +96,36 @@ fn economic_supply_combines_circulating_and_pending_mint_correctly() { assert_eq!(f.vault.pending_mint_shares(), 0); assert_eq!(f.vault.total_economic_supply(), 360); } + +/// A share token whose books disagree: the vault holds more than exists. +#[contract] +struct SkewedShares; + +#[contractimpl] +impl SkewedShares { + pub fn total_supply(_e: Env) -> i128 { + 1 + } + + pub fn balance(_e: Env, _id: Address) -> i128 { + 2 + } +} + +#[test] +fn a_vault_holding_more_shares_than_exist_fails_loudly() { + let e = Env::default(); + let asset = e + .register_stellar_asset_contract_v2(Address::generate(&e)) + .address(); + let shares = e.register(SkewedShares, ()); + let vault = AsyncVaultClient::new( + &e, + &e.register(AsyncVault, (&asset, &shares, &asset, distinct_roles(&e))), + ); + + refused( + vault.try_total_economic_supply(), + VaultError::AccountingMismatch, + ); +} diff --git a/contracts/async-vault/src/test/timing.rs b/contracts/async-vault/src/test/timing.rs index 250b4b91..dcd2b7e2 100644 --- a/contracts/async-vault/src/test/timing.rs +++ b/contracts/async-vault/src/test/timing.rs @@ -7,7 +7,9 @@ fn priceable_at_never_precedes_the_close( closed_at: u64, notice: u64, ) -> TestCaseResult { - prop_assert!(W::priceable_at(closed_at, notice) >= closed_at); + if let Some(at) = W::priceable_at(closed_at, notice) { + prop_assert!(at >= closed_at); + } Ok(()) } @@ -17,12 +19,19 @@ fn a_longer_notice_never_prices_sooner( longer: u64, ) -> TestCaseResult { prop_assume!(shorter <= longer); - prop_assert!(W::priceable_at(closed_at, longer) >= W::priceable_at(closed_at, shorter)); + match W::priceable_at(closed_at, shorter) { + None => prop_assert_eq!(W::priceable_at(closed_at, longer), None), + Some(at_shorter) => { + if let Some(at_longer) = W::priceable_at(closed_at, longer) { + prop_assert!(at_longer >= at_shorter); + } + } + } Ok(()) } fn a_zero_notice_prices_at_the_close(closed_at: u64) -> TestCaseResult { - prop_assert_eq!(W::priceable_at(closed_at, 0), closed_at); + prop_assert_eq!(W::priceable_at(closed_at, 0), Some(closed_at)); Ok(()) } @@ -117,6 +126,10 @@ fn the_notice_ends_on_its_last_second() { } #[test] -fn a_notice_that_would_overflow_saturates() { - assert_eq!(StandardTiming::priceable_at(u64::MAX, 1), u64::MAX); +fn a_notice_that_would_overflow_has_no_end() { + assert_eq!(StandardTiming::priceable_at(u64::MAX, 1), None); + assert_eq!( + StandardTiming::priceable_at(u64::MAX - 1, 1), + Some(u64::MAX) + ); } diff --git a/contracts/async-vault/src/test/treasury.rs b/contracts/async-vault/src/test/treasury.rs index a6574ea5..1f0373b7 100644 --- a/contracts/async-vault/src/test/treasury.rs +++ b/contracts/async-vault/src/test/treasury.rs @@ -1,3 +1,6 @@ +use soroban_sdk::IntoVal; +use stellar_access::access_control::AccessControlError; + use super::*; #[test] @@ -5,7 +8,6 @@ fn capital_makes_the_round_trip_to_the_custodian() { let f = setup(); let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); // Escrow stays refundable until the epoch prices, so none of it is free. assert_eq!(f.vault.free_reserve(), 0); @@ -31,7 +33,6 @@ fn returning_more_than_was_deployed_shows_as_negative_net() { let f = setup(); let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); f.close_epoch(); f.attest(wad(2)); f.vault.fulfill_epoch(&epoch); @@ -48,13 +49,15 @@ fn assets_owed_to_a_priced_redemption_cannot_be_deployed() { let f = setup(); let holder = f.holder(500); let epoch = f.vault.request_redeem(&holder, &500); - f.vault.set_custodian(&f.custodian, &f.admin); assert_eq!(f.fulfill_epoch(wad(2)), epoch); assert_eq!(f.balance(&f.vault.address), 1_000); assert_eq!(f.vault.free_reserve(), 0); - assert!(f.vault.try_deploy_to_custodian(&f.treasury, &1).is_err()); + refused( + f.vault.try_deploy_to_custodian(&f.treasury, &1), + VaultError::ReserveCommittedToExits, + ); assert_eq!(f.vault.claim_redeem(&holder, &epoch), 1_000); } @@ -64,7 +67,6 @@ fn an_unpriced_redemption_does_not_reserve_anything_yet() { let f = setup(); let holder = f.holder(500); f.vault.request_redeem(&holder, &500); - f.vault.set_custodian(&f.custodian, &f.admin); assert_eq!(f.vault.free_reserve(), 1_000); assert_eq!(f.vault.deploy_to_custodian(&f.treasury, &1_000), 1_000); @@ -83,55 +85,117 @@ fn a_claim_waits_while_the_capital_is_deployed() { // Pricing does not need the cash back; the claim does. assert_eq!(f.vault.fulfill_epoch(&epoch), wad(2)); assert_eq!(f.vault.uncovered(), 1_000); - assert!(f.vault.try_claim_redeem(&holder, &epoch).is_err()); + refused( + f.vault.try_claim_redeem(&holder, &epoch), + VaultError::ClaimNotCovered, + ); f.vault.fund(&f.custodian, &1_000); assert_eq!(f.vault.uncovered(), 0); assert_eq!(f.vault.claim_redeem(&holder, &epoch), 1_000); } +#[test] +fn funding_a_zero_amount_is_rejected() { + let f = setup(); + let investor = f.investor(1_000); + + refused(f.vault.try_fund(&investor, &0), VaultError::InvalidAmount); +} + #[test] fn deploying_needs_the_treasury_role() { let f = setup(); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); - assert!(f.vault.try_deploy_to_custodian(&f.manager, &100).is_err()); - assert!(f.vault.try_deploy_to_custodian(&f.admin, &100).is_err()); + assert_eq!( + f.vault.try_deploy_to_custodian(&f.manager, &100), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + assert_eq!( + f.vault.try_deploy_to_custodian(&f.admin, &100), + Err(Ok(AccessControlError::Unauthorized.into())) + ); assert_eq!(f.balance(&f.custodian), 0); } #[test] fn deploying_without_a_custodian_is_rejected() { - let f = setup(); - let investor = f.investor(1_000); - f.vault.request_deposit(&investor, &1_000); + let f = miswired(Miswire::NoCustodian); + let backer = f.investor(1_000); + f.vault.fund(&backer, &1_000); assert_eq!(f.vault.custodian(), None); - assert!(f.vault.try_deploy_to_custodian(&f.treasury, &100).is_err()); + refused( + f.vault.try_deploy_to_custodian(&f.treasury, &100), + VaultError::CustodianNotSet, + ); } #[test] fn setting_the_custodian_needs_the_admin_signature() { - let f = setup(); + let f = miswired(Miswire::NoCustodian); f.e.set_auths(&[]); - assert!(f.vault.try_set_custodian(&f.custodian, &f.admin).is_err()); - assert!(f - .vault - .try_set_custodian(&f.custodian, &f.treasury) - .is_err()); + missing_auth(f.vault.try_set_custodian(&f.custodian)); + f.sign_only_as( + &f.treasury, + "set_custodian", + (f.custodian.clone(),).into_val(&f.e), + ); + missing_auth(f.vault.try_set_custodian(&f.custodian)); assert_eq!(f.vault.custodian(), None); } +#[test] +fn setting_the_vault_as_its_own_custodian_is_refused() { + for f in [setup_unfinished(), setup()] { + let before = f.vault.custodian(); + refused( + f.vault.try_set_custodian(&f.vault.address), + VaultError::InvalidCustodian, + ); + assert_eq!(f.vault.custodian(), before); + } +} + #[test] fn deploying_a_non_positive_amount_is_rejected() { let f = setup(); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); - assert!(f.vault.try_deploy_to_custodian(&f.treasury, &0).is_err()); - assert!(f.vault.try_deploy_to_custodian(&f.treasury, &-1).is_err()); + refused( + f.vault.try_deploy_to_custodian(&f.treasury, &0), + VaultError::InvalidAmount, + ); + refused( + f.vault.try_deploy_to_custodian(&f.treasury, &-1), + VaultError::InvalidAmount, + ); +} + +/// Exits priced after a gain can owe more than the capital the vault counts, +/// so the result may be negative; only leaving the type's range is refused. +#[test] +fn deposited_capital_fails_by_name_instead_of_overflowing() { + let f = setup(); + let investor = f.investor(1); + f.vault.request_deposit(&investor, &1); + let capital = |net_deployed: i128, committed: i128| { + f.e.try_as_contract::(&f.vault.address, || { + crate::state::set_net_deployed(&f.e, net_deployed); + crate::state::set_committed(&f.e, committed); + crate::treasury::deposited_capital(&f.e) + }) + }; + + assert_eq!(capital(10, 3), Ok(8)); + assert_eq!(capital(0, 5), Ok(-4)); + assert_eq!(capital(i128::MAX, 0), Err(Ok(VaultError::AmountTooLarge))); + assert_eq!( + capital(i128::MIN, 2), + Err(Ok(VaultError::AccountingMismatch)) + ); } diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 3e508440..e39064c2 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -1,15 +1,76 @@ use soroban_sdk::testutils::storage::{Instance as _, Persistent as _}; -use storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; +use soroban_sdk::{contracttype, IntoVal, Symbol, Val}; +use stellar_access::access_control::{AccessControlStorageKey, ROLE_EXTEND_AMOUNT}; +use stellar_tokens::fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}; +use strata_storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; use super::*; use crate::keys::DataKey; +use crate::roles::{GUARDIAN_ROLE, MANAGER_ROLE, TREASURY_ROLE}; -const TWO_DAYS: u64 = 2 * 24 * 60 * 60; +const TWO_DAYS: u64 = 2 * DAY_DURATION; + +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; fn persistent_ttl(f: &Fixture, key: &DataKey) -> u32 { f.e.as_contract(&f.vault.address, || f.e.storage().persistent().get_ttl(key)) } +fn ttl_in>(e: &Env, contract: &Address, key: &K) -> u32 { + e.as_contract(contract, || e.storage().persistent().get_ttl(key)) +} + +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + +/// Every entry a grant writes: the role, its member-list slot, the member count, the role list. +fn role_entry_ttls(e: &Env, contract: &Address, account: &Address, role: &Symbol) -> [u32; 4] { + let has_role = AccessControlStorageKey::HasRole(account.clone(), role.clone()); + let index: u32 = e.as_contract(contract, || { + e.storage().persistent().get(&has_role).unwrap() + }); + [ + ttl_in(e, contract, &has_role), + ttl_in( + e, + contract, + &RoleMemberKey::RoleAccounts(RoleAccountKey { + role: role.clone(), + index, + }), + ), + ttl_in( + e, + contract, + &AccessControlStorageKey::RoleAccountsCount(role.clone()), + ), + ttl_in(e, contract, &AccessControlStorageKey::ExistingRoles), + ] +} + +fn share_balance_ttl(f: &Fixture, account: &Address) -> u32 { + ttl_in( + &f.e, + &f.share.address, + &FungibleStorageKey::Balance(account.clone()), + ) +} + +fn testnet_minimum(e: &Env) { + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); +} + fn instance_ttl(f: &Fixture) -> u32 { f.e.as_contract(&f.vault.address, || f.e.storage().instance().get_ttl()) } @@ -93,16 +154,89 @@ fn a_pending_request_still_claims_after_most_of_a_month() { f.vault.request_deposit(&user, &400); let epoch = f.close_epoch(); - f.advance(29 * 24 * 60 * 60); - f.oracle.attest( - &NavReport { - nav_per_share: wad(2), - timestamp: 0, - expires_at: u64::MAX, - }, - &f.attester, - ); + f.advance(29 * DAY_DURATION); + f.oracle + .attest(&nav_input(&f.e, wad(2), u64::MAX), &f.attester); f.vault.fulfill_epoch(&epoch); assert_eq!(f.vault.claim_deposit(&user, &epoch), 200); } + +#[test] +fn a_deposit_claim_writes_the_new_share_balance_with_the_full_ttl() { + let f = setup(); + let user = f.investor(1_000); + let epoch = f.vault.request_deposit(&user, &400); + f.fulfill_epoch(wad(2)); + testnet_minimum(&f.e); + + f.vault.claim_deposit(&user, &epoch); + + assert_eq!(share_balance_ttl(&f, &user), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl() { + let f = setup(); + let holder = f.holder(100); + testnet_minimum(&f.e); + + f.vault.request_redeem(&holder, &100); + + assert_eq!( + share_balance_ttl(&f, &f.vault.address), + BALANCE_EXTEND_AMOUNT + ); +} + +#[test] +fn the_constructor_writes_each_role_with_the_full_ttl() { + let e = Env::default(); + testnet_minimum(&e); + let roles = distinct_roles(&e); + + let vault = register_with(&e, roles.clone()); + + for (account, role) in [ + (&roles.manager, MANAGER_ROLE), + (&roles.treasury, TREASURY_ROLE), + (&roles.guardian, GUARDIAN_ROLE), + ] { + assert_eq!( + role_entry_ttls(&e, &vault, account, &role), + [ROLE_EXTEND_AMOUNT; 4] + ); + } +} + +#[test] +fn a_grant_during_setup_writes_the_role_with_the_full_ttl() { + let f = setup_unfinished(); + let next = Address::generate(&f.e); + testnet_minimum(&f.e); + + f.vault.grant_role(&next, &GUARDIAN_ROLE, &f.admin); + + assert_eq!( + role_entry_ttls(&f.e, &f.vault.address, &next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { + let f = setup(); + let next = Address::generate(&f.e); + let id = f + .vault + .propose_change(&VaultChange::GrantRole(GUARDIAN_ROLE, next.clone())); + f.advance(MIN_UPGRADE_DELAY); + testnet_minimum(&f.e); + + f.vault.apply_change(&id); + + assert_eq!( + role_entry_ttls(&f.e, &f.vault.address, &next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 5d159cf2..50b62e3b 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -1,6 +1,29 @@ +use soroban_sdk::{testutils::Events as _, Event as _, IntoVal}; +use stellar_contract_utils::pausable::PausableError; + use super::*; +use crate::{ChangeApplied, ChangeCancelled, ChangeProposed}; -const DAY: u64 = 24 * 60 * 60; +fn wasm(f: &Fixture, byte: u8) -> BytesN<32> { + BytesN::from_array(&f.e, &[byte; 32]) +} + +fn propose(f: &Fixture, change: VaultChange) -> u32 { + f.vault.propose_change(&change) +} + +/// Raises the upgrade delay through the timelock, which costs one current delay. +fn raise_delay(f: &Fixture, secs: u64) { + let id = propose(f, VaultChange::UpgradeDelay(secs)); + f.advance(f.vault.upgrade_delay()); + f.vault.apply_change(&id); +} + +fn only_pending(f: &Fixture) -> VaultPendingChange { + let pending = f.vault.pending_changes(); + assert_eq!(pending.len(), 1); + pending.get(0).unwrap() +} #[test] fn the_delay_defaults_to_the_minimum() { @@ -12,28 +35,29 @@ fn the_delay_defaults_to_the_minimum() { #[test] fn a_proposal_records_an_eta_one_delay_out() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); + let hash = wasm(&f, 7); - f.vault.propose_upgrade(&hash, &f.admin); + let id = propose(&f, VaultChange::Upgrade(hash.clone())); - let p = f.vault.upgrade_proposal().unwrap(); - assert_eq!(p.action, UpgradeAction::Wasm(hash)); + let p = only_pending(&f); + assert_eq!(p.id, id); + assert_eq!(p.change, VaultChange::Upgrade(hash)); assert_eq!(p.eta, f.e.ledger().timestamp() + MIN_UPGRADE_DELAY); } #[test] -fn a_second_proposal_while_one_stands_is_refused() { +fn a_second_code_change_while_one_stands_is_refused() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); - let other = BytesN::from_array(&f.e, &[9u8; 32]); - f.vault.propose_upgrade(&hash, &f.admin); + propose(&f, VaultChange::Upgrade(wasm(&f, 7))); refused( - f.vault.try_propose_upgrade(&other, &f.admin), + f.vault + .try_propose_change(&VaultChange::Upgrade(wasm(&f, 9))), VaultError::UpgradeProposalExists, ); refused( - f.vault.try_propose_upgrade_delay(&(30 * DAY), &f.admin), + f.vault + .try_propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION)), VaultError::UpgradeProposalExists, ); } @@ -41,24 +65,21 @@ fn a_second_proposal_while_one_stands_is_refused() { #[test] fn a_cancelled_proposal_leaves_the_slot_free() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); - f.vault.propose_upgrade(&hash, &f.admin); + let hash = wasm(&f, 7); + let id = propose(&f, VaultChange::Upgrade(hash.clone())); - f.vault.cancel_upgrade(&f.admin); - assert_eq!(f.vault.upgrade_proposal(), None); + f.vault.cancel_change(&id); + assert_eq!(f.vault.pending_changes().len(), 0); - f.vault.propose_upgrade(&hash, &f.admin); - assert!(f.vault.upgrade_proposal().is_some()); + propose(&f, VaultChange::Upgrade(hash)); + assert_eq!(f.vault.pending_changes().len(), 1); } #[test] fn cancelling_nothing_is_refused() { let f = setup(); - refused( - f.vault.try_cancel_upgrade(&f.admin), - VaultError::UpgradeProposalNotFound, - ); + refused(f.vault.try_cancel_change(&1), VaultError::ChangeNotFound); } #[test] @@ -67,10 +88,10 @@ fn a_delay_below_the_minimum_is_refused() { refused( f.vault - .try_propose_upgrade_delay(&(MIN_UPGRADE_DELAY - 1), &f.admin), + .try_propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY - 1)), VaultError::UpgradeDelayTooShort, ); - assert_eq!(f.vault.upgrade_proposal(), None); + assert_eq!(f.vault.pending_changes().len(), 0); } #[test] @@ -79,51 +100,54 @@ fn a_delay_above_the_maximum_is_refused() { refused( f.vault - .try_propose_upgrade_delay(&(MAX_UPGRADE_DELAY + 1), &f.admin), + .try_propose_change(&VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY + 1)), VaultError::UpgradeDelayTooLong, ); - assert_eq!(f.vault.upgrade_proposal(), None); + assert_eq!(f.vault.pending_changes().len(), 0); } #[test] fn a_delay_below_the_standing_notice_is_refused() { let f = setup(); - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); - f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + raise_delay(&f, 30 * DAY_DURATION); - f.vault.set_notice(&(20 * DAY), &f.admin); + f.vault.set_notice(&(20 * DAY_DURATION)); // Above the minimum, below the notice: an exit could not complete. refused( - f.vault.try_propose_upgrade_delay(&(10 * DAY), &f.admin), + f.vault + .try_propose_change(&VaultChange::UpgradeDelay(10 * DAY_DURATION)), VaultError::UpgradeDelayBelowNotice, ); - f.vault.propose_upgrade_delay(&(21 * DAY), &f.admin); - assert!(f.vault.upgrade_proposal().is_some()); + propose(&f, VaultChange::UpgradeDelay(21 * DAY_DURATION)); + assert_eq!(f.vault.pending_changes().len(), 1); } #[test] fn only_governance_proposes_and_cancels() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); + let change = VaultChange::Upgrade(wasm(&f, 7)); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_propose_upgrade(&hash, &stranger).is_err()); - assert!(f.vault.try_propose_upgrade(&hash, &f.manager).is_err()); - assert!(f.vault.try_propose_upgrade(&hash, &f.guardian).is_err()); - assert!(f - .vault - .try_propose_upgrade_delay(&(30 * DAY), &f.treasury) - .is_err()); + for who in [&stranger, &f.manager, &f.guardian] { + f.sign_only_as(who, "propose_change", (change.clone(),).into_val(&f.e)); + missing_auth(f.vault.try_propose_change(&change)); + } + let delay = VaultChange::UpgradeDelay(30 * DAY_DURATION); + f.sign_only_as( + &f.treasury, + "propose_change", + (delay.clone(),).into_val(&f.e), + ); + missing_auth(f.vault.try_propose_change(&delay)); f.e.mock_all_auths(); - f.vault.propose_upgrade(&hash, &f.admin); - f.e.set_auths(&[]); - assert!(f.vault.try_cancel_upgrade(&stranger).is_err()); - assert!(f.vault.try_cancel_upgrade(&f.guardian).is_err()); + let id = propose(&f, change); + for who in [&stranger, &f.guardian] { + f.sign_only_as(who, "cancel_change", (id,).into_val(&f.e)); + missing_auth(f.vault.try_cancel_change(&id)); + } } #[test] @@ -136,69 +160,54 @@ fn the_schema_version_is_one_after_construction() { #[test] fn applying_before_the_eta_is_refused() { let f = setup(); - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); + let id = propose(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); - refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, - ); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(MIN_UPGRADE_DELAY - 1); - refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, - ); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(1); - f.vault.apply_upgrade(&f.admin); - assert_eq!(f.vault.upgrade_delay(), 30 * DAY); - assert_eq!(f.vault.upgrade_proposal(), None); + f.vault.apply_change(&id); + assert_eq!(f.vault.upgrade_delay(), 30 * DAY_DURATION); + assert_eq!(f.vault.pending_changes().len(), 0); } #[test] fn changing_the_delay_serves_the_old_delay_first() { let f = setup(); - - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); - f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + raise_delay(&f, 30 * DAY_DURATION); // The next proposal uses the new, longer delay. - let hash = BytesN::from_array(&f.e, &[7u8; 32]); - f.vault.propose_upgrade(&hash, &f.admin); + propose(&f, VaultChange::Upgrade(wasm(&f, 7))); assert_eq!( - f.vault.upgrade_proposal().unwrap().eta, - f.e.ledger().timestamp() + 30 * DAY + only_pending(&f).eta, + f.e.ledger().timestamp() + 30 * DAY_DURATION ); } #[test] fn shortening_the_delay_cannot_be_rushed() { let f = setup(); - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); - f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + raise_delay(&f, 30 * DAY_DURATION); // Back down to the minimum: still costs the full thirty days. - f.vault.propose_upgrade_delay(&MIN_UPGRADE_DELAY, &f.admin); - f.advance(30 * DAY - 1); - refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, - ); + let id = propose(&f, VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); + f.advance(30 * DAY_DURATION - 1); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(1); - f.vault.apply_upgrade(&f.admin); + f.vault.apply_change(&id); assert_eq!(f.vault.upgrade_delay(), MIN_UPGRADE_DELAY); } #[test] fn the_notice_cannot_outgrow_the_delay() { let f = setup(); - f.vault.propose_upgrade_delay(&(10 * DAY), &f.admin); + propose(&f, VaultChange::UpgradeDelay(10 * DAY_DURATION)); refused( - f.vault.try_set_notice(&(20 * DAY), &f.admin), + f.vault.try_set_notice(&(20 * DAY_DURATION)), VaultError::NoticeAboveUpgradeDelay, ); assert_eq!(f.vault.notice(), 0); @@ -208,125 +217,407 @@ fn the_notice_cannot_outgrow_the_delay() { fn applying_nothing_is_refused() { let f = setup(); - refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeProposalNotFound, - ); + refused(f.vault.try_apply_change(&1), VaultError::ChangeNotFound); } #[test] fn a_pause_freezes_the_upgrade_timelock_clock() { let f = setup(); + let id = propose(&f, VaultChange::UpgradeDelay(14 * DAY_DURATION)); + let original_eta = only_pending(&f).eta; + + f.advance(3 * DAY_DURATION); + f.vault.pause(&f.guardian); + f.advance(10 * DAY_DURATION); - f.vault.propose_upgrade_delay(&(14 * DAY), &f.admin); - let original_eta = f.vault.upgrade_proposal().unwrap().eta; + assert_eq!( + f.vault.try_apply_change(&id), + Err(Ok(PausableError::EnforcedPause.into())) + ); + + // Unpause: the eta has moved by the ten days spent paused. + f.vault.unpause(&f.admin); + assert_eq!(only_pending(&f).eta, original_eta + 10 * DAY_DURATION); - // Advance 3 days - f.advance(3 * DAY); + // Six unpaused days out of seven: still refused. + f.advance(3 * DAY_DURATION); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); - // Pause the vault for 10 days + f.advance(DAY_DURATION); + f.vault.apply_change(&id); + assert_eq!(f.vault.pending_changes().len(), 0); + assert_eq!(f.vault.upgrade_delay(), 14 * DAY_DURATION); +} + +#[test] +fn a_proposals_eta_matches_the_pending_change_until_a_pause_moves_it() { + let f = setup(); f.vault.pause(&f.guardian); - f.advance(10 * DAY); + f.advance(DAY_DURATION); - // While paused, applying is refused - assert!(f.vault.try_apply_upgrade(&f.admin).is_err()); + let change = VaultChange::Upgrade(wasm(&f, 7)); + let eta = f.e.ledger().timestamp() + MIN_UPGRADE_DELAY; + let id = f.vault.propose_change(&change); - // Unpause: proposal ETA is pushed by the 10-day paused duration - f.vault.unpause(&f.admin); - let updated_p = f.vault.upgrade_proposal().unwrap(); - assert_eq!(updated_p.eta, original_eta + 10 * DAY); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ChangeProposed { id, change, eta }.to_xdr(&f.e, &f.vault.address)] + ); + assert_eq!(only_pending(&f).eta, eta); - // 3 days later (total 6 unpaused days out of 7), applying still refused - f.advance(3 * DAY); - refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, + f.advance(DAY_DURATION); + assert_eq!(only_pending(&f).eta, eta + DAY_DURATION); +} + +#[test] +fn applying_while_paused_is_refused() { + let f = setup(); + let id = propose(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); + f.advance(MIN_UPGRADE_DELAY); + + // At the eta, with no paused time yet, only the pause stands in the way. + f.vault.pause(&f.guardian); + assert_eq!( + f.vault.try_apply_change(&id), + Err(Ok(PausableError::EnforcedPause.into())) ); + assert_eq!(f.vault.upgrade_delay(), MIN_UPGRADE_DELAY); - // 1 more day (7 unpaused days total): applying succeeds - f.advance(DAY); - f.vault.apply_upgrade(&f.admin); - assert_eq!(f.vault.upgrade_proposal(), None); - assert_eq!(f.vault.upgrade_delay(), 14 * DAY); + f.vault.unpause(&f.admin); + f.vault.apply_change(&id); + assert_eq!(f.vault.upgrade_delay(), 30 * DAY_DURATION); } #[test] fn only_governance_applies() { let f = setup(); let stranger = Address::generate(&f.e); - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); + let id = propose(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); f.advance(MIN_UPGRADE_DELAY); - f.e.set_auths(&[]); - assert!(f.vault.try_apply_upgrade(&stranger).is_err()); - assert!(f.vault.try_apply_upgrade(&f.manager).is_err()); - assert!(f.vault.try_apply_upgrade(&f.guardian).is_err()); - assert!(f.vault.upgrade_proposal().is_some()); + for who in [&stranger, &f.manager, &f.guardian] { + f.sign_only_as(who, "apply_change", (id,).into_val(&f.e)); + missing_auth(f.vault.try_apply_change(&id)); + } + assert_eq!(f.vault.pending_changes().len(), 1); +} + +/// Whether the host refused a call to `function` because the vault has no +/// function of that name. The reason is only in the diagnostic events. +fn has_no_function(e: &Env, function: &str) -> bool { + use soroban_sdk::xdr::{ContractEventBody, ScError, ScErrorCode, ScSymbol, ScVal}; + let missing = ScVal::Error(ScError::Context(ScErrorCode::MissingValue)); + let name = ScVal::Symbol(ScSymbol(function.try_into().unwrap())); + e.host() + .get_diagnostic_events() + .unwrap() + .0 + .iter() + .any(|event| { + let ContractEventBody::V0(body) = &event.event.body; + body.topics.contains(&missing) + && matches!(&body.data, ScVal::Vec(Some(args)) if args.contains(&name)) + }) +} + +/// What a caller sees when the host refuses a call: every host error is +/// narrowed to this one. +fn host_refusal() -> soroban_sdk::Error { + use soroban_sdk::xdr::{ScErrorCode, ScErrorType}; + soroban_sdk::Error::from_type_and_code(ScErrorType::Context, ScErrorCode::InvalidAction) } #[test] fn the_vault_exposes_no_immediate_upgrade() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); - - // If the Upgradeable trait were implemented, this client would work - // against the vault and replace the code with no delay at all. - let client = + let upgrader = stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.vault.address); - assert!(client.try_upgrade(&hash, &f.admin).is_err()); + + assert_eq!( + upgrader.try_upgrade(&wasm(&f, 7), &f.admin), + Err(Ok(host_refusal())) + ); + assert!(has_no_function(&f.e, "upgrade")); +} + +#[test] +fn nothing_is_proposed_during_a_wind_down() { + let f = setup(); + super::wind_down::wind_down_now(&f); + + refused( + f.vault + .try_propose_change(&VaultChange::Upgrade(wasm(&f, 7))), + VaultError::WindDownActive, + ); +} + +#[test] +fn a_change_standing_when_the_wind_down_starts_never_applies() { + let f = setup(); + let id = propose(&f, VaultChange::Upgrade(wasm(&f, 7))); + super::wind_down::wind_down_now(&f); + f.advance(MAX_UPGRADE_DELAY); + + refused(f.vault.try_apply_change(&id), VaultError::WindDownActive); + f.vault.cancel_change(&id); + assert_eq!(f.vault.pending_changes().len(), 0); +} + +#[test] +fn applying_a_delay_change_checks_the_notice_again() { + let f = setup(); + raise_delay(&f, 30 * DAY_DURATION); + let id = propose(&f, VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); + + // The notice rises while the shorter delay waits. + f.vault.set_notice(&(20 * DAY_DURATION)); + f.advance(30 * DAY_DURATION); + + refused( + f.vault.try_apply_change(&id), + VaultError::UpgradeDelayBelowNotice, + ); + assert_eq!(f.vault.upgrade_delay(), 30 * DAY_DURATION); +} + +#[test] +fn proposing_and_applying_publish_the_change() { + let f = setup(); + let change = VaultChange::UpgradeDelay(30 * DAY_DURATION); + let eta = f.e.ledger().timestamp() + MIN_UPGRADE_DELAY; + + let id = propose(&f, change.clone()); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ChangeProposed { + id, + change: change.clone(), + eta, + } + .to_xdr(&f.e, &f.vault.address)] + ); + + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ChangeApplied { id, change }.to_xdr(&f.e, &f.vault.address)] + ); +} + +#[test] +fn cancelling_publishes_the_change_id() { + let f = setup(); + let id = propose(&f, VaultChange::Upgrade(wasm(&f, 7))); + + f.vault.cancel_change(&id); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ChangeCancelled { id }.to_xdr(&f.e, &f.vault.address)] + ); +} + +#[test] +fn the_vault_reports_its_clock_to_the_token_and_the_oracle() { + let f = setup(); + f.vault.pause(&f.guardian); + f.advance(DAY_DURATION); + + let clock = strata_timelock::ClockSourceClient::new(&f.e, &f.vault.address).timelock_clock(); + + assert_eq!( + clock, + strata_timelock::Clock { + delay: MIN_UPGRADE_DELAY, + exit_window: 0, + paused_secs: DAY_DURATION, + paused: true, + winding_down: false, + } + ); } #[cfg(feature = "upgrade_wasm")] mod with_wasm { - use super::*; + use share_token::TokenChange; + use soroban_sdk::xdr::{ContractEventBody, ScError, ScErrorCode, ScSymbol, ScVal}; - soroban_sdk::contractimport!( - file = "../../fixtures/upgrade-target/target/wasm32v1-none/release/upgrade_target.wasm" - ); + use super::*; - #[test] - fn immediate_upgrade_with_valid_wasm_does_not_replace_code() { - let f = setup(); - let hash = f.e.deployer().upload_contract_wasm(WASM); - - let client = - stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.vault.address); - assert!(client.try_upgrade(&hash, &f.admin).is_err()); - assert_eq!(f.vault.schema_version(), 1); - let target = Client::new(&f.e, &f.vault.address); - assert!(target.try_migrate(&f.admin).is_err()); + mod token_v1 { + soroban_sdk::contractimport!(file = "../../target/wasm32v1-none/release/share_token.wasm"); } - #[test] - fn applying_a_wasm_proposal_replaces_the_code() { - let f = setup(); - let hash = f.e.deployer().upload_contract_wasm(WASM); + mod token_probe { + soroban_sdk::contractimport!( + file = "../../target/upgrade-probe/wasm32v1-none/release/share_token.wasm" + ); + } - assert_eq!(f.vault.schema_version(), 1); + mod vault_probe { + soroban_sdk::contractimport!( + file = "../../target/upgrade-probe/wasm32v1-none/release/async_vault.wasm" + ); + } - f.vault.propose_upgrade(&hash, &f.admin); + /// Upgrades the vault to its probe build. + fn upgrade_to_probe<'a>(f: &'a Fixture) -> vault_probe::Client<'a> { + let hash = f.e.deployer().upload_contract_wasm(vault_probe::WASM); + let id = f.vault.propose_change(&VaultChange::Upgrade(hash)); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + f.vault.apply_change(&id); + vault_probe::Client::new(&f.e, &f.vault.address) + } - let target = Client::new(&f.e, &f.vault.address); - assert_eq!(target.schema_version(), 1); + /// Whether the host refused a call to `function` because the contract + /// exports no function of that name. The reason is only in the + /// diagnostic events. + fn exports_no(e: &Env, function: &str) -> bool { + let missing = ScVal::Error(ScError::WasmVm(ScErrorCode::MissingValue)); + let name = ScVal::Symbol(ScSymbol(function.try_into().unwrap())); + e.host() + .get_diagnostic_events() + .unwrap() + .0 + .iter() + .any(|event| { + let ContractEventBody::V0(body) = &event.event.body; + body.topics.contains(&missing) + && matches!(&body.data, ScVal::Vec(Some(args)) if args.contains(&name)) + }) + } - target.migrate(&f.admin); - assert_eq!(target.schema_version(), 2); + #[test] + fn the_token_exposes_no_immediate_upgrade() { + let f = setup_with(open_feed(), |e, args| e.register(token_v1::WASM, args)); + let probe_hash = f.e.deployer().upload_contract_wasm(token_probe::WASM); + let upgrader = + stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.share.address); + + assert_eq!( + upgrader.try_upgrade(&probe_hash, &f.admin), + Err(Ok(host_refusal())) + ); + assert!(exports_no(&f.e, "upgrade")); + assert_eq!( + token_probe::Client::new(&f.e, &f.share.address).try_probe(), + Err(Ok(host_refusal())) + ); + assert!(exports_no(&f.e, "probe")); + assert_eq!(f.share.schema_version(), 1); } #[test] - fn a_migration_refuses_a_second_run() { - let f = setup(); - let hash = f.e.deployer().upload_contract_wasm(WASM); - f.vault.propose_upgrade(&hash, &f.admin); + fn the_token_upgrades_under_live_balances_and_open_requests() { + let f = setup_with(open_feed(), |e, args| e.register(token_v1::WASM, args)); + let holder = f.holder(100); + let other = f.investor(1); + let epoch = f.vault.request_redeem(&holder, &40); + let depositor = f.investor(500); + assert_eq!(f.vault.request_deposit(&depositor, &500), epoch); + + let probe_hash = f.e.deployer().upload_contract_wasm(token_probe::WASM); + let id = f.share.propose_change(&TokenChange::Upgrade(probe_hash)); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + f.share.apply_change(&id); + + let probe = token_probe::Client::new(&f.e, &f.share.address); + assert!(probe.probe()); + assert_eq!(probe.schema_version(), 1); + probe.migrate(); + assert_eq!(probe.schema_version(), 2); + assert_eq!( + probe.try_migrate(), + Err(Ok(token_probe::ProbeError::AlreadyMigrated.into())) + ); + + assert_eq!(f.shares(&holder), 60); + assert_eq!(f.shares(&f.vault.address), 40); + assert_eq!( + f.vault.get_redeem_request(&epoch, &holder).unwrap().shares, + 40 + ); + + f.fulfill_epoch_at(f.close_epoch(), wad(2)); + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 80); + assert_eq!(f.vault.claim_deposit(&depositor, &epoch), 250); + assert_eq!(f.shares(&depositor), 250); + assert_eq!(f.shares(&f.vault.address), 0); + + f.share.transfer(&holder, &other, &10); + assert_eq!(f.shares(&other), 10); + } - let target = Client::new(&f.e, &f.vault.address); - target.migrate(&f.admin); + #[test] + fn applying_a_wasm_change_replaces_the_code() { + let f = setup(); + + let probe = upgrade_to_probe(&f); + assert!(probe.probe()); + assert_eq!(probe.schema_version(), 1); + + probe.migrate(); + assert_eq!(probe.schema_version(), 2); - assert!(target.try_migrate(&f.admin).is_err()); - assert_eq!(target.schema_version(), 2); + assert_eq!( + probe.try_migrate(), + Err(Ok(vault_probe::ProbeError::AlreadyMigrated.into())) + ); + assert_eq!(probe.schema_version(), 2); + } + + #[test] + fn the_vault_upgrades_under_open_requests_and_a_proposed_wind_down() { + let f = setup(); + let staying = f.holder(500); + let exiting = f.holder(300); + let priced = f.vault.request_redeem(&exiting, &100); + f.fulfill_epoch(wad(2)); + let depositor = f.investor(400); + let open = f.vault.request_deposit(&depositor, &400); + let successor = Address::generate(&f.e); + let queued = f + .vault + .propose_change(&VaultChange::Custodian(successor.clone())); + f.vault.propose_wind_down(); + let kept = ( + f.vault.get_epoch(&open), + f.vault.get_deposit_request(&open, &depositor), + f.vault.get_epoch(&priced), + f.vault.get_redeem_request(&priced, &exiting), + f.vault.committed(), + f.vault.wind_down(), + f.vault.pending_changes(), + ); + + let probe = upgrade_to_probe(&f); + assert!(probe.probe()); + probe.migrate(); + assert_eq!(probe.schema_version(), 2); + assert_eq!( + ( + f.vault.get_epoch(&open), + f.vault.get_deposit_request(&open, &depositor), + f.vault.get_epoch(&priced), + f.vault.get_redeem_request(&priced, &exiting), + f.vault.committed(), + f.vault.wind_down(), + f.vault.pending_changes(), + ), + kept + ); + + assert_eq!(f.vault.claim_redeem(&exiting, &priced), 200); + f.vault.apply_change(&queued); + assert_eq!(f.vault.custodian(), Some(successor)); + assert_eq!(f.close_epoch(), open); + f.fulfill_epoch_at(open, wad(2)); + assert_eq!(f.vault.claim_deposit(&depositor, &open), 200); + f.vault.activate_wind_down(); + assert_eq!(f.vault.finalize_wind_down_round(), 1_800); + assert_eq!(f.vault.wind_down_supply(), 900); + assert_eq!(f.vault.claim_wind_down(&staying), 1_000); } } diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index e141597d..90a63568 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -1,37 +1,40 @@ +use proptest::prelude::*; +use soroban_sdk::{contract, contracterror, contractimpl, panic_with_error, IntoVal}; + use super::*; #[test] fn governance_sets_the_delay_within_the_maximum() { let f = setup(); - assert_eq!(f.vault.wind_down_delay(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); - f.vault.set_wind_down_delay(&(7 * 24 * 60 * 60), &f.admin); - assert_eq!(f.vault.wind_down_delay(), 7 * 24 * 60 * 60); + f.vault.set_wind_down_delay(&(14 * DAY_DURATION)); + assert_eq!(f.vault.wind_down_delay(), 14 * DAY_DURATION); } #[test] fn the_delay_cannot_exceed_the_maximum() { let f = setup(); - assert!(f - .vault - .try_set_wind_down_delay(&(MAX_WIND_DOWN_DELAY + 1), &f.admin) - .is_err()); - assert_eq!(f.vault.wind_down_delay(), 0); + refused( + f.vault.try_set_wind_down_delay(&(MAX_WIND_DOWN_DELAY + 1)), + VaultError::WindDownDelayTooLong, + ); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } #[test] fn only_governance_sets_the_delay() { let f = setup(); let stranger = Address::generate(&f.e); + let longer = 2 * MIN_WIND_DOWN_DELAY; - f.e.set_auths(&[]); - assert!(f.vault.try_set_wind_down_delay(&60, &stranger).is_err()); - assert!(f.vault.try_set_wind_down_delay(&60, &f.manager).is_err()); - assert!(f.vault.try_set_wind_down_delay(&60, &f.treasury).is_err()); - assert!(f.vault.try_set_wind_down_delay(&60, &f.guardian).is_err()); - assert_eq!(f.vault.wind_down_delay(), 0); + for who in [&stranger, &f.manager, &f.treasury, &f.guardian] { + f.sign_only_as(who, "set_wind_down_delay", (longer,).into_val(&f.e)); + missing_auth(f.vault.try_set_wind_down_delay(&longer)); + } + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } #[test] @@ -41,38 +44,27 @@ fn a_fresh_vault_is_not_winding_down() { assert_eq!(f.vault.wind_down(), None); } -const WEEK: u64 = 7 * 24 * 60 * 60; - #[test] fn governance_proposes_and_anyone_activates_after_the_delay() { let f = setup(); - let stranger = Address::generate(&f.e); - f.vault.set_wind_down_delay(&WEEK, &f.admin); + let delay = 2 * MIN_WIND_DOWN_DELAY; + f.vault.set_wind_down_delay(&delay); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); let proposed = f.vault.wind_down().unwrap(); assert_eq!(proposed.status, WindDownStatus::Proposed); assert_eq!(proposed.round, 0); + assert_eq!(proposed.active_at, f.e.ledger().timestamp() + delay); - assert!(f.vault.try_activate_wind_down().is_err()); - - f.advance(WEEK); - f.vault.activate_wind_down(); - - assert_eq!(f.vault.wind_down().unwrap().status, WindDownStatus::Active); - let _ = stranger; -} - -#[test] -fn activation_is_open_to_a_stranger() { - let f = setup(); - f.vault.set_wind_down_delay(&WEEK, &f.admin); - f.vault.propose_wind_down(&f.admin); - f.advance(WEEK); + f.advance(delay - 1); + refused( + f.vault.try_activate_wind_down(), + VaultError::WindDownDelayNotElapsed, + ); + f.advance(1); f.e.set_auths(&[]); f.vault.activate_wind_down(); - assert_eq!(f.vault.wind_down().unwrap().status, WindDownStatus::Active); } @@ -81,22 +73,24 @@ fn only_governance_proposes() { let f = setup(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_propose_wind_down(&stranger).is_err()); - assert!(f.vault.try_propose_wind_down(&f.manager).is_err()); - assert!(f.vault.try_propose_wind_down(&f.guardian).is_err()); + for who in [&stranger, &f.manager, &f.guardian] { + f.sign_only_as(who, "propose_wind_down", soroban_sdk::Vec::new(&f.e)); + missing_auth(f.vault.try_propose_wind_down()); + } assert_eq!(f.vault.wind_down(), None); } #[test] fn a_second_proposal_cannot_push_the_date_back() { let f = setup(); - f.vault.set_wind_down_delay(&WEEK, &f.admin); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); let first = f.vault.wind_down().unwrap().active_at; - f.advance(WEEK / 2); - assert!(f.vault.try_propose_wind_down(&f.admin).is_err()); + f.advance(MIN_WIND_DOWN_DELAY / 2); + refused( + f.vault.try_propose_wind_down(), + VaultError::WindDownAlreadyProposed, + ); assert_eq!(f.vault.wind_down().unwrap().active_at, first); } @@ -104,20 +98,23 @@ fn a_second_proposal_cannot_push_the_date_back() { #[test] fn governance_cancels_a_proposal_before_activation() { let f = setup(); - f.vault.set_wind_down_delay(&WEEK, &f.admin); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_cancel_wind_down_proposal(&stranger).is_err()); - assert!(f.vault.try_cancel_wind_down_proposal(&f.manager).is_err()); - assert!(f.vault.try_cancel_wind_down_proposal(&f.guardian).is_err()); + for who in [&stranger, &f.manager, &f.guardian] { + f.sign_only_as( + who, + "cancel_wind_down_proposal", + soroban_sdk::Vec::new(&f.e), + ); + missing_auth(f.vault.try_cancel_wind_down_proposal()); + } f.e.mock_all_auths(); - f.vault.cancel_wind_down_proposal(&f.admin); + f.vault.cancel_wind_down_proposal(); assert_eq!(f.vault.wind_down(), None); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); assert_eq!( f.vault.wind_down().unwrap().status, WindDownStatus::Proposed @@ -127,12 +124,12 @@ fn governance_cancels_a_proposal_before_activation() { #[test] fn an_active_wind_down_cannot_be_cancelled() { let f = setup(); - f.vault.set_wind_down_delay(&WEEK, &f.admin); - f.vault.propose_wind_down(&f.admin); - f.advance(WEEK); - f.vault.activate_wind_down(); + wind_down_now(&f); - assert!(f.vault.try_cancel_wind_down_proposal(&f.admin).is_err()); + refused( + f.vault.try_cancel_wind_down_proposal(), + VaultError::WindDownActive, + ); assert_eq!(f.vault.wind_down().unwrap().status, WindDownStatus::Active); } @@ -140,25 +137,35 @@ fn an_active_wind_down_cannot_be_cancelled() { fn activating_without_a_proposal_is_refused() { let f = setup(); - assert!(f.vault.try_activate_wind_down().is_err()); + refused( + f.vault.try_activate_wind_down(), + VaultError::WindDownNotProposed, + ); } #[test] -fn a_zero_delay_activates_immediately() { - let f = setup(); - - f.vault.propose_wind_down(&f.admin); - f.vault.activate_wind_down(); - - assert_eq!(f.vault.wind_down().unwrap().status, WindDownStatus::Active); +fn a_delay_below_the_minimum_is_refused() { + for f in [setup_unfinished(), setup()] { + refused( + f.vault.try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1)), + VaultError::WindDownDelayTooShort, + ); + } } /// Proposes, waits and activates. Returns nothing; the vault is active after. -fn wind_down_now(f: &Fixture) { - f.vault.propose_wind_down(&f.admin); +pub(super) fn wind_down_now(f: &Fixture) { + f.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); } +/// Winds down and finalises the first round; returns what it credited. +pub(super) fn first_round_now(f: &Fixture) -> i128 { + wind_down_now(f); + f.vault.finalize_wind_down_round() +} + #[test] fn an_active_wind_down_takes_no_new_requests() { let f = setup(); @@ -166,8 +173,41 @@ fn an_active_wind_down_takes_no_new_requests() { let holder = f.holder(500); wind_down_now(&f); - assert!(f.vault.try_request_deposit(&investor, &100).is_err()); - assert!(f.vault.try_request_redeem(&holder, &100).is_err()); + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::WindDownActive, + ); + refused( + f.vault.try_request_redeem(&holder, &100), + VaultError::WindDownActive, + ); +} + +#[test] +fn a_proposed_wind_down_takes_no_new_deposit() { + let f = setup(); + let holder = f.holder(500); + let early = f.investor(1_000); + let epoch = f.vault.request_deposit(&early, &100); + let late = f.investor(1_000); + f.vault.propose_wind_down(); + + refused( + f.vault.try_request_deposit(&late, &100), + VaultError::WindDownAlreadyProposed, + ); + f.vault.request_redeem(&holder, &100); + assert_eq!(f.vault.cancel_deposit(&early, &epoch), 100); +} + +#[test] +fn cancelling_the_proposal_reopens_deposits() { + let f = setup(); + let investor = f.investor(1_000); + f.vault.propose_wind_down(); + f.vault.cancel_wind_down_proposal(); + + f.vault.request_deposit(&investor, &100); } #[test] @@ -179,26 +219,33 @@ fn an_active_wind_down_prices_nothing_and_closes_no_epoch() { wind_down_now(&f); f.attest(wad(2)); - assert!(f.vault.try_fulfill_epoch(&epoch).is_err()); - assert!(f.vault.try_close_epoch(&f.manager).is_err()); + refused( + f.vault.try_fulfill_epoch(&epoch), + VaultError::WindDownActive, + ); + refused( + f.vault.try_close_epoch(&f.manager), + VaultError::WindDownActive, + ); } #[test] fn an_active_wind_down_sends_nothing_to_the_custodian() { let f = setup(); - f.vault.set_custodian(&f.custodian, &f.admin); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); wind_down_now(&f); - assert!(f.vault.try_deploy_to_custodian(&f.treasury, &100).is_err()); + refused( + f.vault.try_deploy_to_custodian(&f.treasury, &100), + VaultError::WindDownActive, + ); } #[test] fn funding_stays_open_during_a_wind_down() { let f = setup(); - f.vault.set_custodian(&f.custodian, &f.admin); let backer = f.investor(1_000); wind_down_now(&f); @@ -238,7 +285,10 @@ fn an_epoch_sealed_before_the_announcement_is_still_cancellable() { f.attest(wad(2)); // The price is readable, so without a wind-down this cancel is refused. - assert!(f.vault.try_cancel_deposit(&investor, &epoch).is_err()); + refused( + f.vault.try_cancel_deposit(&investor, &epoch), + VaultError::PriceAvailable, + ); wind_down_now(&f); @@ -247,7 +297,7 @@ fn an_epoch_sealed_before_the_announcement_is_still_cancellable() { } #[test] -fn an_escrowed_redemption_is_still_cancellable_after_the_announcement() { +fn an_escrowed_redemption_cancelled_in_a_wind_down_joins_the_payout() { let f = setup(); let user = f.holder(500); let epoch = f.vault.request_redeem(&user, &200); @@ -256,7 +306,10 @@ fn an_escrowed_redemption_is_still_cancellable_after_the_announcement() { wind_down_now(&f); assert_eq!(f.vault.cancel_redeem(&user, &epoch), 200); - assert_eq!(f.shares(&user), 500); + assert_eq!(f.shares(&user), 300); + + let pot = f.vault.finalize_wind_down_round(); + assert_eq!(f.vault.claim_wind_down(&user), pot); } #[test] @@ -282,9 +335,7 @@ fn a_second_round_does_not_credit_the_first_round_again() { let f = setup(); let _user = f.holder(500); let backer = f.investor(1_000); - wind_down_now(&f); - - let first = f.vault.finalize_wind_down_round(); + let first = first_round_now(&f); assert!(first > 0); f.vault.fund(&backer, &600); @@ -298,19 +349,24 @@ fn a_second_round_does_not_credit_the_first_round_again() { fn a_round_with_nothing_to_distribute_is_refused() { let f = setup(); let _user = f.holder(500); - wind_down_now(&f); - f.vault.finalize_wind_down_round(); + first_round_now(&f); - assert!(f.vault.try_finalize_wind_down_round().is_err()); + refused( + f.vault.try_finalize_wind_down_round(), + VaultError::NothingToDistribute, + ); } #[test] fn a_round_before_activation_is_refused() { let f = setup(); let _user = f.holder(500); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); - assert!(f.vault.try_finalize_wind_down_round().is_err()); + refused( + f.vault.try_finalize_wind_down_round(), + VaultError::WindDownNotActive, + ); } #[test] @@ -333,9 +389,7 @@ fn a_round_leaves_priced_liabilities_alone() { f.fulfill_epoch(wad(2)); let committed = f.vault.committed(); assert_eq!(committed, 400); - wind_down_now(&f); - - f.vault.finalize_wind_down_round(); + first_round_now(&f); assert_eq!(f.vault.committed(), committed); assert_eq!(f.vault.claim_redeem(&user, &epoch), 400); @@ -346,9 +400,7 @@ fn two_holders_split_a_round_in_proportion() { let f = setup(); let big = f.holder(600); let small = f.holder(200); - wind_down_now(&f); - - let pot = f.vault.finalize_wind_down_round(); + let pot = first_round_now(&f); let supply = f.vault.wind_down_supply(); let big_shares = f.shares(&big); @@ -370,9 +422,7 @@ fn a_holder_who_waits_collects_every_round_at_once() { let patient = f.holder(400); let prompt = f.holder(400); let backer = f.investor(10_000); - wind_down_now(&f); - - f.vault.finalize_wind_down_round(); + first_round_now(&f); let first = f.vault.claim_wind_down(&prompt); f.vault.fund(&backer, &800); @@ -384,18 +434,64 @@ fn a_holder_who_waits_collects_every_round_at_once() { assert_eq!(all_at_once, first + second); } +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +enum ModuleError { + BurnRefused = 1, +} + +#[contract] +struct FailsEveryBurn; + +#[contractimpl] +impl FailsEveryBurn { + pub fn on_destroyed(e: Env, _from: Address, _amount: i128, _token: Address) { + panic_with_error!(&e, ModuleError::BurnRefused); + } +} + +#[test] +fn a_module_failing_on_burn_blocks_wind_down_claims_and_cannot_be_removed() { + let f = setup(); + let holder = f.holder(500); + let module = f.e.register(FailsEveryBurn, ()); + f.add_compliance_module(ComplianceHook::Destroyed, &module); + first_round_now(&f); + + assert_eq!( + f.vault.try_claim_wind_down(&holder), + Err(Ok(ModuleError::BurnRefused.into())) + ); + assert_eq!(f.shares(&holder), 500); + assert_eq!(f.balance(&holder), 0); + + let rules = ComplianceClient::new(&f.e, &f.share.compliance()); + assert_eq!( + rules.try_propose_change(&ComplianceChange::RemoveModule( + ComplianceHook::Destroyed, + module, + )), + Err(Ok( + ::compliance::ComplianceContractError::WindDownActive.into() + )) + ); +} + #[test] fn claiming_burns_the_shares_and_pays_once() { let f = setup(); let user = f.holder(500); - wind_down_now(&f); - f.vault.finalize_wind_down_round(); + first_round_now(&f); let paid = f.vault.claim_wind_down(&user); assert!(paid > 0); assert_eq!(f.shares(&user), 0); - assert!(f.vault.try_claim_wind_down(&user).is_err()); + refused( + f.vault.try_claim_wind_down(&user), + VaultError::NoEntitlement, + ); assert_eq!(f.balance(&user), paid); } @@ -404,12 +500,14 @@ fn surrendering_between_rounds_pays_nothing_and_is_allowed() { let f = setup(); let user = f.holder(500); let backer = f.investor(10_000); - wind_down_now(&f); - f.vault.finalize_wind_down_round(); + first_round_now(&f); f.vault.claim_wind_down(&user); // Nothing surrendered, nothing owed. - assert!(f.vault.try_claim_wind_down(&user).is_err()); + refused( + f.vault.try_claim_wind_down(&user), + VaultError::NoEntitlement, + ); f.vault.fund(&backer, &800); f.vault.finalize_wind_down_round(); @@ -422,7 +520,10 @@ fn claiming_before_the_first_round_is_refused() { let user = f.holder(500); wind_down_now(&f); - assert!(f.vault.try_claim_wind_down(&user).is_err()); + refused( + f.vault.try_claim_wind_down(&user), + VaultError::DistributionNotStarted, + ); } #[test] @@ -430,27 +531,125 @@ fn a_holder_with_no_shares_cannot_claim() { let f = setup(); let _user = f.holder(500); let stranger = Address::generate(&f.e); - wind_down_now(&f); - f.vault.finalize_wind_down_round(); + first_round_now(&f); - assert!(f.vault.try_claim_wind_down(&stranger).is_err()); + refused( + f.vault.try_claim_wind_down(&stranger), + VaultError::NoEntitlement, + ); } #[test] -fn an_escrowed_redemption_is_recovered_then_surrendered() { +fn a_cancelled_redemption_joins_the_wind_down_payout() { let f = setup(); let user = f.holder(200); let epoch = f.vault.request_redeem(&user, &200); - wind_down_now(&f); - f.vault.finalize_wind_down_round(); + first_round_now(&f); - // The escrowed shares are inside the snapshot, so they must come back first. - assert!(f.vault.try_claim_wind_down(&user).is_err()); + refused( + f.vault.try_claim_wind_down(&user), + VaultError::NoEntitlement, + ); f.vault.cancel_redeem(&user, &epoch); - let rest = f.vault.claim_wind_down(&user); - assert!(rest > 0); assert_eq!(f.shares(&user), 0); + assert!(f.vault.claim_wind_down(&user) > 0); +} + +#[test] +fn a_token_pause_does_not_hold_a_redemption_in_a_wind_down() { + let f = setup(); + let user = f.holder(200); + let epoch = f.vault.request_redeem(&user, &200); + first_round_now(&f); + f.share.pause(&f.admin); + + f.vault.cancel_redeem(&user, &epoch); + + assert!(f.vault.claim_wind_down(&user) > 0); +} + +/// A freeze holds everything, even an escrow a wind-down would otherwise surrender. +#[test] +fn a_frozen_holders_redemption_stays_escrowed_in_a_wind_down() { + let f = setup(); + let user = f.holder(200); + let epoch = f.vault.request_redeem(&user, &200); + first_round_now(&f); + f.share.set_address_frozen(&user, &true, &f.compliance); + + refused(f.vault.try_cancel_redeem(&user, &epoch), VaultError::Frozen); + assert_eq!(f.shares(&f.vault.address), 200); + assert!(f.vault.get_redeem_request(&epoch, &user).is_some()); + + f.share.set_address_frozen(&user, &false, &f.compliance); + f.vault.cancel_redeem(&user, &epoch); + assert_eq!(f.vault.claim_wind_down(&user), 400); +} + +#[test] +fn a_frozen_holder_is_paid_once_a_compliance_holder_granted_in_the_wind_down_lifts_the_freeze() { + let f = setup(); + let frozen = f.holder(500); + let other = f.holder(500); + first_round_now(&f); + let role = soroban_sdk::Symbol::new(&f.e, "compliance"); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + f.share.revoke_role(&f.compliance, &role, &f.admin); + let paid = f.vault.claim_wind_down(&other); + + let successor = Address::generate(&f.e); + let id = f.share.propose_change(&share_token::TokenChange::GrantRole( + role, + successor.clone(), + )); + f.advance(MIN_UPGRADE_DELAY); + f.share.apply_change(&id); + f.share.set_address_frozen(&frozen, &false, &successor); + + assert_eq!(f.vault.claim_wind_down(&frozen), paid); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +/// The snapshot left this escrow out, so its shares must not come back to be surrendered. +#[test] +fn a_redemption_that_pays_nothing_returns_no_shares_in_a_wind_down() { + let f = setup(); + let a = f.holder(200); + let b = f.holder(200); + let epoch = f.vault.request_redeem(&a, &1); + f.fulfill_epoch(WAD_SCALE / 2); + wind_down_now(&f); + assert_eq!(f.vault.finalize_wind_down_round(), 800); + + assert_eq!(f.vault.claim_redeem(&a, &epoch), 0); + let paid = f.vault.claim_wind_down(&a) + f.vault.claim_wind_down(&b); + + assert_eq!(paid, 799); + assert_eq!(f.vault.wind_down_owed(), 1); + assert_eq!(f.shares(&a), 0); + assert_eq!(f.shares(&f.vault.address), 0); +} + +/// A cash payout never needs the identity check, even a zero one: the burn +/// still runs for a delisted holder. +#[test] +fn a_delisted_holders_worthless_redemption_burns_in_a_wind_down() { + let f = setup(); + let a = f.holder(200); + let b = f.holder(200); + let epoch = f.vault.request_redeem(&a, &1); + f.fulfill_epoch(WAD_SCALE / 2); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.identity.allow(&a, &false); + + assert_eq!(f.vault.claim_redeem(&a, &epoch), 0); + f.vault.claim_wind_down(&a); + f.vault.claim_wind_down(&b); + + assert_eq!(f.shares(&a), 0); + assert_eq!(f.shares(&f.vault.address), 0); } #[test] @@ -458,21 +657,24 @@ fn the_payouts_never_exceed_what_the_rounds_credited() { let f = setup(); let a = f.holder(333); let b = f.holder(667); + f.empty_reserve(); wind_down_now(&f); + f.vault.fund(&f.custodian, &7); + assert_eq!(f.vault.finalize_wind_down_round(), 7); - let pot = f.vault.finalize_wind_down_round(); let paid = f.vault.claim_wind_down(&a) + f.vault.claim_wind_down(&b); - assert!(paid <= pot); - assert!(f.vault.wind_down_owed() >= 0); + assert_eq!(paid, 6); + assert_eq!(f.balance(&a) + f.balance(&b), paid); + assert_eq!(paid + f.vault.wind_down_owed(), 7); + assert_eq!(f.balance(&f.vault.address), f.vault.wind_down_owed()); } #[test] fn the_pause_does_not_block_a_wind_down_claim() { let f = setup(); let user = f.holder(500); - wind_down_now(&f); - f.vault.finalize_wind_down_round(); + first_round_now(&f); f.vault.pause(&f.guardian); assert!(f.vault.claim_wind_down(&user) > 0); @@ -487,24 +689,32 @@ fn a_zero_share_refund_is_refused_once_a_round_has_taken_the_reserve() { f.close_epoch(); // A price high enough that one unit of the asset buys no shares at all. f.fulfill_epoch_at(epoch, wad(2)); - wind_down_now(&f); - f.vault.finalize_wind_down_round(); + first_round_now(&f); // Known limit: the refund is not tracked as a liability, so a round can // credit the assets it needed. - assert!(f.vault.try_claim_deposit(&investor, &epoch).is_err()); + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::ClaimNotCovered, + ); } #[test] fn the_vault_cannot_claim_against_its_own_escrowed_shares() { let f = setup(); let user = f.holder(500); - f.vault.request_redeem(&user, &200); - wind_down_now(&f); - f.vault.finalize_wind_down_round(); + let epoch = f.vault.request_redeem(&user, &200); + first_round_now(&f); + let owed = f.vault.wind_down_owed(); - f.e.set_auths(&[]); - assert!(f.vault.try_claim_wind_down(&f.vault.address).is_err()); + assert_eq!(f.vault.wind_down_claimable(&f.vault.address), 0); + refused( + f.vault.try_claim_wind_down(&f.vault.address), + VaultError::NoEntitlement, + ); + assert_eq!(f.shares(&f.vault.address), 200); + assert_eq!(f.vault.wind_down_owed(), owed); + assert_eq!(f.vault.cancel_redeem(&user, &epoch), 200); } #[test] @@ -512,11 +722,10 @@ fn a_position_survives_the_ledger_advancing() { let f = setup(); let user = f.holder(500); let backer = f.investor(10_000); - wind_down_now(&f); - f.vault.finalize_wind_down_round(); + first_round_now(&f); f.vault.claim_wind_down(&user); - f.advance(29 * 24 * 60 * 60); + f.advance(29 * DAY_DURATION); f.vault.fund(&backer, &800); f.vault.finalize_wind_down_round(); @@ -529,10 +738,8 @@ fn shares_acquired_after_first_claim_earn_all_prior_rounds() { let u1 = f.holder(300); let u2 = f.holder(300); let backer = f.investor(10_000); - wind_down_now(&f); - // Round 1 - f.vault.finalize_wind_down_round(); + first_round_now(&f); let paid1 = f.vault.claim_wind_down(&u1); assert!(paid1 > 0); @@ -551,8 +758,8 @@ fn shares_acquired_after_first_claim_earn_all_prior_rounds() { let expected_total = stellar_contract_utils::math::i128_fixed_point::checked_mul_div_floor( &f.e, &600, - &f.vault.wind_down_acc(), - &WAD_SCALE, + &f.vault.wind_down_distributed(), + &f.vault.wind_down_supply(), ) .unwrap(); assert_eq!(paid1 + paid2, expected_total); @@ -565,21 +772,26 @@ fn multiple_rounds_with_floor_dust_remain_solvent_for_last_claimant() { let h2 = f.holder(333); let h3 = f.holder(334); let backer = f.investor(10_000); - wind_down_now(&f); - - f.vault.finalize_wind_down_round(); - f.vault.claim_wind_down(&h1); + first_round_now(&f); + let first = f.vault.claim_wind_down(&h1); f.vault.fund(&backer, &333); f.vault.finalize_wind_down_round(); - f.vault.claim_wind_down(&h2); + let second = f.vault.claim_wind_down(&h2); f.vault.fund(&backer, &333); f.vault.finalize_wind_down_round(); + let last = f.vault.claim_wind_down(&h3); + + let late = [f.vault.claim_wind_down(&h1), f.vault.claim_wind_down(&h2)]; + let paid = first + second + last + late[0] + late[1]; - let paid3 = f.vault.claim_wind_down(&h3); - assert!(paid3 > 0); - assert!(f.vault.wind_down_owed() >= 0); + assert_eq!([first, second, last], [666, 776, 890]); + assert_eq!(late, [221, 111]); + assert_eq!(f.vault.wind_down_distributed(), 2_666); + assert_eq!(paid + f.vault.wind_down_owed(), 2_666); + assert_eq!(f.vault.wind_down_owed(), 2); + assert_eq!(f.balance(&f.vault.address), f.vault.wind_down_owed()); } #[test] @@ -605,16 +817,281 @@ fn redemption_priced_before_wind_down_is_excluded_from_snapshot() { assert!(paid1 > 0); } +#[test] +fn shares_sent_to_the_vault_are_refused_so_the_snapshot_counts_holders_only() { + let f = setup(); + let a = f.holder(500); + let _b = f.holder(300); + + assert_eq!( + f.share.try_transfer(&a, &f.vault.address, &100), + Err(Ok(share_token::ShareTokenError::VaultAddress.into())), + ); + first_round_now(&f); + assert_eq!(f.vault.wind_down_supply(), 800); +} + #[test] fn propose_wind_down_reports_active_when_active() { let f = setup(); let _user = f.holder(500); wind_down_now(&f); - let err = f - .vault - .try_propose_wind_down(&f.admin) - .unwrap_err() - .unwrap(); + let err = f.vault.try_propose_wind_down().unwrap_err().unwrap(); assert_eq!(err, VaultError::WindDownActive.into()); } + +/// Each holder's payout floors, so `owed` can keep a unit per holder after +/// everyone has claimed. That unit is not free to release: a later round lets +/// each floor catch up and needs it. +#[test] +fn a_later_round_needs_the_units_an_earlier_round_left_in_owed() { + let f = setup(); + let a = f.holder(1); + let b = f.holder(1); + f.empty_reserve(); + wind_down_now(&f); + + f.vault.fund(&f.custodian, &1); + assert_eq!(f.vault.finalize_wind_down_round(), 1); + assert_eq!(f.vault.claim_wind_down(&a), 0); + assert_eq!(f.vault.claim_wind_down(&b), 0); + assert_eq!(f.vault.wind_down_owed(), 1); + + f.vault.fund(&f.custodian, &1); + assert_eq!(f.vault.finalize_wind_down_round(), 1); + assert_eq!(f.vault.claim_wind_down(&a), 1); + assert_eq!(f.vault.claim_wind_down(&b), 1); + + assert_eq!(f.vault.wind_down_owed(), 0); + assert_eq!(f.balance(&f.vault.address), 0); +} + +#[test] +fn a_round_of_one_unit_at_the_lowest_price_still_credits_it() { + let f = setup(); + let whale = f.investor(10i128.pow(13)); + let epoch = f.vault.request_deposit(&whale, &10i128.pow(13)); + f.fulfill_epoch(MIN_ANSWER); + f.vault.claim_deposit(&whale, &epoch); + assert_eq!(f.shares(&whale), 10i128.pow(19)); + f.empty_reserve(); + wind_down_now(&f); + f.vault.fund(&f.custodian, &1); + + assert_eq!(f.vault.finalize_wind_down_round(), 1); + assert_eq!(f.vault.claim_wind_down(&whale), 1); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn at_the_lowest_price_holders_collect_the_reserve_less_a_unit_each() { + let f = setup(); + let deposit = 5 * 10i128.pow(12); + let a = f.investor(deposit); + let b = f.investor(deposit); + let epoch = f.vault.request_deposit(&a, &deposit); + f.vault.request_deposit(&b, &deposit); + f.fulfill_epoch(MIN_ANSWER); + f.vault.claim_deposit(&a, &epoch); + f.vault.claim_deposit(&b, &epoch); + f.empty_reserve(); + wind_down_now(&f); + let pot = 1_000_000_007; + f.vault.fund(&f.custodian, &pot); + + assert_eq!(f.vault.finalize_wind_down_round(), pot); + let paid = f.vault.claim_wind_down(&a) + f.vault.claim_wind_down(&b); + let owed = f.vault.wind_down_owed(); + assert!(owed < 2); + assert_eq!(paid + owed, pot); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + + /// The bound only holds for shares the snapshot counted and that were + /// actually surrendered: random holders, each fully claiming, over a + /// random number of funding rounds. `owed` never reaches the holder + /// count, because each holder's own floor loses less than one unit. + #[test] + fn owed_stays_below_the_holder_count_once_every_share_is_surrendered( + holdings in proptest::collection::vec(1i128..1_000, 1..6), + extra_fundings in proptest::collection::vec(1i128..1_000, 0..3), + ) { + let f = setup(); + let holders: std::vec::Vec
      = + holdings.iter().map(|shares| f.holder(*shares)).collect(); + let total_shares: i128 = holdings.iter().sum(); + let backer = f.investor(total_shares + 10_000); + f.empty_reserve(); + wind_down_now(&f); + + // Funds exactly the snapshot, so the first round always credits + // something whatever the random holdings: every holder below then + // always has a round to claim against. + f.vault.fund(&backer, &total_shares); + f.vault.finalize_wind_down_round(); + + for amount in &extra_fundings { + f.vault.fund(&backer, amount); + let _ = f.vault.try_finalize_wind_down_round(); + } + + for holder in &holders { + f.vault.claim_wind_down(holder); + } + + prop_assert!(f.vault.wind_down_owed() < holders.len() as i128); + prop_assert!(f.balance(&f.vault.address) >= f.vault.wind_down_owed()); + } +} + +#[test] +fn a_frozen_holders_slice_stays_owed_until_the_freeze_lifts() { + let f = setup(); + let frozen = f.holder(500); + let other = f.holder(500); + first_round_now(&f); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + + refused(f.vault.try_claim_wind_down(&frozen), VaultError::Frozen); + let paid = f.vault.claim_wind_down(&other); + assert_eq!(f.vault.wind_down_owed(), paid); + assert_eq!(f.shares(&frozen), 500); + + f.share.set_address_frozen(&frozen, &false, &f.compliance); + assert_eq!(f.vault.claim_wind_down(&frozen), paid); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn a_frozen_holders_escrowed_exit_waits_through_a_wind_down() { + let f = setup(); + let frozen = f.holder(500); + let _other = f.holder(500); + let epoch = f.vault.request_redeem(&frozen, &500); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + first_round_now(&f); + + refused( + f.vault.try_cancel_redeem(&frozen, &epoch), + VaultError::Frozen, + ); + refused(f.vault.try_claim_wind_down(&frozen), VaultError::Frozen); + + f.share.set_address_frozen(&frozen, &false, &f.compliance); + assert_eq!(f.vault.cancel_redeem(&frozen, &epoch), 500); + assert!(f.vault.claim_wind_down(&frozen) > 0); +} + +#[test] +fn wind_down_claimable_is_zero_for_a_frozen_holder() { + let f = setup(); + let holder = f.holder(500); + first_round_now(&f); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + assert_eq!(f.vault.wind_down_claimable(&holder), 0); +} + +#[test] +fn the_claimable_view_and_the_claim_fail_alike_at_the_overflow_edge() { + let f = setup(); + let holder = f.holder(500); + f.empty_reserve(); + wind_down_now(&f); + f.vault.fund(&f.custodian, &100); + f.vault.finalize_wind_down_round(); + f.e.as_contract(&f.vault.address, || { + strata_storage::set_persistent( + &f.e, + &crate::keys::DataKey::WindDownPosition(holder.clone()), + &crate::WindDownPosition { + entitlement: i128::MAX - 100, + paid: 0, + }, + ) + }); + + refused( + f.vault.try_wind_down_claimable(&holder), + VaultError::AmountTooLarge, + ); + refused( + f.vault.try_claim_wind_down(&holder), + VaultError::AmountTooLarge, + ); +} + +#[test] +fn the_claimable_view_shows_what_the_claim_then_pays() { + let f = setup(); + let a = f.holder(300); + let _b = f.holder(700); + first_round_now(&f); + + let shown = f.vault.wind_down_claimable(&a); + assert!(shown > 0); + assert_eq!(f.vault.claim_wind_down(&a), shown); + assert_eq!(f.vault.wind_down_claimable(&a), 0); +} + +#[test] +fn a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim() { + let f = setup(); + let holder = f.holder(500); + let _other = f.holder(500); + first_round_now(&f); + f.share.freeze_partial_tokens(&holder, &400, &f.compliance); + + let first = f.vault.claim_wind_down(&holder); + + assert_eq!(f.shares(&holder), 400); + assert_eq!(f.share.get_frozen_tokens(&holder), 400); + f.share + .unfreeze_partial_tokens(&holder, &400, &f.compliance); + let rest = f.vault.claim_wind_down(&holder); + assert_eq!(rest, 4 * first); +} + +#[test] +fn a_snapshot_with_more_shares_to_burn_than_exist_fails_loudly() { + let f = setup(); + wind_down_now(&f); + f.e.as_contract(&f.vault.address, || { + crate::state::set_pending_burn_shares(&f.e, 1) + }); + + refused( + f.vault.try_finalize_wind_down_round(), + VaultError::AccountingMismatch, + ); +} + +#[test] +fn a_payout_below_what_was_already_paid_fails_loudly() { + let f = setup(); + let _holder = f.holder(500); + first_round_now(&f); + let newcomer = f.investor(0); + f.e.as_contract(&f.vault.address, || { + strata_storage::set_persistent( + &f.e, + &crate::keys::DataKey::WindDownPosition(newcomer.clone()), + &crate::WindDownPosition { + entitlement: 0, + paid: 1, + }, + ) + }); + + refused( + f.vault.try_wind_down_claimable(&newcomer), + VaultError::AccountingMismatch, + ); + refused( + f.vault.try_claim_wind_down(&newcomer), + VaultError::AccountingMismatch, + ); +} diff --git a/contracts/async-vault/src/timing.rs b/contracts/async-vault/src/timing.rs index 90d545d0..493f9262 100644 --- a/contracts/async-vault/src/timing.rs +++ b/contracts/async-vault/src/timing.rs @@ -3,8 +3,9 @@ /// same laws. pub(crate) trait FulfilmentTiming { /// The moment an epoch becomes priceable. Read once, when the epoch - /// closes, so a later change to the notice cannot move it. - fn priceable_at(closed_at: u64, notice: u64) -> u64; + /// closes, so a later change to the notice cannot move it. `None` when + /// that moment does not fit a `u64`. + fn priceable_at(closed_at: u64, notice: u64) -> Option; fn notice_elapsed(now: u64, priceable_at: u64) -> bool; @@ -15,8 +16,8 @@ pub(crate) trait FulfilmentTiming { pub(crate) struct StandardTiming; impl FulfilmentTiming for StandardTiming { - fn priceable_at(closed_at: u64, notice: u64) -> u64 { - closed_at.saturating_add(notice) + fn priceable_at(closed_at: u64, notice: u64) -> Option { + closed_at.checked_add(notice) } fn notice_elapsed(now: u64, priceable_at: u64) -> bool { diff --git a/contracts/async-vault/src/treasury.rs b/contracts/async-vault/src/treasury.rs index 4958a1fe..f18fa1e9 100644 --- a/contracts/async-vault/src/treasury.rs +++ b/contracts/async-vault/src/treasury.rs @@ -2,6 +2,7 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; use crate::error::VaultError; use crate::event::{CustodianSet, Deployed, Funded}; +use crate::guards; use crate::keys::DataKey; use crate::state; use crate::wind_down; @@ -14,7 +15,7 @@ pub(crate) fn set_custodian(e: &Env, custodian: &Address) { .publish(e); } -fn held(e: &Env) -> i128 { +pub(crate) fn held(e: &Env) -> i128 { let asset = state::get_addr(e, &DataKey::Asset); TokenClient::new(e, &asset).balance(&e.current_contract_address()) } @@ -30,8 +31,9 @@ pub(crate) fn free_reserve(e: &Env) -> i128 { (liquid_reserve(e) - state::committed(e) - wind_down::owed(e)).max(0) } -/// Owed to holders beyond what the vault holds. Zero when every priced claim is -/// payable. While this is positive nothing may leave for the custodian. +/// How far the vault is short of paying priced redemptions and the escrow +/// depositors can cancel. While this is positive nothing may leave for the +/// custodian. pub(crate) fn uncovered(e: &Env) -> i128 { (state::committed(e) - liquid_reserve(e)).max(0) } @@ -39,21 +41,19 @@ pub(crate) fn uncovered(e: &Env) -> i128 { /// Capital in the vault and deployed to the custodian, less liabilities already /// committed to priced exits. Used to evaluate headroom against the deposit cap. pub(crate) fn deposited_capital(e: &Env) -> i128 { - (held(e) + state::net_deployed(e)).saturating_sub(state::committed(e)) + held(e) + .checked_add(state::net_deployed(e)) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) + .checked_sub(state::committed(e)) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AccountingMismatch)) } pub(crate) fn deploy(e: &Env, assets: i128) -> i128 { - wind_down::refuse_if_active(e); - if assets <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - + guards::not_winding_down(e); + guards::positive_amount(e, assets); let custodian = state::get_addr_opt(e, &DataKey::Custodian) .unwrap_or_else(|| panic_with_error!(e, VaultError::CustodianNotSet)); - - if assets > free_reserve(e) { - panic_with_error!(e, VaultError::ReserveCommittedToExits); - } + guards::within_free_reserve(e, assets); let net_deployed = state::net_deployed(e) .checked_add(assets) @@ -73,12 +73,9 @@ pub(crate) fn deploy(e: &Env, assets: i128) -> i128 { net_deployed } -pub(crate) fn fund(e: &Env, from: &Address, assets: i128) -> i128 { - from.require_auth(); - - if assets <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } +pub(crate) fn fund(e: &Env, caller: &Address, assets: i128) -> i128 { + caller.require_auth(); + guards::positive_amount(e, assets); let net_deployed = state::net_deployed(e) .checked_sub(assets) @@ -86,10 +83,10 @@ pub(crate) fn fund(e: &Env, from: &Address, assets: i128) -> i128 { state::set_net_deployed(e, net_deployed); let asset = state::get_addr(e, &DataKey::Asset); - TokenClient::new(e, &asset).transfer(from, e.current_contract_address(), &assets); + TokenClient::new(e, &asset).transfer(caller, e.current_contract_address(), &assets); Funded { - from: from.clone(), + caller: caller.clone(), assets, net_deployed, } diff --git a/contracts/async-vault/src/upgrade.rs b/contracts/async-vault/src/upgrade.rs index aa4ae603..e045be5e 100644 --- a/contracts/async-vault/src/upgrade.rs +++ b/contracts/async-vault/src/upgrade.rs @@ -1,140 +1,18 @@ -use soroban_sdk::{contracttype, panic_with_error, BytesN, Env}; +use soroban_sdk::Env; -use crate::error::VaultError; -use crate::event::{ - UpgradeCancelled, UpgradeDelayProposed, UpgradeDelaySet, UpgradeProposed, Upgraded, -}; use crate::keys::DataKey; -use crate::state; /// Seven days. Short enough to fix a defect in a week, long enough that an /// investor who dislikes the change has time to act on it. -pub const MIN_UPGRADE_DELAY: u64 = 7 * 24 * 60 * 60; +pub const MIN_UPGRADE_DELAY: u64 = strata_timelock::MIN_DELAY; /// Ninety days. Prevents setting a delay that effectively brick-walls the vault /// against future upgrades. -pub const MAX_UPGRADE_DELAY: u64 = 90 * 24 * 60 * 60; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum UpgradeAction { - Wasm(BytesN<32>), - Delay(u64), -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UpgradeProposal { - pub action: UpgradeAction, - pub eta: u64, -} +pub const MAX_UPGRADE_DELAY: u64 = strata_timelock::MAX_DELAY; pub(crate) fn delay(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::UpgradeDelay).unwrap_or(MIN_UPGRADE_DELAY) -} - -pub(crate) fn proposal(e: &Env) -> Option { - storage::get_instance(e, &DataKey::UpgradeProposal) -} - -/// An exit takes a full notice, so a delay shorter than one is not a window. -fn check_delay(e: &Env, secs: u64) { - if secs < MIN_UPGRADE_DELAY { - panic_with_error!(e, VaultError::UpgradeDelayTooShort); - } - if secs > MAX_UPGRADE_DELAY { - panic_with_error!(e, VaultError::UpgradeDelayTooLong); - } - if secs < state::notice(e) { - panic_with_error!(e, VaultError::UpgradeDelayBelowNotice); - } -} - -fn queue(e: &Env, action: UpgradeAction) -> u64 { - if proposal(e).is_some() { - panic_with_error!(e, VaultError::UpgradeProposalExists); - } - let eta = e.ledger().timestamp().saturating_add(delay(e)); - storage::set_instance( - e, - &DataKey::UpgradeProposal, - &UpgradeProposal { action, eta }, - ); - if stellar_contract_utils::pausable::paused(e) { - storage::set_instance(e, &DataKey::PausedAt, &e.ledger().timestamp()); - } - eta -} - -pub(crate) fn on_pause(e: &Env) { - let paused_at: Option = storage::get_instance(e, &DataKey::PausedAt); - if paused_at.is_none() { - storage::set_instance(e, &DataKey::PausedAt, &e.ledger().timestamp()); - } + strata_storage::get_instance(e, &DataKey::UpgradeDelay).unwrap_or(MIN_UPGRADE_DELAY) } -pub(crate) fn on_unpause(e: &Env) { - let paused_at: Option = storage::get_instance(e, &DataKey::PausedAt); - if let Some(paused_at) = paused_at { - let duration = e.ledger().timestamp().saturating_sub(paused_at); - if duration > 0 { - if let Some(mut p) = proposal(e) { - p.eta = p.eta.saturating_add(duration); - storage::set_instance(e, &DataKey::UpgradeProposal, &p); - } - } - e.storage().instance().remove(&DataKey::PausedAt); - } -} - -pub(crate) fn propose_wasm(e: &Env, wasm_hash: BytesN<32>) { - let eta = queue(e, UpgradeAction::Wasm(wasm_hash)); - UpgradeProposed { eta }.publish(e); -} - -pub(crate) fn propose_delay(e: &Env, secs: u64) { - check_delay(e, secs); - let eta = queue(e, UpgradeAction::Delay(secs)); - UpgradeDelayProposed { secs, eta }.publish(e); -} - -pub(crate) fn cancel(e: &Env) { - if proposal(e).is_none() { - panic_with_error!(e, VaultError::UpgradeProposalNotFound); - } - e.storage().instance().remove(&DataKey::UpgradeProposal); - UpgradeCancelled {}.publish(e); -} - -pub(crate) fn apply(e: &Env) { - if stellar_contract_utils::pausable::paused(e) { - panic_with_error!( - e, - stellar_contract_utils::pausable::PausableError::EnforcedPause - ); - } - - let p = - proposal(e).unwrap_or_else(|| panic_with_error!(e, VaultError::UpgradeProposalNotFound)); - - if e.ledger().timestamp() < p.eta { - panic_with_error!(e, VaultError::UpgradeDelayNotElapsed); - } - - match &p.action { - UpgradeAction::Delay(secs) => { - // The notice may have moved since the proposal; this check decides. - check_delay(e, *secs); - storage::set_instance(e, &DataKey::UpgradeDelay, secs); - e.storage().instance().remove(&DataKey::UpgradeProposal); - UpgradeDelaySet { secs: *secs }.publish(e); - } - UpgradeAction::Wasm(hash) => { - e.storage().instance().remove(&DataKey::UpgradeProposal); - stellar_contract_utils::upgradeable::upgrade(e, hash); - Upgraded { - schema_version: stellar_contract_utils::upgradeable::get_schema_version(e), - } - .publish(e); - } - } +pub(crate) fn set_delay(e: &Env, secs: u64) { + strata_storage::set_instance(e, &DataKey::UpgradeDelay, &secs); } diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 7a22e250..6a8ca0db 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -1,19 +1,29 @@ -use bindings::ShareClient; use soroban_sdk::{contracttype, panic_with_error, token::TokenClient, Address, Env}; -use stellar_contract_utils::math::{i128_fixed_point::checked_mul_div_floor, wad::WAD_SCALE}; +use stellar_contract_utils::math::i128_fixed_point::checked_mul_div_floor; +use stellar_tokens::{fungible::FungibleTokenClient, rwa::RWATokenClient}; use crate::error::VaultError; use crate::event::{ WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; +use crate::guards; use crate::keys::DataKey; +use crate::lock_policy::{self, Exit, Standing}; use crate::state; use crate::treasury; /// Ninety days. Long enough for a real announcement period, short enough that /// governance cannot set a delay the vault would never outlive. -pub const MAX_WIND_DOWN_DELAY: u64 = 90 * 24 * 60 * 60; +pub const MAX_WIND_DOWN_DELAY: u64 = 90 * strata_timelock::DAY_DURATION; + +/// Seven days: holders always get a week's warning before a wind-down starts. +/// It is also the delay when none was set. +#[cfg(not(feature = "short_wind_down"))] +pub const MIN_WIND_DOWN_DELAY: u64 = 7 * strata_timelock::DAY_DURATION; +/// The local harness build activates a wind-down at once. +#[cfg(feature = "short_wind_down")] +pub const MIN_WIND_DOWN_DELAY: u64 = 0; #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -41,19 +51,16 @@ pub struct WindDownPosition { } pub(crate) fn delay(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(MIN_WIND_DOWN_DELAY) } pub(crate) fn set_delay(e: &Env, secs: u64) { - if secs > MAX_WIND_DOWN_DELAY { - panic_with_error!(e, VaultError::WindDownDelayTooLong); - } - storage::set_instance(e, &DataKey::WindDownDelay, &secs); + strata_storage::set_instance(e, &DataKey::WindDownDelay, &secs); WindDownDelaySet { secs }.publish(e); } pub(crate) fn info(e: &Env) -> Option { - storage::get_instance(e, &DataKey::WindDown) + strata_storage::get_instance(e, &DataKey::WindDown) } pub(crate) fn is_active(e: &Env) -> bool { @@ -70,28 +77,12 @@ pub(crate) fn is_active(e: &Env) -> bool { } } -/// Called at the top of every entrypoint the wind-down closes. -pub(crate) fn refuse_if_active(e: &Env) { - if is_active(e) { - panic_with_error!(e, VaultError::WindDownActive); - } -} - pub(crate) fn propose(e: &Env) { - match info(e) { - Some(WindDownInfo { - status: WindDownStatus::Active, - .. - }) => panic_with_error!(e, VaultError::WindDownActive), - Some(WindDownInfo { - status: WindDownStatus::Proposed, - .. - }) => panic_with_error!(e, VaultError::WindDownAlreadyProposed), - None => {} - } + guards::not_winding_down(e); + guards::no_wind_down_proposed(e); let active_at = e.ledger().timestamp().saturating_add(delay(e)); - storage::set_instance( + strata_storage::set_instance( e, &DataKey::WindDown, &WindDownInfo { @@ -105,122 +96,110 @@ pub(crate) fn propose(e: &Env) { } pub(crate) fn cancel_proposal(e: &Env) { - match info(e) { - Some(WindDownInfo { - status: WindDownStatus::Proposed, - .. - }) => {} - Some(WindDownInfo { - status: WindDownStatus::Active, - .. - }) => panic_with_error!(e, VaultError::WindDownActive), - None => panic_with_error!(e, VaultError::WindDownNotProposed), - } + guards::not_winding_down(e); + guards::wind_down_proposed(e); e.storage().instance().remove(&DataKey::WindDown); WindDownProposalCancelled {}.publish(e); } pub(crate) fn activate(e: &Env) { + guards::not_winding_down(e); let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotProposed)); - - if wd.status == WindDownStatus::Active { - panic_with_error!(e, VaultError::WindDownActive); - } - if e.ledger().timestamp() < wd.active_at { - panic_with_error!(e, VaultError::WindDownDelayNotElapsed); - } + guards::wind_down_delay_elapsed(e, &wd); wd.status = WindDownStatus::Active; - storage::set_instance(e, &DataKey::WindDown, &wd); + strata_storage::set_instance(e, &DataKey::WindDown, &wd); WindDownActivated {}.publish(e); } pub(crate) fn owed(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::WindDownOwed).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::WindDownOwed).unwrap_or(0) } -pub(crate) fn acc(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::WindDownAcc).unwrap_or(0) +pub(crate) fn distributed(e: &Env) -> i128 { + strata_storage::get_instance(e, &DataKey::WindDownDistributed).unwrap_or(0) } pub(crate) fn supply_snapshot(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::WindDownSupply).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::WindDownSupply).unwrap_or(0) +} + +fn credited_before_snapshot(e: &Env) -> i128 { + strata_storage::get_instance(e, &DataKey::WindDownCredited).unwrap_or(0) } /// Shares that exist or are owed: circulating, escrowed against an unpriced -/// redemption, and owed by a deposit that has been priced but not claimed. +/// redemption, owed by a deposit that has been priced but not claimed, and +/// credited straight to a holder's entitlement. fn snapshot_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); - ShareClient::new(e, &share_token) - .total_supply() - .saturating_sub(state::pending_burn_shares(e)) + let total_supply = FungibleTokenClient::new(e, &share_token).total_supply(); + state::reduce(e, total_supply, state::pending_burn_shares(e)) .checked_add(state::pending_mint_shares(e)) + .and_then(|supply| supply.checked_add(credited_before_snapshot(e))) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) } +/// Shares credited before the first round are counted into its snapshot, having left the supply. +pub(crate) fn credit(e: &Env, holder: &Address, shares: i128) { + let mut p = position(e, holder); + p.entitlement = p + .entitlement + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + strata_storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); + + if info(e).map(|wd| wd.round).unwrap_or(0) == 0 { + let credited = credited_before_snapshot(e) + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + strata_storage::set_instance(e, &DataKey::WindDownCredited, &credited); + } +} + pub(crate) fn finalize_round(e: &Env) -> i128 { let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotActive)); - if wd.status != WindDownStatus::Active { - panic_with_error!(e, VaultError::WindDownNotActive); - } + guards::winding_down(e); let snapshot = if wd.round == 0 { let s = snapshot_supply(e); - storage::set_instance(e, &DataKey::WindDownSupply, &s); + strata_storage::set_instance(e, &DataKey::WindDownSupply, &s); s } else { supply_snapshot(e) }; - - if snapshot <= 0 { - panic_with_error!(e, VaultError::NothingToDistribute); - } + guards::something_to_distribute(e, snapshot); let pot = treasury::free_reserve(e); - let delta = checked_mul_div_floor(e, &pot, &WAD_SCALE, &snapshot) - .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - if delta <= 0 { - panic_with_error!(e, VaultError::NothingToDistribute); - } + guards::something_to_distribute(e, pot); - let acc_before = acc(e); - let acc_per_share = acc_before - .checked_add(delta) + let distributed = distributed(e) + .checked_add(pot) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - - // The most every holder together can ever claim is floor(supply * acc / WAD). - // Crediting the growth of that figure, rather than this round's own floor, - // keeps owed at or above every payout the accumulator can produce. - let promised = |acc_value: &i128| { - checked_mul_div_floor(e, &snapshot, acc_value, &WAD_SCALE) - .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) - }; - let credited = promised(&acc_per_share) - promised(&acc_before); - let owed_total = owed(e) - .checked_add(credited) + .checked_add(pot) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - storage::set_instance(e, &DataKey::WindDownAcc, &acc_per_share); - storage::set_instance(e, &DataKey::WindDownOwed, &owed_total); + strata_storage::set_instance(e, &DataKey::WindDownDistributed, &distributed); + strata_storage::set_instance(e, &DataKey::WindDownOwed, &owed_total); wd.round += 1; - storage::set_instance(e, &DataKey::WindDown, &wd); + strata_storage::set_instance(e, &DataKey::WindDown, &wd); WindDownRoundFinalized { round: wd.round, pot, - credited, - acc_per_share, + credited: pot, + distributed, } .publish(e); - credited + pot } pub(crate) fn position(e: &Env, holder: &Address) -> WindDownPosition { - storage::get_persistent(e, &DataKey::WindDownPosition(holder.clone())).unwrap_or( + strata_storage::get_persistent(e, &DataKey::WindDownPosition(holder.clone())).unwrap_or( WindDownPosition { entitlement: 0, paid: 0, @@ -228,59 +207,66 @@ pub(crate) fn position(e: &Env, holder: &Address) -> WindDownPosition { ) } -/// What this holder would be paid if they claimed now, surrendering whatever -/// they hold. +/// What this holder would be paid if they claimed now, surrendering the free +/// shares they hold. Zero for a fully frozen holder, whom `claim` refuses. pub(crate) fn claimable(e: &Env, holder: &Address) -> i128 { if info(e).map(|wd| wd.round).unwrap_or(0) == 0 { return 0; } - - let share_token = state::get_addr(e, &DataKey::ShareToken); - let held = ShareClient::new(e, &share_token).balance(holder); - let p = position(e, holder); - let entitlement = p.entitlement.saturating_add(held); - - checked_mul_div_floor(e, &entitlement, &acc(e), &WAD_SCALE) - .map(|earned| earned - p.paid) - .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) -} - -pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { - holder.require_auth(); - - let round = info(e).map(|wd| wd.round).unwrap_or(0); - if round == 0 { - panic_with_error!(e, VaultError::DistributionNotStarted); + if lock_policy::standing(e, holder) == Standing::Frozen { + return 0; + } + if *holder == e.current_contract_address() { + return 0; } - let vault = e.current_contract_address(); - let share_token = state::get_addr(e, &DataKey::ShareToken); - let share = ShareClient::new(e, &share_token); + settle(e, holder, lock_policy::free_shares(e, holder)).1 +} - let surrendered = share.balance(holder); +/// The holder's position once `surrendered` joins their entitlement, and the +/// asset base units it pays now. +fn settle(e: &Env, holder: &Address, surrendered: i128) -> (WindDownPosition, i128) { let mut p = position(e, holder); p.entitlement = p .entitlement .checked_add(surrendered) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - let earned = checked_mul_div_floor(e, &p.entitlement, &acc(e), &WAD_SCALE) + let earned = checked_mul_div_floor(e, &p.entitlement, &distributed(e), &supply_snapshot(e)) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - let assets = earned - p.paid; + let assets = state::reduce(e, earned, p.paid); + p.paid = earned; - if surrendered == 0 && assets == 0 { - panic_with_error!(e, VaultError::NoEntitlement); - } + (p, assets) +} - p.paid = earned; - storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); +pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { + holder.require_auth(); + guards::distribution_started(e); + guards::allowed_to(e, Exit::ClaimWindDown, holder); + guards::holder_not_the_vault(e, holder); + + let vault = e.current_contract_address(); + let share_token = state::get_addr(e, &DataKey::ShareToken); + + // A partial freeze keeps its shares out of the surrender; they earn every + // round regardless, and are paid once unfrozen and claimed again. + let surrendered = lock_policy::free_shares(e, holder); + let (p, assets) = settle(e, holder, surrendered); + guards::has_entitlement(e, surrendered, assets); + + strata_storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); if surrendered > 0 { - share.burn(holder, &surrendered, &vault); + RWATokenClient::new(e, &share_token).burn(holder, &surrendered, &vault); } if assets > 0 { - storage::set_instance(e, &DataKey::WindDownOwed, &(owed(e) - assets)); + strata_storage::set_instance( + e, + &DataKey::WindDownOwed, + &state::reduce(e, owed(e), assets), + ); let asset = state::get_addr(e, &DataKey::Asset); TokenClient::new(e, &asset).transfer(&vault, holder, &assets); } diff --git a/contracts/compliance/Cargo.toml b/contracts/compliance/Cargo.toml index 203d3b86..aa79d1b2 100644 --- a/contracts/compliance/Cargo.toml +++ b/contracts/compliance/Cargo.toml @@ -14,3 +14,9 @@ soroban-sdk = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } +strata-storage = { path = "../../crates/strata-storage" } +strata-timelock = { path = "../../crates/strata-timelock" } +strata-bindings = { path = "../../crates/strata-bindings" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/compliance/src/extend.rs b/contracts/compliance/src/extend.rs new file mode 100644 index 00000000..c12778d7 --- /dev/null +++ b/contracts/compliance/src/extend.rs @@ -0,0 +1,13 @@ +//! OpenZeppelin extends an entry when it reads it, not when it creates it. + +use soroban_sdk::Env; +use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; + +/// Reads the token count and every bucket, the one a binding wrote included. +pub(crate) fn bound_tokens(e: &Env) { + token_binder::linked_tokens(e); +} + +pub(crate) fn modules(e: &Env, hook: &ComplianceHook) { + compliance::storage::get_modules_for_hook(e, hook.clone()); +} diff --git a/contracts/compliance/src/governance.rs b/contracts/compliance/src/governance.rs new file mode 100644 index 00000000..85e48f70 --- /dev/null +++ b/contracts/compliance/src/governance.rs @@ -0,0 +1,153 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, Env, Vec}; +use stellar_access::{ + access_control::{self, AccessControlError, AccessControlStorageKey}, + role_transfer, +}; +use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; +use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; + +use crate::extend; +use crate::guards; +use crate::{ + ChangeApplied, ChangeCancelled, ChangeProposed, Compliance, ComplianceContractError, + SetupFinished, VaultBound, +}; + +/// Seven days of five-second ledgers: how long the next admin has to accept a +/// hand-over. +pub const ADMIN_ACCEPT_LEDGERS: u32 = 7 * 17_280; + +/// A privileged change that waits for the vault's timelock before it lands. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ComplianceChange { + AddModule(ComplianceHook, Address), + RemoveModule(ComplianceHook, Address), + BindToken(Address), + UnbindToken(Address), + Admin(Address), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompliancePendingChange { + pub id: u32, + pub change: ComplianceChange, + pub eta: u64, +} + +/// The compliance contract keeps no clock of its own: it reads the vault's. +impl TimelockHost for Compliance { + fn clock(e: &Env) -> Result { + strata_timelock::clock::from_source(e) + } +} + +fn refuse(e: &Env, refusal: Refusal) -> ! { + match refusal { + Refusal::WindingDown => panic_with_error!(e, ComplianceContractError::WindDownActive), + Refusal::Paused => panic_with_error!(e, ComplianceContractError::VaultPaused), + Refusal::NotElapsed => panic_with_error!(e, ComplianceContractError::ChangeNotReady), + Refusal::BelowExitWindow => { + panic_with_error!(e, ComplianceContractError::DelayBelowNotice) + } + Refusal::NotFound => panic_with_error!(e, ComplianceContractError::ChangeNotFound), + Refusal::TooMany => panic_with_error!(e, ComplianceContractError::TooManyPendingChanges), + Refusal::SetupOpen => panic_with_error!(e, ComplianceContractError::SetupOpen), + Refusal::SetupClosed | Refusal::AlreadyBound => { + panic_with_error!(e, ComplianceContractError::SetupClosed) + } + Refusal::Unbound => panic_with_error!(e, ComplianceContractError::VaultNotBound), + } +} + +fn vault_clock(e: &Env) -> Clock { + ::clock(e).unwrap_or_else(|r| refuse(e, r)) +} + +pub(crate) fn bind_vault(e: &Env, vault: &Address) { + setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); + VaultBound { + vault: vault.clone(), + } + .publish(e); +} + +pub(crate) fn finish_setup(e: &Env) { + guards::setup_open(e); + guards::no_hand_over_pending(e); + setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); + SetupFinished {}.publish(e); +} + +pub(crate) fn propose(e: &Env, change: ComplianceChange) -> u32 { + let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); + let (_, schedule) = + queue::pending::(e, id).unwrap_or_else(|| refuse(e, Refusal::NotFound)); + // No pause has run since the proposal, so the eta needs no second clock read. + ChangeProposed { + id, + change, + eta: schedule.proposed_at.saturating_add(schedule.delay), + } + .publish(e); + id +} + +pub(crate) fn cancel(e: &Env, id: u32) { + queue::cancel(e, id).unwrap_or_else(|r| refuse(e, r)); + ChangeCancelled { id }.publish(e); +} + +pub(crate) fn apply(e: &Env, id: u32) { + let change: ComplianceChange = + queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + match &change { + ComplianceChange::AddModule(hook, module) => { + compliance::storage::add_module_to(e, hook.clone(), module.clone()); + extend::modules(e, hook); + } + ComplianceChange::RemoveModule(hook, module) => { + compliance::storage::remove_module_from(e, hook.clone(), module.clone()) + } + ComplianceChange::BindToken(token) => { + token_binder::bind_token(e, token); + extend::bound_tokens(e); + } + ComplianceChange::UnbindToken(token) => token_binder::unbind_token(e, token), + ComplianceChange::Admin(next) => offer_admin(e, next), + } + ChangeApplied { id, change }.publish(e); +} + +/// The first step of the two-step hand-over, without the admin authorisation +/// `transfer_admin_role` would ask for a second time inside `apply_change`. +fn offer_admin(e: &Env, next: &Address) { + let admin = access_control::get_admin(e) + .unwrap_or_else(|| panic_with_error!(e, AccessControlError::AdminNotSet)); + let live_until = e.ledger().sequence().saturating_add(ADMIN_ACCEPT_LEDGERS); + role_transfer::transfer_role(e, next, &AccessControlStorageKey::PendingAdmin, live_until); + access_control::emit_admin_transfer_initiated(e, &admin, next, live_until); +} + +/// Every change still waiting, with the moment it may land on the vault's +/// clock as it reads now. +pub(crate) fn pending(e: &Env) -> Vec { + let mut out = Vec::new(e); + let open = queue::open_ids(e); + if open.is_empty() { + // Skips the vault, which is not bound yet during setup. + return out; + } + let clock = vault_clock(e); + for id in open.iter() { + if let Some((change, schedule)) = queue::pending::(e, id) { + out.push_back(CompliancePendingChange { + id, + change, + eta: schedule.eta(&clock), + }); + } + } + out +} diff --git a/contracts/compliance/src/guards.rs b/contracts/compliance/src/guards.rs new file mode 100644 index 00000000..edb7352e --- /dev/null +++ b/contracts/compliance/src/guards.rs @@ -0,0 +1,40 @@ +use soroban_sdk::{panic_with_error, Env}; +use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; +use strata_timelock::{setup, TimelockHost}; + +use crate::{Compliance, ComplianceContractError}; + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ComplianceContractError::SetupClosed); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, ComplianceContractError::AdminTransferPending); + } +} + +/// Passes before a vault is bound: there is no wind-down to read yet. +pub(crate) fn vault_not_winding_down(e: &Env) { + if ::clock(e).is_ok_and(|clock| clock.winding_down) { + panic_with_error!(e, ComplianceContractError::WindDownActive); + } +} + +/// Direct rule changes are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ComplianceContractError::UseTimelock); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 5946ddf3..75e3561f 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -1,8 +1,33 @@ +//! # Design +//! //! Testnet-only stub — in production this is an external modular-compliance //! module (ERC-3643), not part of this product. Do not deploy to mainnet. +//! +//! - **No rule of its own.** With no module registered every check passes; the +//! allowlist lives in the identity verifier. The cost: any other rule needs a +//! module or another compliance contract. +//! - **Changes wait for the vault's timelock.** After setup, module, binding +//! and admin changes wait, and a wind-down refuses them. The cost: a faulty +//! module takes a full delay to remove, and stays in a wind-down. +//! - **An unbound token fails.** The hooks the token calls after each mint, +//! transfer and burn refuse it when it is not bound. The cost: unbinding it +//! stops all three, the vault's claims included. +//! - **The operator is not read.** OpenZeppelin's module and binding calls take +//! an `operator`; the stub asks the admin to sign instead. The cost: the +//! admin cannot delegate those calls. +//! - **No upgrade.** The stub has no upgrade path. The cost: a fix means a new +//! deployment and a token `Compliance` change, which waits for the vault's +//! timelock after setup. + #![no_std] -use soroban_sdk::{contract, contractimpl, contractmeta, Address, Env, Vec}; +mod extend; +mod governance; +mod guards; + +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contractmeta, Address, Env, Vec, +}; contractmeta!( key = "stub", @@ -11,39 +36,532 @@ contractmeta!( use stellar_access::access_control; use stellar_macros::only_admin; use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; +use strata_bindings::VaultBinding; + +pub use governance::{ComplianceChange, CompliancePendingChange, ADMIN_ACCEPT_LEDGERS}; + +strata_bindings::error_enum! { + strata_bindings::COMPLIANCE_ERRORS, + + /// Compliance contract failures, coded 8000 to 8999. Codes are stable once + /// assigned and are never reused; a removed variant leaves its number + /// retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum ComplianceContractError { + /// No pending change has this id. + ChangeNotFound = 8001, + /// The change's delay has not elapsed. + ChangeNotReady = 8002, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 8003, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 8004, + /// A direct call cannot make this change after setup: it goes through + /// `propose_change`. + UseTimelock = 8005, + /// Only possible while setup is open, and only once. + SetupClosed = 8006, + /// Setup cannot finish before the contract is bound to its vault. + VaultNotBound = 8007, + /// The vault is winding down, so no change may be proposed or applied, + /// and no admin hand-over is accepted. + WindDownActive = 8008, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 8009, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 8010, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 8011, + } +} + +#[contractevent] +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: ComplianceChange, + pub eta: u64, +} + +#[contractevent] +pub struct ChangeCancelled { + #[topic] + pub id: u32, +} + +#[contractevent] +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: ComplianceChange, +} + +#[contractevent] +pub struct SetupFinished {} + +#[contractevent] +pub struct VaultBound { + #[topic] + pub vault: Address, +} #[contract] pub struct Compliance; #[contractimpl] impl Compliance { + /// Creates the contract with its admin. + /// + /// # Arguments + /// + /// * `admin` - Changes the modules and the bound tokens. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); + strata_storage::bump_instance(e); + } + + /// Names the vault whose timelock this contract's changes wait for. Once, + /// and only during setup. + /// + /// # Arguments + /// + /// * `vault` - The vault. A contract that does not answer + /// `timelock_clock` fails the call. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has finished, or a vault is already bound. + /// + /// # Events + /// + /// * `VaultBound` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn bind_vault(e: &Env, vault: Address) { + strata_storage::bump_instance(e); + governance::bind_vault(e, &vault); + } + + /// Ends the window in which module and binding changes are instant. It + /// never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `VaultNotBound` - No vault is bound. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn finish_setup(e: &Env) { + strata_storage::bump_instance(e); + governance::finish_setup(e); + } + + /// Queues a change behind the vault's timelock and returns its id. + /// + /// # Arguments + /// + /// * `change` - A module to add or remove, a token to bind or unbind, or + /// an admin hand-over. + /// + /// # Errors + /// + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `WindDownActive` - The vault is winding down. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds; time the vault spends + /// paused from now on moves it later. + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn propose_change(e: &Env, change: ComplianceChange) -> u32 { + strata_storage::bump_instance(e); + governance::propose(e, change) + } + + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn cancel_change(e: &Env, id: u32) { + strata_storage::bump_instance(e); + governance::cancel(e, id); + } + + /// Lands a change whose delay has run. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `VaultPaused` - The vault is paused. + /// * `DelayBelowNotice` - The change's delay is shorter than the vault's + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time the vault spent paused + /// since, has not run. + /// * `ComplianceError` or `TokenBinderError` - The module or token change + /// is refused as `add_module_to`, `remove_module_from`, `bind_token` or + /// `unbind_token` would refuse it. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `ModuleAdded`, + /// `ModuleRemoved`, `TokenBound`, `TokenUnbound` or + /// `AdminTransferInitiated`. + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn apply_change(e: &Env, id: u32) { + strata_storage::bump_instance(e); + governance::apply(e, id); + } + + /// Every change still waiting, with the time in seconds it may land on + /// the vault's clock as it reads now. + pub fn pending_changes(e: &Env) -> Vec { + governance::pending(e) + } + + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. + pub fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { + guards::direct_hand_over_allowed(e, live_until_ledger); + strata_storage::bump_instance(e); + access_control::transfer_admin_role(e, &new_admin, live_until_ledger); + } + + /// Makes the nominee of a pending hand-over the admin. Refused while the + /// vault winds down: no admin power changes hands then. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `RoleTransferError::NoPendingTransfer` - No offer is pending. + /// * `RoleTransferError::TransferExpired` - The offer's last ledger has + /// passed. + /// + /// # Events + /// + /// * `AdminTransferCompleted` + /// + /// # Authorization + /// + /// The nominee signs. + pub fn accept_admin_transfer(e: &Env) { + guards::vault_not_winding_down(e); + strata_storage::bump_instance(e); + access_control::accept_admin_transfer(e); + } + + /// The admin. + pub fn get_admin(e: &Env) -> Option
      { + access_control::get_admin(e) + } +} + +#[contractimpl(contracttrait)] +impl VaultBinding for Compliance { + /// The vault whose timelock this contract's changes wait for, once + /// `bind_vault` has named it. + fn vault(e: &Env) -> Option
      { + strata_timelock::setup::source(e) + } + + /// Whether setup is still open, so module and binding changes are + /// instant. + fn setup_open(e: &Env) -> bool { + strata_timelock::setup::is_open(e) } } #[contractimpl(contracttrait)] impl token_binder::TokenBinder for Compliance { + /// Links `token` to these rules. Instant during setup; after it, propose a + /// `BindToken` change. + /// + /// # Arguments + /// + /// * `token` - The share token. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `TokenBinderError::TokenAlreadyBound` - `token` is already bound. + /// * `TokenBinderError::MaxTokensReached` - No room for another token. + /// + /// # Events + /// + /// * `TokenBound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn bind_token(e: &Env, token: Address, _operator: Address) { + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); token_binder::bind_token(e, &token); + extend::bound_tokens(e); } + /// Unlinks `token`, after which its moves fail the hooks. Instant during + /// setup; after it, propose an `UnbindToken` change. + /// + /// # Arguments + /// + /// * `token` - A bound token. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `TokenBinderError::TokenNotFound` - `token` is not bound. + /// + /// # Events + /// + /// * `TokenUnbound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn unbind_token(e: &Env, token: Address, _operator: Address) { + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); token_binder::unbind_token(e, &token); } } +/// The token calls the check before and the hook after every mint, burn and +/// transfer, which is what keeps this instance alive. #[contractimpl(contracttrait)] impl compliance::Compliance for Compliance { + /// Registers `module` on `hook`. Instant during setup; after it, propose + /// an `AddModule` change. + /// + /// # Arguments + /// + /// * `hook` - The check or the hook the module runs on. + /// * `module` - The module contract. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `ComplianceError::ModuleAlreadyRegistered` - `module` is already on + /// `hook`. + /// * `ComplianceError::ModuleBoundExceeded` - `hook` has no room left. + /// + /// # Events + /// + /// * `ModuleAdded` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { - compliance::storage::add_module_to(e, hook, module); + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); + compliance::storage::add_module_to(e, hook.clone(), module); + extend::modules(e, &hook); } + /// Removes `module` from `hook`. Instant during setup; after it, propose a + /// `RemoveModule` change. + /// + /// # Arguments + /// + /// * `hook` - The check or the hook the module runs on. + /// * `module` - The module contract. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `ComplianceError::ModuleNotRegistered` - `module` is not on `hook`. + /// + /// # Events + /// + /// * `ModuleRemoved` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); compliance::storage::remove_module_from(e, hook, module); } + + /// Runs the `Transferred` modules after a bound token moves shares. + /// + /// # Arguments + /// + /// * `from`, `to` - The two ends of the move. + /// * `amount` - Share base units moved. + /// * `token` - The calling token. + /// + /// # Errors + /// + /// * `ComplianceError::TokenNotBound` - `token` is not bound here. + /// * A module's own error. + /// + /// # Authorization + /// + /// `token` signs, as the calling contract. + fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + strata_storage::bump_instance(e); + compliance::storage::transferred(e, from, to, amount, token); + } + + /// Runs the `Created` modules after a bound token mints shares. + /// + /// # Arguments + /// + /// * `to` - The account credited. + /// * `amount` - Share base units minted. + /// * `token` - The calling token. + /// + /// # Errors + /// + /// * `ComplianceError::TokenNotBound` - `token` is not bound here. + /// * A module's own error. + /// + /// # Authorization + /// + /// `token` signs, as the calling contract. + fn created(e: &Env, to: Address, amount: i128, token: Address) { + strata_storage::bump_instance(e); + compliance::storage::created(e, to, amount, token); + } + + /// Runs the `Destroyed` modules after a bound token burns shares. + /// + /// # Arguments + /// + /// * `from` - The account debited. + /// * `amount` - Share base units burned. + /// * `token` - The calling token. + /// + /// # Errors + /// + /// * `ComplianceError::TokenNotBound` - `token` is not bound here. + /// * A module's own error. + /// + /// # Authorization + /// + /// `token` signs, as the calling contract. + fn destroyed(e: &Env, from: Address, amount: i128, token: Address) { + strata_storage::bump_instance(e); + compliance::storage::destroyed(e, from, amount, token); + } + + /// Whether every `CanTransfer` module accepts the move; true when none is + /// registered. + /// + /// # Arguments + /// + /// * `from`, `to` - The two ends of the move. + /// * `amount` - Share base units. + /// * `token` - The token asking. + /// + /// # Errors + /// + /// * A module's own error. + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + strata_storage::bump_instance(e); + compliance::storage::can_transfer(e, from, to, amount, token) + } + + /// Whether every `CanCreate` module accepts the mint; true when none is + /// registered. + /// + /// # Arguments + /// + /// * `to` - The account to credit. + /// * `amount` - Share base units. + /// * `token` - The token asking. + /// + /// # Errors + /// + /// * A module's own error. + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + strata_storage::bump_instance(e); + compliance::storage::can_create(e, to, amount, token) + } } + +#[cfg(test)] +mod test; diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs new file mode 100644 index 00000000..12ce17b8 --- /dev/null +++ b/contracts/compliance/src/test.rs @@ -0,0 +1,77 @@ +mod changes; +mod guards; +mod ttl; + +use soroban_sdk::{ + testutils::{storage::Instance as _, Address as _, Ledger as _}, + Address, Env, +}; +use strata_storage::INSTANCE_EXTEND_AMOUNT; + +use crate::{Compliance, ComplianceClient}; + +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +fn instance_ttl(e: &Env, id: &Address) -> u32 { + e.as_contract(id, || e.storage().instance().get_ttl()) +} + +fn age(e: &Env) { + e.ledger() + .with_mut(|l| l.sequence_number += TWO_DAYS_IN_LEDGERS); +} + +/// A compliance contract with one bound token, its instance decayed past the +/// extend threshold. +fn decayed(e: &Env) -> (ComplianceClient<'_>, Address) { + e.mock_all_auths(); + let admin = Address::generate(e); + let token = Address::generate(e); + let client = ComplianceClient::new(e, &e.register(Compliance, (admin.clone(),))); + client.bind_token(&token, &admin); + age(e); + assert!(instance_ttl(e, &client.address) < INSTANCE_EXTEND_AMOUNT); + (client, token) +} + +#[test] +fn construction_writes_the_instance_with_the_full_ttl() { + let e = Env::default(); + let id = e.register(Compliance, (Address::generate(&e),)); + + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_checks_a_token_runs_before_moving_shares_restore_the_instance_ttl() { + let e = Env::default(); + let (client, token) = decayed(&e); + let holder = Address::generate(&e); + let other = Address::generate(&e); + + assert!(client.can_create(&holder, &1, &token)); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); + + age(&e); + assert!(client.can_transfer(&holder, &other, &1, &token)); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl() { + let e = Env::default(); + let (client, token) = decayed(&e); + let holder = Address::generate(&e); + let other = Address::generate(&e); + + client.created(&holder, &1, &token); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); + + age(&e); + client.transferred(&holder, &other, &1, &token); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); + + age(&e); + client.destroyed(&other, &1, &token); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); +} diff --git a/contracts/compliance/src/test/changes.rs b/contracts/compliance/src/test/changes.rs new file mode 100644 index 00000000..e91f1bae --- /dev/null +++ b/contracts/compliance/src/test/changes.rs @@ -0,0 +1,802 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{ + Address as _, AuthorizedFunction, AuthorizedInvocation, Events as _, Ledger as _, MockAuth, + MockAuthInvoke, + }, + vec, Address, Env, Event as _, IntoVal, Symbol, Val, Vec, +}; +use stellar_access::role_transfer::RoleTransferError; +use stellar_tokens::rwa::compliance::ComplianceHook; +use strata_storage::INSTANCE_EXTEND_AMOUNT; +use strata_timelock::{Clock, DAY_DURATION, MIN_DELAY}; + +use super::{age, instance_ttl}; +use crate::{ + ChangeApplied, ChangeProposed, Compliance, ComplianceChange, ComplianceClient, + ComplianceContractError, CompliancePendingChange, ADMIN_ACCEPT_LEDGERS, +}; + +/// Plays the vault: reports whatever clock a test sets, and counts the reads. +#[contract] +struct ClockStub; + +#[contractimpl] +impl ClockStub { + pub fn set(e: Env, clock: Clock) { + e.storage().instance().set(&symbol_short!("clock"), &clock); + } + + pub fn reads(e: Env) -> u32 { + e.storage() + .instance() + .get(&symbol_short!("reads")) + .unwrap_or(0) + } +} + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for ClockStub { + fn timelock_clock(e: &Env) -> Clock { + let reads = Self::reads(e.clone()) + 1; + e.storage().instance().set(&symbol_short!("reads"), &reads); + e.storage().instance().get(&symbol_short!("clock")).unwrap() + } +} + +fn running() -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } +} + +struct Fixture<'a> { + e: Env, + rules: ComplianceClient<'a>, + clock: ClockStubClient<'a>, + admin: Address, +} + +impl Fixture<'_> { + fn propose(&self, change: &ComplianceChange) -> u32 { + self.rules.propose_change(change) + } + + fn land(&self, change: &ComplianceChange) { + let id = self.propose(change); + self.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); + self.rules.apply_change(&id); + } + + /// Signs the next call to the compliance contract as `who` and nothing + /// else, so the call's own authorisation check is what refuses it. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.rules.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + + /// The last call asked `who` alone to sign, once, on exactly this call. + fn signed_once_by(&self, who: &Address, fn_name: &str, args: Vec) { + assert_eq!( + self.e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.rules.address.clone(), + Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); + } + + /// Lets the instance decay past its extend threshold, then checks the call + /// extends it back to the full TTL. + fn restores_the_instance(&self, call: impl FnOnce()) { + age(&self.e); + assert!(instance_ttl(&self.e, &self.rules.address) < INSTANCE_EXTEND_AMOUNT); + call(); + assert_eq!( + instance_ttl(&self.e, &self.rules.address), + INSTANCE_EXTEND_AMOUNT + ); + } +} + +/// A compliance contract in setup, and a vault clock it has not bound yet. +fn unbound<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + let admin = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + let clock = ClockStubClient::new(&e, &e.register(ClockStub, ())); + clock.set(&running()); + Fixture { + e, + rules, + clock, + admin, + } +} + +/// A compliance contract bound to a vault clock, with setup still open. +fn bound<'a>() -> Fixture<'a> { + let f = unbound(); + f.rules.bind_vault(&f.clock.address); + f +} + +fn finished<'a>() -> Fixture<'a> { + let f = bound(); + f.rules.finish_setup(); + f +} + +fn refused( + result: Result, Result>, + expected: ComplianceContractError, +) { + assert_eq!(result, Err(Ok(expected.into()))); +} + +/// Asserts a call failed for lack of a valid signature, or because the callee +/// answers no interface the host can invoke: either way, the host's own +/// refusal of the invocation, not one of the contract's named errors. +fn missing_auth( + result: Result, Result>, +) { + use soroban_sdk::xdr::{ScErrorCode, ScErrorType}; + assert_eq!( + result, + Err(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Context, + ScErrorCode::InvalidAction + ))) + ); +} + +#[test] +fn bindings_are_instant_during_setup() { + let f = bound(); + let token = Address::generate(&f.e); + + f.rules.bind_token(&token, &f.admin); + + assert_eq!(f.rules.linked_tokens(), vec![&f.e, token]); +} + +#[test] +fn setup_cannot_finish_without_a_vault() { + let f = unbound(); + + refused( + f.rules.try_finish_setup(), + ComplianceContractError::VaultNotBound, + ); + assert!(f.rules.setup_open()); +} + +#[test] +fn a_second_vault_or_a_late_vault_is_refused() { + let f = bound(); + refused( + f.rules.try_bind_vault(&Address::generate(&f.e)), + ComplianceContractError::SetupClosed, + ); + + f.rules.finish_setup(); + + refused( + f.rules.try_bind_vault(&Address::generate(&f.e)), + ComplianceContractError::SetupClosed, + ); + refused( + f.rules.try_finish_setup(), + ComplianceContractError::SetupClosed, + ); + assert_eq!(f.rules.vault(), Some(f.clock.address.clone())); +} + +#[test] +fn an_address_that_reports_no_clock_cannot_be_bound() { + let f = unbound(); + + missing_auth(f.rules.try_bind_vault(&Address::generate(&f.e))); + assert_eq!(f.rules.vault(), None); +} + +fn implements_the_vault_view() {} + +#[test] +fn the_contract_answers_the_view_the_vault_reads() { + implements_the_vault_view::(); + let f = unbound(); + let view = strata_bindings::VaultBindingClient::new(&f.e, &f.rules.address); + assert_eq!(view.vault(), None); + assert!(view.setup_open()); + + f.rules.bind_vault(&f.clock.address); + f.rules.finish_setup(); + + assert_eq!(view.vault(), Some(f.clock.address.clone())); + assert!(!view.setup_open()); +} + +#[test] +fn only_the_admin_binds_and_finishes() { + let f = unbound(); + let stranger = Address::generate(&f.e); + f.sign_only_as( + &stranger, + "bind_vault", + (f.clock.address.clone(),).into_val(&f.e), + ); + missing_auth(f.rules.try_bind_vault(&f.clock.address)); + assert_eq!(f.rules.vault(), None); + + let f = bound(); + let stranger = Address::generate(&f.e); + f.sign_only_as(&stranger, "finish_setup", Vec::new(&f.e)); + missing_auth(f.rules.try_finish_setup()); + assert!(f.rules.setup_open()); +} + +#[test] +fn after_setup_rule_changes_wait_for_the_timelock() { + let f = finished(); + let token = Address::generate(&f.e); + let module = Address::generate(&f.e); + + refused( + f.rules.try_bind_token(&token, &f.admin), + ComplianceContractError::UseTimelock, + ); + refused( + f.rules.try_unbind_token(&token, &f.admin), + ComplianceContractError::UseTimelock, + ); + refused( + f.rules + .try_add_module_to(&ComplianceHook::CanTransfer, &module, &f.admin), + ComplianceContractError::UseTimelock, + ); + refused( + f.rules + .try_remove_module_from(&ComplianceHook::CanTransfer, &module, &f.admin), + ComplianceContractError::UseTimelock, + ); +} + +#[test] +fn proposals_wait_until_setup_is_finished() { + let f = bound(); + refused( + f.rules + .try_propose_change(&ComplianceChange::BindToken(Address::generate(&f.e))), + ComplianceContractError::SetupOpen, + ); + assert_eq!(f.rules.pending_changes().len(), 0); +} + +#[test] +fn a_proposed_binding_lands_after_the_delay() { + let f = finished(); + let token = Address::generate(&f.e); + let change = ComplianceChange::BindToken(token.clone()); + + let id = f.rules.propose_change(&change); + + assert_eq!( + f.rules.pending_changes(), + vec![ + &f.e, + CompliancePendingChange { + id, + change, + eta: 10_000 + MIN_DELAY, + } + ] + ); + refused( + f.rules.try_apply_change(&id), + ComplianceContractError::ChangeNotReady, + ); + + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id); + + assert_eq!(f.rules.linked_tokens(), vec![&f.e, token]); + assert_eq!(f.rules.pending_changes().len(), 0); +} + +#[test] +fn every_rule_change_lands_through_the_timelock() { + let f = bound(); + let token = Address::generate(&f.e); + let early = Address::generate(&f.e); + let late = Address::generate(&f.e); + f.rules.bind_token(&token, &f.admin); + f.rules + .add_module_to(&ComplianceHook::CanTransfer, &early, &f.admin); + f.rules.finish_setup(); + + f.land(&ComplianceChange::RemoveModule( + ComplianceHook::CanTransfer, + early.clone(), + )); + f.land(&ComplianceChange::AddModule( + ComplianceHook::CanCreate, + late.clone(), + )); + f.land(&ComplianceChange::UnbindToken(token)); + + assert!(!f + .rules + .is_module_registered(&ComplianceHook::CanTransfer, &early)); + assert!(f + .rules + .is_module_registered(&ComplianceHook::CanCreate, &late)); + assert_eq!(f.rules.linked_tokens().len(), 0); +} + +#[test] +fn nothing_lands_while_the_vault_is_paused_or_winding_down() { + let f = finished(); + let module = Address::generate(&f.e); + let id = f.rules.propose_change(&ComplianceChange::AddModule( + ComplianceHook::CanCreate, + module, + )); + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + + let mut paused = running(); + paused.paused = true; + f.clock.set(&paused); + refused( + f.rules.try_apply_change(&id), + ComplianceContractError::VaultPaused, + ); + + let mut winding = running(); + winding.winding_down = true; + f.clock.set(&winding); + refused( + f.rules.try_apply_change(&id), + ComplianceContractError::WindDownActive, + ); + refused( + f.rules + .try_propose_change(&ComplianceChange::BindToken(Address::generate(&f.e))), + ComplianceContractError::WindDownActive, + ); +} + +#[test] +fn a_vault_pause_pushes_the_change_back() { + let f = finished(); + let id = f.propose(&ComplianceChange::BindToken(Address::generate(&f.e))); + let mut resumed = running(); + resumed.paused_secs = 3 * DAY_DURATION; + f.clock.set(&resumed); + let eta = 10_000 + MIN_DELAY + 3 * DAY_DURATION; + + assert_eq!(f.rules.pending_changes().get(0).unwrap().eta, eta); + f.e.ledger().set_timestamp(eta - 1); + refused( + f.rules.try_apply_change(&id), + ComplianceContractError::ChangeNotReady, + ); + + f.e.ledger().set_timestamp(eta); + f.rules.apply_change(&id); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = finished(); + let id = f.propose(&ComplianceChange::BindToken(Address::generate(&f.e))); + let mut raised = running(); + raised.delay = 30 * DAY_DURATION; + raised.exit_window = 20 * DAY_DURATION; + f.clock.set(&raised); + f.e.ledger().set_timestamp(10_000 + 30 * DAY_DURATION); + + refused( + f.rules.try_apply_change(&id), + ComplianceContractError::DelayBelowNotice, + ); +} + +#[test] +fn a_cancelled_change_never_lands() { + let f = finished(); + let id = f + .rules + .propose_change(&ComplianceChange::BindToken(Address::generate(&f.e))); + + f.rules.cancel_change(&id); + + refused( + f.rules.try_apply_change(&id), + ComplianceContractError::ChangeNotFound, + ); + assert_eq!(f.rules.linked_tokens().len(), 0); +} + +#[test] +fn only_the_admin_proposes_cancels_and_applies() { + let f = finished(); + let stranger = Address::generate(&f.e); + let change = ComplianceChange::BindToken(Address::generate(&f.e)); + let id = f.propose(&change); + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + + f.sign_only_as( + &stranger, + "propose_change", + (change.clone(),).into_val(&f.e), + ); + missing_auth(f.rules.try_propose_change(&change)); + f.sign_only_as(&stranger, "cancel_change", (id,).into_val(&f.e)); + missing_auth(f.rules.try_cancel_change(&id)); + f.sign_only_as(&stranger, "apply_change", (id,).into_val(&f.e)); + missing_auth(f.rules.try_apply_change(&id)); + assert_eq!(f.rules.pending_changes().len(), 1); +} + +#[test] +fn proposing_and_applying_read_the_vault_clock_once() { + let f = finished(); + let before = f.clock.reads(); + + let id = f.propose(&ComplianceChange::BindToken(Address::generate(&f.e))); + assert_eq!(f.clock.reads(), before + 1); + + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id); + assert_eq!(f.clock.reads(), before + 2); +} + +#[test] +fn proposing_and_applying_publish_the_change() { + let f = finished(); + let change = ComplianceChange::BindToken(Address::generate(&f.e)); + + let id = f.propose(&change); + assert_eq!( + f.e.events().all().filter_by_contract(&f.rules.address), + [ChangeProposed { + id, + change: change.clone(), + eta: 10_000 + MIN_DELAY, + } + .to_xdr(&f.e, &f.rules.address)] + ); + + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id); + let events = f.e.events().all().filter_by_contract(&f.rules.address); + assert_eq!( + events.events().last(), + Some(&ChangeApplied { id, change }.to_xdr(&f.e, &f.rules.address)) + ); +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = bound(); + let next = Address::generate(&f.e); + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.rules.try_finish_setup(), + ComplianceContractError::AdminTransferPending, + ); + + f.rules.accept_admin_transfer(); + assert_eq!(f.rules.get_admin(), Some(next.clone())); + f.rules.finish_setup(); + f.signed_once_by(&next, "finish_setup", Vec::new(&f.e)); + assert!(!f.rules.setup_open()); +} + +#[test] +fn only_the_admin_offers_or_cancels_a_hand_over() { + let f = bound(); + let next = Address::generate(&f.e); + let stranger = Address::generate(&f.e); + let live_until = f.e.ledger().sequence() + 100; + + f.sign_only_as( + &stranger, + "transfer_admin_role", + (next.clone(), live_until).into_val(&f.e), + ); + missing_auth(f.rules.try_transfer_admin_role(&next, &live_until)); + f.e.mock_all_auths(); + assert_eq!( + f.rules.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + + f.rules.transfer_admin_role(&next, &live_until); + f.sign_only_as( + &stranger, + "transfer_admin_role", + (next.clone(), 0_u32).into_val(&f.e), + ); + missing_auth(f.rules.try_transfer_admin_role(&next, &0)); + + f.e.mock_all_auths(); + f.rules.accept_admin_transfer(); + assert_eq!(f.rules.get_admin(), Some(next)); +} + +#[test] +fn only_the_nominee_accepts_a_hand_over() { + let f = bound(); + let next = Address::generate(&f.e); + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + for other in [Address::generate(&f.e), f.admin.clone()] { + f.sign_only_as(&other, "accept_admin_transfer", Vec::new(&f.e)); + missing_auth(f.rules.try_accept_admin_transfer()); + } + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); + + f.sign_only_as(&next, "accept_admin_transfer", Vec::new(&f.e)); + f.rules.accept_admin_transfer(); + assert_eq!(f.rules.get_admin(), Some(next)); +} + +#[test] +fn a_cancelled_hand_over_no_longer_holds_setup_open() { + let f = bound(); + let next = Address::generate(&f.e); + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + f.rules.transfer_admin_role(&next, &0); + + f.rules.finish_setup(); + + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn after_setup_the_admin_hands_over_through_the_timelock() { + let f = finished(); + let next = Address::generate(&f.e); + refused( + f.rules + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + ComplianceContractError::UseTimelock, + ); + + let id = f + .rules + .propose_change(&ComplianceChange::Admin(next.clone())); + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id); + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); + + f.rules.accept_admin_transfer(); + + assert_eq!(f.rules.get_admin(), Some(next)); +} + +#[test] +fn a_hand_over_after_setup_is_cancelled_at_once() { + let f = finished(); + let next = Address::generate(&f.e); + f.land(&ComplianceChange::Admin(next.clone())); + + f.rules.transfer_admin_role(&next, &0); + + assert_eq!( + f.rules.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down() { + let f = finished(); + let next = Address::generate(&f.e); + f.land(&ComplianceChange::Admin(next.clone())); + let mut winding = running(); + winding.winding_down = true; + f.clock.set(&winding); + + refused( + f.rules.try_accept_admin_transfer(), + ComplianceContractError::WindDownActive, + ); + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn the_hand_over_offer_is_open_on_its_last_ledger() { + let f = finished(); + let next = Address::generate(&f.e); + f.land(&ComplianceChange::Admin(next.clone())); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.rules.accept_admin_transfer(); + + assert_eq!(f.rules.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = finished(); + f.land(&ComplianceChange::Admin(Address::generate(&f.e))); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1); + + assert_eq!( + f.rules.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn every_admin_call_restores_the_instance_ttl() { + let f = unbound(); + let next = Address::generate(&f.e); + + f.restores_the_instance(|| { + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)) + }); + f.restores_the_instance(|| f.rules.transfer_admin_role(&next, &0)); + f.restores_the_instance(|| f.rules.bind_vault(&f.clock.address)); + f.restores_the_instance(|| f.rules.finish_setup()); + + let change = ComplianceChange::BindToken(Address::generate(&f.e)); + let mut id = 0; + f.restores_the_instance(|| id = f.propose(&change)); + f.restores_the_instance(|| f.rules.cancel_change(&id)); + + let id = f.propose(&ComplianceChange::Admin(next)); + f.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); + f.restores_the_instance(|| f.rules.apply_change(&id)); + f.restores_the_instance(|| f.rules.accept_admin_transfer()); +} + +#[test] +fn each_admin_call_asks_the_admin_for_one_signature() { + let f = unbound(); + let token = Address::generate(&f.e); + let module = Address::generate(&f.e); + + f.rules.bind_token(&token, &f.admin); + f.signed_once_by( + &f.admin, + "bind_token", + (token.clone(), f.admin.clone()).into_val(&f.e), + ); + f.rules.unbind_token(&token, &f.admin); + f.signed_once_by( + &f.admin, + "unbind_token", + (token.clone(), f.admin.clone()).into_val(&f.e), + ); + f.rules + .add_module_to(&ComplianceHook::CanTransfer, &module, &f.admin); + f.signed_once_by( + &f.admin, + "add_module_to", + (ComplianceHook::CanTransfer, module.clone(), f.admin.clone()).into_val(&f.e), + ); + f.rules + .remove_module_from(&ComplianceHook::CanTransfer, &module, &f.admin); + f.signed_once_by( + &f.admin, + "remove_module_from", + (ComplianceHook::CanTransfer, module, f.admin.clone()).into_val(&f.e), + ); + + f.rules.bind_vault(&f.clock.address); + f.signed_once_by( + &f.admin, + "bind_vault", + (f.clock.address.clone(),).into_val(&f.e), + ); + f.rules.finish_setup(); + f.signed_once_by(&f.admin, "finish_setup", Vec::new(&f.e)); + + let change = ComplianceChange::BindToken(token); + let id = f.rules.propose_change(&change); + f.signed_once_by(&f.admin, "propose_change", (change.clone(),).into_val(&f.e)); + f.rules.cancel_change(&id); + f.signed_once_by(&f.admin, "cancel_change", (id,).into_val(&f.e)); + + let id = f.rules.propose_change(&change); + f.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); + f.rules.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); + + let id = f + .rules + .propose_change(&ComplianceChange::Admin(Address::generate(&f.e))); + f.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); + f.rules.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); +} + +#[test] +fn a_hand_over_asks_each_side_for_one_signature() { + let f = bound(); + let next = Address::generate(&f.e); + let live_until = f.e.ledger().sequence() + 100; + + f.rules.transfer_admin_role(&next, &live_until); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (next.clone(), live_until).into_val(&f.e), + ); + f.rules.transfer_admin_role(&next, &0); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (next.clone(), 0_u32).into_val(&f.e), + ); + + f.rules.transfer_admin_role(&next, &live_until); + f.rules.accept_admin_transfer(); + f.signed_once_by(&next, "accept_admin_transfer", Vec::new(&f.e)); +} + +#[test] +fn each_hook_asks_the_bound_token_for_one_signature() { + let f = bound(); + let token = Address::generate(&f.e); + let from = Address::generate(&f.e); + let to = Address::generate(&f.e); + f.rules.bind_token(&token, &f.admin); + + f.rules.created(&to, &10, &token); + f.signed_once_by( + &token, + "created", + (to.clone(), 10_i128, token.clone()).into_val(&f.e), + ); + f.rules.transferred(&to, &from, &4, &token); + f.signed_once_by( + &token, + "transferred", + (to.clone(), from.clone(), 4_i128, token.clone()).into_val(&f.e), + ); + f.rules.destroyed(&from, &4, &token); + f.signed_once_by( + &token, + "destroyed", + (from, 4_i128, token.clone()).into_val(&f.e), + ); +} diff --git a/contracts/compliance/src/test/guards.rs b/contracts/compliance/src/test/guards.rs new file mode 100644 index 00000000..94594eb3 --- /dev/null +++ b/contracts/compliance/src/test/guards.rs @@ -0,0 +1,110 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, Env, InvokeError}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{Compliance, ComplianceClient, ComplianceContractError}; + +/// Plays the vault: the contract binds only to an address that reports a clock. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +struct Fixture<'a> { + e: Env, + rules: ComplianceClient<'a>, + vault: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.rules.bind_vault(&self.vault); + self.rules.finish_setup(); + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin,))); + let vault = e.register(VaultStub, ()); + Fixture { e, rules, vault } +} + +/// Runs `guard` inside the compliance contract, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.rules.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: ComplianceContractError, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let f = setup(); + holds(&f, || guards::setup_open(&f.e)); + + f.finish_setup(); + refuses(&f, ComplianceContractError::SetupClosed, || { + guards::setup_open(&f.e) + }); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, ComplianceContractError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.rules.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let f = setup(); + holds(&f, || guards::direct_change_allowed(&f.e)); + + f.finish_setup(); + refuses(&f, ComplianceContractError::UseTimelock, || { + guards::direct_change_allowed(&f.e) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 100)); + + f.finish_setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, ComplianceContractError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} diff --git a/contracts/compliance/src/test/ttl.rs b/contracts/compliance/src/test/ttl.rs new file mode 100644 index 00000000..704d73d2 --- /dev/null +++ b/contracts/compliance/src/test/ttl.rs @@ -0,0 +1,157 @@ +//! A new bound-token or module entry gets its full TTL in the call that creates it. + +use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::{storage::Persistent as _, Address as _, Ledger as _}, + Address, Env, IntoVal, Val, +}; +use stellar_tokens::rwa::{ + compliance::{storage::ComplianceDataKey, ComplianceHook, COMPLIANCE_EXTEND_AMOUNT}, + utils::token_binder::{BUCKET_SIZE, TOKEN_BINDER_EXTEND_AMOUNT}, +}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::{Compliance, ComplianceChange, ComplianceClient}; + +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; + +/// Plays the vault: its clock lets a timelocked change land. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +/// Mirrors OpenZeppelin's private token-binder key: same variants, same encoding. +#[contracttype] +enum TokenBinderStorageKey { + TokenBucket(u32), + TotalCount, +} + +struct Fixture<'a> { + e: Env, + rules: ComplianceClient<'a>, + admin: Address, +} + +impl Fixture<'_> { + fn persistent_ttl>(&self, key: &K) -> u32 { + self.e.as_contract(&self.rules.address, || { + self.e.storage().persistent().get_ttl(key) + }) + } + + /// Every entry a first binding writes: the token count and the first bucket. + fn bound_token_ttls(&self) -> [u32; 2] { + [ + self.persistent_ttl(&TokenBinderStorageKey::TotalCount), + self.persistent_ttl(&TokenBinderStorageKey::TokenBucket(0)), + ] + } + + fn modules_ttl(&self, hook: ComplianceHook) -> u32 { + self.persistent_ttl(&ComplianceDataKey::HookModules(hook)) + } + + fn land(&self, change: &ComplianceChange) { + let id = self.rules.propose_change(change); + self.e.ledger().with_mut(|l| { + l.timestamp += MIN_DELAY; + l.sequence_number += (MIN_DELAY / 5) as u32; + }); + self.rules.apply_change(&id); + } +} + +/// A compliance contract in setup on a ledger with testnet's minimum lifetime. +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); + let admin = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + Fixture { e, rules, admin } +} + +fn finished<'a>() -> Fixture<'a> { + let f = setup(); + f.rules.bind_vault(&f.e.register(VaultStub, ())); + f.rules.finish_setup(); + f +} + +#[test] +fn a_binding_during_setup_writes_the_bound_tokens_with_the_full_ttl() { + let f = setup(); + + f.rules.bind_token(&Address::generate(&f.e), &f.admin); + + assert_eq!(f.bound_token_ttls(), [TOKEN_BINDER_EXTEND_AMOUNT; 2]); +} + +#[test] +fn a_binding_through_the_timelock_writes_the_bound_tokens_with_the_full_ttl() { + let f = finished(); + + f.land(&ComplianceChange::BindToken(Address::generate(&f.e))); + + assert_eq!(f.bound_token_ttls(), [TOKEN_BINDER_EXTEND_AMOUNT; 2]); +} + +#[test] +fn the_binding_that_opens_a_second_bucket_writes_it_with_the_full_ttl() { + let f = setup(); + + for _ in 0..=BUCKET_SIZE { + f.rules.bind_token(&Address::generate(&f.e), &f.admin); + } + + assert_eq!( + f.persistent_ttl(&TokenBinderStorageKey::TokenBucket(1)), + TOKEN_BINDER_EXTEND_AMOUNT + ); +} + +#[test] +fn a_module_added_during_setup_writes_the_hooks_modules_with_the_full_ttl() { + let f = setup(); + + f.rules.add_module_to( + &ComplianceHook::CanTransfer, + &Address::generate(&f.e), + &f.admin, + ); + + assert_eq!( + f.modules_ttl(ComplianceHook::CanTransfer), + COMPLIANCE_EXTEND_AMOUNT + ); +} + +#[test] +fn a_module_added_through_the_timelock_writes_the_hooks_modules_with_the_full_ttl() { + let f = finished(); + + f.land(&ComplianceChange::AddModule( + ComplianceHook::Created, + Address::generate(&f.e), + )); + + assert_eq!( + f.modules_ttl(ComplianceHook::Created), + COMPLIANCE_EXTEND_AMOUNT + ); +} diff --git a/contracts/identity-verifier/Cargo.toml b/contracts/identity-verifier/Cargo.toml index 86550d5b..7eb6f83c 100644 --- a/contracts/identity-verifier/Cargo.toml +++ b/contracts/identity-verifier/Cargo.toml @@ -14,7 +14,7 @@ soroban-sdk = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } -storage = { path = "../../crates/storage" } +strata-storage = { path = "../../crates/strata-storage" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 5c3ba014..f535b542 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -1,5 +1,24 @@ +//! # Design +//! //! Testnet-only stub — in production this is an external ERC-3643 identity //! registry (a trusted issuer's), not part of this product. Do not deploy to mainnet. +//! +//! - **A yes or no per address.** No claims, topics or issuers, and an address +//! never set is refused. The cost: no expiry and no per-jurisdiction rule; a +//! deployment that needs them brings its own registry. +//! - **Instant edits.** The admin lists and delists at once, with no timelock. +//! The cost: a stolen admin key can list or delist any account at once; a +//! delisted holder still leaves in cash. +//! - **A fixed admin, no upgrade.** The admin account's signers can change in +//! place, but the account cannot. The cost: replacing it, or the code, needs +//! a new verifier and a token change that waits for the timelock. +//! - **No recovery account.** `recovery_target` is always `None`, so +//! `recover_balance` always fails. The cost: a lost wallet's shares move by +//! `legal_transfer`, signed by `compliance` and the token's admin. +//! - **OpenZeppelin's error code.** A refusal is +//! `RWAError::IdentityVerificationFailed` (304); the stub has no error enum. +//! The cost: the code says what failed, not which contract refused. + #![no_std] use soroban_sdk::{ @@ -24,36 +43,88 @@ pub struct IdentityVerifier; #[contractimpl] impl IdentityVerifier { + /// Creates the verifier with its admin. + /// + /// # Arguments + /// + /// * `admin` - Edits the allowlist. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); + strata_storage::bump_instance(e); } /// Adds or removes an account from the allowlist. + /// + /// # Arguments + /// + /// * `account` - The account. + /// * `allowed` - True adds it, false removes it. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn allow(e: &Env, account: Address, allowed: bool, caller: Address) { - caller.require_auth(); - storage::set_persistent(e, &DataKey::Allowed(account), &allowed); + pub fn allow(e: &Env, account: Address, allowed: bool) { + strata_storage::bump_instance(e); + strata_storage::set_persistent(e, &DataKey::Allowed(account), &allowed); } + /// Whether `account` is on the allowlist. + /// + /// # Arguments + /// + /// * `account` - The account. pub fn is_allowed(e: &Env, account: Address) -> bool { - storage::get_persistent(e, &DataKey::Allowed(account)).unwrap_or(false) + strata_storage::bump_instance(e); + strata_storage::get_persistent(e, &DataKey::Allowed(account)).unwrap_or(false) } } #[contractimpl(contracttrait)] impl identity_verification::IdentityVerifier for IdentityVerifier { + /// Succeeds when `account` is on the allowlist. The share token calls this + /// on every mint and transfer, so it is what keeps the instance alive. + /// + /// # Arguments + /// + /// * `account` - The account. + /// + /// # Errors + /// + /// * `RWAError::IdentityVerificationFailed` - `account` is not on the + /// allowlist. fn verify_identity(e: &Env, account: &Address) { + strata_storage::bump_instance(e); let allowed: bool = - storage::get_persistent(e, &DataKey::Allowed(account.clone())).unwrap_or(false); + strata_storage::get_persistent(e, &DataKey::Allowed(account.clone())).unwrap_or(false); if !allowed { panic_with_error!(e, RWAError::IdentityVerificationFailed); } } + /// Always `None`: this stub names no recovery account, so the share + /// token's `recover_balance` fails with `RWAError::IdentityMismatch`. + /// + /// # Arguments + /// + /// * `old_account` - Not read. fn recovery_target(_e: &Env, _old_account: &Address) -> Option
      { None } + /// Does nothing: this stub keeps no claim topics or issuers. + /// + /// # Arguments + /// + /// * `claim_topics_and_issuers`, `operator` - Not read. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn set_claim_topics_and_issuers( e: &Env, diff --git a/contracts/identity-verifier/src/test.rs b/contracts/identity-verifier/src/test.rs index de865bfe..714fae3c 100644 --- a/contracts/identity-verifier/src/test.rs +++ b/contracts/identity-verifier/src/test.rs @@ -1,13 +1,77 @@ +extern crate std; + use super::*; use soroban_sdk::{ - testutils::{storage::Persistent as _, Address as _, Ledger as _}, - Address, Env, + testutils::{ + storage::Instance as _, storage::Persistent as _, Address as _, AuthorizedFunction, + AuthorizedInvocation, Ledger as _, MockAuth, MockAuthInvoke, + }, + Address, Env, IntoVal, Symbol, Val, Vec, }; -use storage::PERSISTENT_EXTEND_AMOUNT; +use strata_storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; const DAY_IN_LEDGERS: u32 = 17_280; use stellar_tokens::rwa::identity_verification::IdentityVerifierClient as IdClient; +/// Signs one `allow` call as `who` and nothing else. +fn sign_only_as(e: &Env, verifier: &Address, who: &Address, args: Vec) { + e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: verifier, + fn_name: "allow", + args, + sub_invokes: &[], + }, + }]); +} + +/// The last call asked `who` alone to sign, once, on exactly this call. +fn signed_once_by(e: &Env, verifier: &Address, who: &Address, fn_name: &str, args: Vec) { + assert_eq!( + e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + verifier.clone(), + Symbol::new(e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); +} + +#[test] +fn each_admin_call_asks_the_admin_for_one_signature() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let investor = Address::generate(&e); + let id = e.register(IdentityVerifier, (admin.clone(),)); + + IdentityVerifierClient::new(&e, &id).allow(&investor, &true); + signed_once_by( + &e, + &id, + &admin, + "allow", + (investor.clone(), true).into_val(&e), + ); + + let topics = Address::generate(&e); + IdClient::new(&e, &id).set_claim_topics_and_issuers(&topics, &admin); + signed_once_by( + &e, + &id, + &admin, + "set_claim_topics_and_issuers", + (topics, admin.clone()).into_val(&e), + ); +} + #[test] fn only_admin_can_allow() { let e = Env::default(); @@ -22,22 +86,23 @@ fn only_admin_can_allow() { assert!(!client.is_allowed(&investor)); // Stranger cannot allow. - assert!(client.try_allow(&investor, &true, &stranger).is_err()); + sign_only_as(&e, &id, &stranger, (investor.clone(), true).into_val(&e)); + assert!(client.try_allow(&investor, &true).is_err()); assert!(!client.is_allowed(&investor)); // Admin allows. e.mock_all_auths(); - client.allow(&investor, &true, &admin); + client.allow(&investor, &true); assert!(client.is_allowed(&investor)); // Stranger cannot disallow. - e.set_auths(&[]); - assert!(client.try_allow(&investor, &false, &stranger).is_err()); + sign_only_as(&e, &id, &stranger, (investor.clone(), false).into_val(&e)); + assert!(client.try_allow(&investor, &false).is_err()); assert!(client.is_allowed(&investor)); // Admin disallows. e.mock_all_auths(); - client.allow(&investor, &false, &admin); + client.allow(&investor, &false); assert!(!client.is_allowed(&investor)); } @@ -56,7 +121,7 @@ fn verify_identity_checks_allowlist() { assert!(id_client.try_verify_identity(&investor).is_err()); // Once allowed, verification succeeds. - client.allow(&investor, &true, &admin); + client.allow(&investor, &true); id_client.verify_identity(&investor); } @@ -80,7 +145,7 @@ fn allowing_writes_the_entry_with_the_full_ttl() { let investor = Address::generate(&e); let id = e.register(IdentityVerifier, (admin.clone(),)); - IdentityVerifierClient::new(&e, &id).allow(&investor, &true, &admin); + IdentityVerifierClient::new(&e, &id).allow(&investor, &true); assert_eq!(allowed_ttl(&e, &id, &investor), PERSISTENT_EXTEND_AMOUNT); } @@ -93,7 +158,7 @@ fn checking_an_allowed_account_restores_the_full_ttl() { let investor = Address::generate(&e); let id = e.register(IdentityVerifier, (admin.clone(),)); let client = IdentityVerifierClient::new(&e, &id); - client.allow(&investor, &true, &admin); + client.allow(&investor, &true); age(&e, 2 * DAY_IN_LEDGERS); assert!(client.is_allowed(&investor)); @@ -103,3 +168,38 @@ fn checking_an_allowed_account_restores_the_full_ttl() { IdClient::new(&e, &id).verify_identity(&investor); assert_eq!(allowed_ttl(&e, &id, &investor), PERSISTENT_EXTEND_AMOUNT); } + +fn instance_ttl(e: &Env, id: &Address) -> u32 { + e.as_contract(id, || e.storage().instance().get_ttl()) +} + +#[test] +fn construction_writes_the_instance_with_the_full_ttl() { + let e = Env::default(); + let id = e.register(IdentityVerifier, (Address::generate(&e),)); + + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn allowing_and_checking_restore_the_instance_ttl() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let investor = Address::generate(&e); + let id = e.register(IdentityVerifier, (admin.clone(),)); + let client = IdentityVerifierClient::new(&e, &id); + + age(&e, 2 * DAY_IN_LEDGERS); + assert!(instance_ttl(&e, &id) < INSTANCE_EXTEND_AMOUNT); + client.allow(&investor, &true); + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); + + age(&e, 2 * DAY_IN_LEDGERS); + IdClient::new(&e, &id).verify_identity(&investor); + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); + + age(&e, 2 * DAY_IN_LEDGERS); + client.is_allowed(&investor); + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); +} diff --git a/contracts/nav-oracle/Cargo.toml b/contracts/nav-oracle/Cargo.toml index 6a092b14..3835900c 100644 --- a/contracts/nav-oracle/Cargo.toml +++ b/contracts/nav-oracle/Cargo.toml @@ -11,10 +11,18 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } -storage = { path = "../../crates/storage" } +strata-storage = { path = "../../crates/strata-storage" } stellar-access = { workspace = true } stellar-macros = { workspace = true } -bindings = { path = "../../crates/bindings" } +strata-bindings = { path = "../../crates/strata-bindings" } +strata-timelock = { path = "../../crates/strata-timelock" } +stellar-contract-utils = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +# Builds a stand-in for a later oracle version, used only by the upgrade test. +upgrade_probe = [] +# Runs the test that upgrades the built oracle to its probe build. Build both first. +upgrade_wasm = [] diff --git a/contracts/nav-oracle/src/extend.rs b/contracts/nav-oracle/src/extend.rs new file mode 100644 index 00000000..2de355a3 --- /dev/null +++ b/contracts/nav-oracle/src/extend.rs @@ -0,0 +1,13 @@ +//! OpenZeppelin extends a role entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env, Symbol}; +use stellar_access::access_control; + +/// The role, its member-list slot, the member count and the role list: all a grant writes. +pub(crate) fn role_entries(e: &Env, account: &Address, role: &Symbol) { + if let Some(index) = access_control::has_role(e, account, role) { + access_control::get_role_member(e, role, index); + } + access_control::get_role_member_count(e, role); + access_control::get_existing_roles(e); +} diff --git a/contracts/nav-oracle/src/governance.rs b/contracts/nav-oracle/src/governance.rs new file mode 100644 index 00000000..2ceab364 --- /dev/null +++ b/contracts/nav-oracle/src/governance.rs @@ -0,0 +1,165 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use stellar_access::{ + access_control::{self, AccessControlStorageKey}, + role_transfer, +}; +use stellar_contract_utils::upgradeable; +use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; + +use crate::extend; +use crate::guards; +use crate::state::OracleConfig; +use crate::{ + store_config, ChangeApplied, ChangeCancelled, ChangeProposed, NavOracleContract, OracleError, + SetupFinished, VaultBound, +}; + +/// Seven days of five-second ledgers: how long the next admin has to accept a +/// hand-over. +pub const ADMIN_ACCEPT_LEDGERS: u32 = 7 * 17_280; + +/// A privileged change that waits for the vault's timelock before it lands. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OracleChange { + Upgrade(BytesN<32>), + GrantRole(Symbol, Address), + RevokeRole(Symbol, Address), + Admin(Address), + Config(OracleConfig), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OraclePendingChange { + pub id: u32, + pub change: OracleChange, + pub eta: u64, +} + +/// True when `next` changes nothing but a shorter `freshness_duration`, or +/// nothing at all. A shorter window only lets reports go stale sooner: pricing +/// waits and cancellation reopens, so no value moves. Any other change can hold +/// the price away from the real value, a tighter cap included, so it waits. +pub fn shortens_freshness_only(current: &OracleConfig, next: &OracleConfig) -> bool { + let rest_unchanged = *next + == OracleConfig { + freshness_duration: next.freshness_duration, + ..current.clone() + }; + rest_unchanged && next.freshness_duration <= current.freshness_duration +} + +/// The oracle keeps no clock of its own: it reads the vault's. +impl TimelockHost for NavOracleContract { + fn clock(e: &Env) -> Result { + strata_timelock::clock::from_source(e) + } +} + +fn refuse(e: &Env, refusal: Refusal) -> ! { + match refusal { + Refusal::WindingDown => panic_with_error!(e, OracleError::WindDownActive), + Refusal::Paused => panic_with_error!(e, OracleError::VaultPaused), + Refusal::NotElapsed => panic_with_error!(e, OracleError::ChangeNotReady), + Refusal::BelowExitWindow => panic_with_error!(e, OracleError::DelayBelowNotice), + Refusal::NotFound => panic_with_error!(e, OracleError::ChangeNotFound), + Refusal::TooMany => panic_with_error!(e, OracleError::TooManyPendingChanges), + Refusal::SetupOpen => panic_with_error!(e, OracleError::SetupOpen), + Refusal::SetupClosed | Refusal::AlreadyBound => { + panic_with_error!(e, OracleError::SetupClosed) + } + Refusal::Unbound => panic_with_error!(e, OracleError::VaultNotBound), + } +} + +fn vault_clock(e: &Env) -> Clock { + ::clock(e).unwrap_or_else(|r| refuse(e, r)) +} + +pub(crate) fn bind_vault(e: &Env, vault: &Address) { + setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); + VaultBound { + vault: vault.clone(), + } + .publish(e); +} + +pub(crate) fn finish_setup(e: &Env) { + guards::setup_open(e); + guards::no_hand_over_pending(e); + setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); + SetupFinished {}.publish(e); +} + +pub(crate) fn propose(e: &Env, change: OracleChange) -> u32 { + guards::change_in_bounds(e, &change); + guards::not_a_second_upgrade(e, &change); + let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); + let (_, schedule) = + queue::pending::(e, id).unwrap_or_else(|| refuse(e, Refusal::NotFound)); + // No pause has run since the proposal, so the eta needs no second clock read. + ChangeProposed { + id, + change, + eta: schedule.proposed_at.saturating_add(schedule.delay), + } + .publish(e); + id +} + +pub(crate) fn cancel(e: &Env, id: u32) { + queue::cancel(e, id).unwrap_or_else(|r| refuse(e, r)); + ChangeCancelled { id }.publish(e); +} + +pub(crate) fn apply(e: &Env, id: u32) { + let change: OracleChange = + queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + let admin = access_control::get_admin(e) + .unwrap_or_else(|| panic_with_error!(e, OracleError::AdminRequired)); + guards::change_in_bounds(e, &change); + match &change { + OracleChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + OracleChange::GrantRole(role, account) => { + access_control::grant_role_no_auth(e, account, role, &admin); + extend::role_entries(e, account, role); + } + OracleChange::RevokeRole(role, account) => { + access_control::revoke_role_no_auth(e, account, role, &admin) + } + OracleChange::Admin(next) => offer_admin(e, &admin, next), + OracleChange::Config(config) => store_config(e, config), + } + ChangeApplied { id, change }.publish(e); +} + +/// The first step of the two-step hand-over, without the admin authorisation +/// `transfer_admin_role` would ask for a second time inside `apply_change`. +fn offer_admin(e: &Env, admin: &Address, next: &Address) { + let live_until = e.ledger().sequence().saturating_add(ADMIN_ACCEPT_LEDGERS); + role_transfer::transfer_role(e, next, &AccessControlStorageKey::PendingAdmin, live_until); + access_control::emit_admin_transfer_initiated(e, admin, next, live_until); +} + +/// Every change still waiting, with the moment it may land on the vault's +/// clock as it reads now. +pub(crate) fn pending(e: &Env) -> Vec { + let mut out = Vec::new(e); + let open = queue::open_ids(e); + if open.is_empty() { + // Skips the vault, which is not bound yet during setup. + return out; + } + let clock = vault_clock(e); + for id in open.iter() { + if let Some((change, schedule)) = queue::pending::(e, id) { + out.push_back(OraclePendingChange { + id, + change, + eta: schedule.eta(&clock), + }); + } + } + out +} diff --git a/contracts/nav-oracle/src/guards.rs b/contracts/nav-oracle/src/guards.rs new file mode 100644 index 00000000..29489794 --- /dev/null +++ b/contracts/nav-oracle/src/guards.rs @@ -0,0 +1,153 @@ +use soroban_sdk::{panic_with_error, BytesN, Env, String}; +use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; +use strata_timelock::{queue, setup, TimelockHost}; + +use crate::state::{self, get_config, NavReport, OracleConfig, OracleState}; +use crate::NavOracleContract; +use crate::{ + compute_state, in_band, shortens_freshness_only, OracleChange, OracleError, BPS_DENOM, + MAX_ANSWER, MAX_FRESHNESS_SECS, MAX_PROOF_URI_LEN, MIN_ANSWER, MIN_COOLDOWN_SECS, +}; + +pub(crate) fn valid_config(e: &Env, cfg: &OracleConfig) { + if cfg.freshness_duration == 0 + || cfg.freshness_duration > MAX_FRESHNESS_SECS + || cfg.cooldown_secs < MIN_COOLDOWN_SECS + || cfg.cooldown_secs > cfg.freshness_duration + || cfg.min_answer < MIN_ANSWER + || cfg.max_answer < cfg.min_answer + || cfg.max_answer > MAX_ANSWER + || !bps_in_range(cfg.max_up_bps) + || !bps_in_range(cfg.max_down_bps) + { + panic_with_error!(e, OracleError::InvalidConfig); + } +} + +fn bps_in_range(bps: u32) -> bool { + bps > 0 && bps as i128 <= BPS_DENOM +} + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, OracleError::SetupClosed); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, OracleError::AdminTransferPending); + } +} + +/// Passes before a vault is bound: there is no wind-down to read yet. +pub(crate) fn vault_not_winding_down(e: &Env) { + if ::clock(e).is_ok_and(|clock| clock.winding_down) { + panic_with_error!(e, OracleError::WindDownActive); + } +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, OracleError::UseTimelock); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} + +pub(crate) fn direct_config_allowed(e: &Env, config: &OracleConfig) { + if !setup::is_open(e) && !shortens_freshness_only(&get_config(e), config) { + panic_with_error!(e, OracleError::UseTimelock); + } +} + +/// The bounds a change must meet when it is proposed and again when it +/// applies. +pub(crate) fn change_in_bounds(e: &Env, change: &OracleChange) { + if let OracleChange::Config(config) = change { + valid_config(e, config); + } +} + +/// One upgrade at a time, so a standing eta is never quietly replaced. +pub(crate) fn not_a_second_upgrade(e: &Env, change: &OracleChange) { + if matches!(change, OracleChange::Upgrade(_)) && upgrade_pending(e) { + panic_with_error!(e, OracleError::UpgradeAlreadyPending); + } +} + +fn upgrade_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id) + .is_some_and(|(change, _)| matches!(change, OracleChange::Upgrade(_))) + }) +} + +pub(crate) fn consumable(e: &Env) { + if compute_state(e) != OracleState::Valid { + panic_with_error!(e, OracleError::NotConsumable); + } +} + +pub(crate) fn nav_not_paused(e: &Env) { + if state::nav_paused(e) { + panic_with_error!(e, OracleError::NavPaused); + } +} + +pub(crate) fn nav_paused(e: &Env) { + if !state::nav_paused(e) { + panic_with_error!(e, OracleError::NavNotPaused); + } +} + +pub(crate) fn nav_in_band(e: &Env, cfg: &OracleConfig, nav_per_share: i128) { + if !in_band(cfg, nav_per_share) { + panic_with_error!(e, OracleError::NavOutOfBand); + } +} + +pub(crate) fn expires_in_future(e: &Env, expires_at: u64) { + if expires_at <= e.ledger().timestamp() { + panic_with_error!(e, OracleError::ExpiresInPast); + } +} + +pub(crate) fn proof_hash_not_empty(e: &Env, proof_hash: &BytesN<32>) { + if proof_hash.to_array() == [0; 32] { + panic_with_error!(e, OracleError::EmptyProofHash); + } +} + +pub(crate) fn proof_uri_fits(e: &Env, proof_uri: &String) { + if proof_uri.len() > MAX_PROOF_URI_LEN { + panic_with_error!(e, OracleError::ProofUriTooLong); + } +} + +pub(crate) fn cooldown_elapsed(e: &Env, cfg: &OracleConfig, last: &NavReport) { + if e.ledger().timestamp() < last.accepted_at.saturating_add(cfg.cooldown_secs) { + panic_with_error!(e, OracleError::CooldownActive); + } +} + +pub(crate) fn within_deviation(e: &Env, cfg: &OracleConfig, last: &NavReport, nav_per_share: i128) { + let bps = if nav_per_share >= last.nav_per_share { + cfg.max_up_bps + } else { + cfg.max_down_bps + }; + let diff = (nav_per_share - last.nav_per_share).abs(); + let bound = last.nav_per_share * (bps as i128) / BPS_DENOM; + if diff > bound { + panic_with_error!(e, OracleError::DeviationExceeded); + } +} diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 60b34847..70f6054e 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,41 +1,151 @@ +//! # Design +//! +//! The feed the vault prices epochs from. An attester reports the share price, +//! and the oracle keeps the latest report that passes the limits the admin +//! sets. +//! +//! - **The ledger dates a report.** It expires at the earlier of `expires_at` +//! and its acceptance plus `freshness_duration`. The cost: the vault compares +//! acceptance times, not valuation dates. +//! - **The proof is anchored, never verified.** A report carries a hash of its +//! evidence and may say where to read it. The cost: any hash that is not all +//! zeros passes, so a reader fetches the evidence and compares the hash. +//! - **No report while the NAV is paused.** The guardian or the admin pauses +//! it; only the admin lifts it. The cost: the vault prices nothing meanwhile, +//! and sealed epochs reopen to cancellation. +//! - **Tightening waits too.** After setup only a shorter `freshness_duration` +//! is instant: a tighter cap or band moves value too. The cost: an urgent +//! limit change waits the full delay. +//! - **The band applies on read.** A stored report outside the band reads as +//! stale. The cost: narrowing the band away from it stops pricing, and new +//! reports far from it fail the cap until `clear_latest`. +//! - **Both moves are capped.** Both caps are required; a 10,000 bps fall cap +//! lets any loss land at once. The cost of a lower one: a loss takes several +//! reports, and exits priced meanwhile are paid above value. +//! - **Revoking is instant, granting waits.** After setup a new attester or +//! guardian waits for the timelock. The cost: replacing a compromised +//! attester keeps the NAV paused for the whole delay. +//! - **Clearing needs a paused NAV.** `clear_latest` runs only while the NAV is +//! paused, so the next report is a separate act. The cost: that report has no +//! earlier price to cap it; only the band bounds it. +//! - **One report per cooldown.** The cooldown runs from one second to +//! `freshness_duration`, so no two reports share a ledger. The cost: an epoch +//! sealed just after a report waits up to a cooldown for the next. + #![no_std] +mod extend; +mod governance; +mod guards; mod state; use soroban_sdk::{ - contract, contracterror, contractevent, contractimpl, panic_with_error, Address, Env, Symbol, - Vec, + contract, contracterror, contractevent, contractimpl, panic_with_error, Address, BytesN, Env, + String, Symbol, Vec, }; use stellar_access::access_control::{self, AccessControl}; +use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_role}; +use strata_bindings::{OracleFeed, VaultBinding}; -use state::{get_config, get_latest, ripcord_raised, ATTESTER_ROLE, GUARDIAN_ROLE}; +use state::{get_config, get_latest, nav_paused, ATTESTER_ROLE, GUARDIAN_ROLE}; -pub use state::{NavReport, OracleConfig, OracleState}; +pub use governance::{ + shortens_freshness_only, OracleChange, OraclePendingChange, ADMIN_ACCEPT_LEDGERS, +}; +pub use state::{NavInput, NavReport, OracleConfig, OracleState}; const BPS_DENOM: i128 = 10_000; -const MAX_ANSWER: i128 = i128::MAX / BPS_DENOM; - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum OracleError { - InvalidConfig = 3001, - /// `nav_per_share <= 0`, or outside `[min_answer, max_answer]`. - NavOutOfBand = 3002, - /// Attested before `cooldown_secs` elapsed since the last record. - CooldownActive = 3003, - /// Per-share move exceeds `max_deviation_bps`. - DeviationExceeded = 3004, - /// `expires_at` not in the future — the record would be born stale. - ExpiresInPast = 3005, - NoRecord = 3006, - /// `ensure_consumable` found the feed in a state other than `Valid`. - NotConsumable = 3007, - /// `clear_latest` was called while the ripcord was down. - RipcordNotRaised = 3008, - /// The oracle must always have an admin. - AdminRequired = 3009, +const WAD: i128 = 1_000_000_000_000_000_000; + +/// One report per ledger at most. A deployment sets a longer cooldown. +pub const MIN_COOLDOWN_SECS: u64 = 1; +/// Thirty-five days: a monthly valuation with slack, and no longer. +pub const MAX_FRESHNESS_SECS: u64 = 35 * strata_timelock::DAY_DURATION; +/// A million units of the asset per share. Far above any real price, and far +/// below where the deviation arithmetic could overflow. +pub const MAX_ANSWER: i128 = 1_000_000 * WAD; +/// A millionth of an asset unit per share, mirroring `MAX_ANSWER`. At it, one +/// asset base unit mints at most a million share base units, and a 1 bps +/// move cap still allows a move. +pub const MIN_ANSWER: i128 = WAD / 1_000_000; +/// Longest proof URI an attestation may carry, in bytes. +pub const MAX_PROOF_URI_LEN: u32 = 256; + +strata_bindings::error_enum! { + strata_bindings::ORACLE_ERRORS, + + /// Oracle failures, coded 3000 to 3999. Codes are stable once assigned and + /// are never reused; a removed variant leaves its number retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum OracleError { + /// A config breaks a bound: `freshness_duration` from 1 s to 35 days, + /// `cooldown_secs` from 1 s to `freshness_duration`, `min_answer` from + /// `MIN_ANSWER` to `max_answer`, `max_answer` at most `MAX_ANSWER`, + /// and each move cap from 1 to 10,000 bps. + InvalidConfig = 3001, + /// `nav_per_share <= 0`, or outside `[min_answer, max_answer]`. + NavOutOfBand = 3002, + /// Attested before `cooldown_secs` elapsed since the last record. + CooldownActive = 3003, + /// The move from the last record exceeds `max_up_bps` (a rise) or + /// `max_down_bps` (a fall). + DeviationExceeded = 3004, + /// `expires_at` not in the future — the record would be born stale. + ExpiresInPast = 3005, + /// No report is stored: none has been accepted yet, or `clear_latest` + /// removed it. `latest`, `nav_per_share` and `attested_at` fail with it. + NoRecord = 3006, + /// `ensure_consumable` found the feed in a state other than `Valid`. + NotConsumable = 3007, + /// `clear_latest` was called while the NAV was not paused. + NavNotPaused = 3008, + /// `renounce_admin` is always refused: the oracle must always have an + /// admin. Hand it over instead: propose and apply an `Admin` change + /// (`transfer_admin_role` during setup), then the nominee calls + /// `accept_admin_transfer`. + AdminRequired = 3009, + /// The NAV is paused. Unpause it first; a report never lifts it. + NavPaused = 3010, + /// No pending change has this id. + ChangeNotFound = 3011, + /// The change's delay has not elapsed. + ChangeNotReady = 3012, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 3013, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 3014, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 3015, + /// Only possible while setup is open, and only once. + SetupClosed = 3016, + /// Setup cannot finish before the oracle is bound to its vault. + VaultNotBound = 3017, + /// The vault is winding down, so no change may be proposed or applied, + /// and no admin hand-over is accepted. + WindDownActive = 3018, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 3019, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 3020, + /// An upgrade already waits. Cancel it before proposing another. + UpgradeAlreadyPending = 3021, + /// The proof hash is all zeros, so it commits to nothing. + EmptyProofHash = 3023, + /// The proof URI is longer than `MAX_PROOF_URI_LEN` bytes. + ProofUriTooLong = 3024, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 3025, + /// The configuration the constructor writes is absent from instance + /// storage. + NotInitialized = 3026, + } } #[contractevent] @@ -44,37 +154,69 @@ pub struct NavAttested { pub attester: Address, pub nav_per_share: i128, pub expires_at: u64, + pub proof_hash: BytesN<32>, + pub proof_uri: String, } #[contractevent] pub struct LatestCleared {} #[contractevent] -pub struct RipcordSet { +pub struct NavPausedSet { pub paused: bool, } +#[contractevent] +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: OracleChange, + pub eta: u64, +} + +#[contractevent] +pub struct ChangeCancelled { + #[topic] + pub id: u32, +} + +#[contractevent] +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: OracleChange, +} + +#[contractevent] +pub struct SetupFinished {} + +#[contractevent] +pub struct VaultBound { + #[topic] + pub vault: Address, +} + #[contractevent] pub struct ConfigSet { pub freshness_duration: u64, pub cooldown_secs: u64, pub max_up_bps: u32, - pub max_down_bps: Option, + pub max_down_bps: u32, pub min_answer: i128, pub max_answer: i128, } -fn validate_config(e: &Env, cfg: &OracleConfig) { - if cfg.freshness_duration == 0 - || cfg.min_answer <= 0 - || cfg.max_answer < cfg.min_answer - || cfg.max_answer > MAX_ANSWER - || cfg.max_up_bps == 0 - || cfg.max_up_bps as i128 > BPS_DENOM - || cfg.max_down_bps.is_some_and(|d| d as i128 > BPS_DENOM) - { - panic_with_error!(e, OracleError::InvalidConfig); +pub(crate) fn store_config(e: &Env, config: &OracleConfig) { + state::set_config(e, config); + ConfigSet { + freshness_duration: config.freshness_duration, + cooldown_secs: config.cooldown_secs, + max_up_bps: config.max_up_bps, + max_down_bps: config.max_down_bps, + min_answer: config.min_answer, + max_answer: config.max_answer, } + .publish(e); } fn in_band(cfg: &OracleConfig, nav_per_share: i128) -> bool { @@ -86,7 +228,7 @@ fn trustworthy(e: &Env, report: &NavReport) -> bool { } fn compute_state(e: &Env) -> OracleState { - if ripcord_raised(e) { + if nav_paused(e) { return OracleState::Paused; } match get_latest(e) { @@ -101,15 +243,152 @@ pub struct NavOracleContract; #[contractimpl(contracttrait)] impl AccessControl for NavOracleContract { + /// Grants `role` to `account` at once. Only during setup; after it, propose + /// a `GrantRole` change. Revoking stays instant: taking an attester or a + /// guardian away moves no value. + /// + /// # Arguments + /// + /// * `account` - The account that gets the role. + /// * `role` - `attester` or `guardian`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// + /// # Events + /// + /// * `RoleGranted`, unless `account` already holds `role`. + /// + /// # Authorization + /// + /// `caller` signs. + fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); + access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); + } + + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. + fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { + guards::direct_hand_over_allowed(e, live_until_ledger); + strata_storage::bump_instance(e); + access_control::transfer_admin_role(e, &new_admin, live_until_ledger); + } + + /// Makes the nominee of a pending hand-over the admin. Refused while the + /// vault winds down: no admin power changes hands then. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `RoleTransferError::NoPendingTransfer` - No offer is pending. + /// * `RoleTransferError::TransferExpired` - The offer's last ledger has + /// passed. + /// + /// # Events + /// + /// * `AdminTransferCompleted` + /// + /// # Authorization + /// + /// The nominee signs. + fn accept_admin_transfer(e: &Env) { + guards::vault_not_winding_down(e); + strata_storage::bump_instance(e); + access_control::accept_admin_transfer(e); + } + + /// Refused. A role admin could grant that role with no delay. + /// + /// # Arguments + /// + /// * `role`, `admin_role` - Not read. + /// + /// # Errors + /// + /// * `UseTimelock` - Always. + fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { + panic_with_error!(e, OracleError::UseTimelock); + } + /// Refused. An oracle with no admin can never be reconfigured, never clear a - /// stuck record and never lower the ripcord again. + /// stuck record and never unpause the NAV again. The way out is a hand-over: + /// propose and apply an `Admin` change (`transfer_admin_role` during setup), + /// then the nominee calls `accept_admin_transfer`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, OracleError::AdminRequired); } } +#[contractimpl(contracttrait)] +impl VaultBinding for NavOracleContract { + /// The vault whose timelock this oracle's changes wait for, once + /// `bind_vault` has named it. + fn vault(e: &Env) -> Option
      { + strata_timelock::setup::source(e) + } + + /// Whether setup is still open, so admin changes are instant. + fn setup_open(e: &Env) -> bool { + strata_timelock::setup::is_open(e) + } +} + #[contractimpl] impl NavOracleContract { + /// Creates the oracle with its admin, its two roles and its limits. + /// + /// # Arguments + /// + /// * `admin` - Reconfigures the oracle and lifts a NAV pause. + /// * `attester` - Gets the `attester` role, which submits reports. + /// * `guardian` - Gets the `guardian` role, which pauses the NAV. + /// * `config` - The limits reports must meet; see `config`. + /// + /// # Errors + /// + /// * `InvalidConfig` - `config` breaks a bound. + /// + /// # Events + /// + /// * `RoleGranted` for `attester` and for `guardian`. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor( e: &Env, admin: Address, @@ -117,27 +396,175 @@ impl NavOracleContract { guardian: Address, config: OracleConfig, ) { - validate_config(e, &config); + guards::valid_config(e, &config); access_control::set_admin(e, &admin); access_control::grant_role_no_auth(e, &attester, &ATTESTER_ROLE, &admin); + extend::role_entries(e, &attester, &ATTESTER_ROLE); access_control::grant_role_no_auth(e, &guardian, &GUARDIAN_ROLE, &admin); + extend::role_entries(e, &guardian, &GUARDIAN_ROLE); state::set_config(e, &config); - state::set_ripcord(e, false); + state::set_nav_paused(e, false); + upgradeable::set_schema_version(e, 1); } - pub fn nav_per_share(e: &Env) -> i128 { - Self::latest(e).nav_per_share + /// The storage layout version, `1` until an upgrade migrates it. + pub fn schema_version(e: &Env) -> u32 { + upgradeable::get_schema_version(e) } - pub fn latest(e: &Env) -> NavReport { - get_latest(e).unwrap_or_else(|| panic_with_error!(e, OracleError::NoRecord)) + /// Names the vault whose timelock this oracle's changes wait for. Once, + /// and only during setup. + /// + /// # Arguments + /// + /// * `vault` - The vault. A contract that does not answer + /// `timelock_clock` fails the call. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has finished, or a vault is already bound. + /// + /// # Events + /// + /// * `VaultBound` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn bind_vault(e: &Env, vault: Address) { + strata_storage::bump_instance(e); + governance::bind_vault(e, &vault); } - pub fn state(e: &Env) -> OracleState { - compute_state(e) + /// Ends the window in which admin changes are instant. It never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `VaultNotBound` - No vault is bound. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn finish_setup(e: &Env) { + strata_storage::bump_instance(e); + governance::finish_setup(e); + } + + /// Queues a change behind the vault's timelock and returns its id. + /// + /// # Arguments + /// + /// * `change` - An upgrade, a role grant or revoke, an admin hand-over or + /// a new config. + /// + /// # Errors + /// + /// * `InvalidConfig` - A `Config` change breaks a bound. + /// * `UpgradeAlreadyPending` - An upgrade already waits. + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `WindDownActive` - The vault is winding down. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds; time the vault spends + /// paused from now on moves it later. + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn propose_change(e: &Env, change: OracleChange) -> u32 { + strata_storage::bump_instance(e); + governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn cancel_change(e: &Env, id: u32) { + strata_storage::bump_instance(e); + governance::cancel(e, id); + } + + /// Lands a change whose delay has run. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `VaultPaused` - The vault is paused. + /// * `DelayBelowNotice` - The change's delay is shorter than the vault's + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time the vault spent paused + /// since, has not run. + /// * `InvalidConfig` - A `Config` change breaks a bound. + /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account + /// without the role. + /// * An `Upgrade` whose Wasm is not on the network fails the call. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `ConfigSet`, `RoleGranted`, + /// `RoleRevoked` or `AdminTransferInitiated`. + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn apply_change(e: &Env, id: u32) { + strata_storage::bump_instance(e); + governance::apply(e, id); + } + + /// Every change still waiting, with the time in seconds it may land on + /// the vault's clock as it reads now. + pub fn pending_changes(e: &Env) -> Vec { + governance::pending(e) + } + + /// The stored report: `nav_per_share` in WAD, `accepted_at` and + /// `expires_at` in seconds, and the proof the attester gave with it, + /// `proof_hash` and `proof_uri`. + /// + /// # Errors + /// + /// * `NoRecord` - No report is stored. + pub fn latest(e: &Env) -> NavReport { + get_latest(e).unwrap_or_else(|| panic_with_error!(e, OracleError::NoRecord)) + } + + /// True when no report is stored, it has expired, or its value is outside + /// the configured band. The NAV pause is not counted; `state` reports it. pub fn is_stale(e: &Env) -> bool { match get_latest(e) { None => true, @@ -145,57 +572,69 @@ impl NavOracleContract { } } - /// Ledger time the standing report was accepted. Not the valuation date - /// the reporter had in mind: the moment the contract took it. - pub fn attested_at(e: &Env) -> u64 { - Self::latest(e).timestamp - } - + /// Succeeds only while `state` is `Valid`. + /// + /// # Errors + /// + /// * `NotConsumable` - The NAV is paused, or the report is missing, + /// expired or out of band. pub fn ensure_consumable(e: &Env) { - if compute_state(e) != OracleState::Valid { - panic_with_error!(e, OracleError::NotConsumable); - } + guards::consumable(e); } + /// Stores a valuation and its proof for the vault to price epochs from. + /// The stored `expires_at` is the earlier of the input's and the time of + /// acceptance plus `freshness_duration`. It never verifies the proof. + /// + /// # Arguments + /// + /// * `input` - `nav_per_share` in WAD: asset base units per share base + /// unit. `expires_at` in seconds: the last moment the attester vouches + /// for it. `proof_hash` hashes the evidence; `proof_uri` says where to + /// read it, or is empty. + /// * `caller` - An `attester`. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` is not an `attester`. + /// * `NavPaused` - The NAV is paused. + /// * `NavOutOfBand` - `nav_per_share` is outside the band. + /// * `ExpiresInPast` - `expires_at` is not in the future. + /// * `EmptyProofHash` - `proof_hash` is all zeros. + /// * `ProofUriTooLong` - `proof_uri` is over 256 bytes. + /// * `CooldownActive` - The last report is younger than `cooldown_secs`. + /// * `DeviationExceeded` - The move from the last report exceeds its cap. + /// + /// # Events + /// + /// * `NavAttested` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "attester")] - pub fn attest(e: &Env, report: NavReport, caller: Address) { - storage::bump_instance(e); + pub fn attest(e: &Env, input: NavInput, caller: Address) { + guards::nav_not_paused(e); let cfg = get_config(e); - let now = e.ledger().timestamp(); - - if !in_band(&cfg, report.nav_per_share) { - panic_with_error!(e, OracleError::NavOutOfBand); - } - if report.expires_at <= now { - // should never happen - panic_with_error!(e, OracleError::ExpiresInPast); - } - - if let Some(prev) = get_latest(e) { - if now < prev.timestamp.saturating_add(cfg.cooldown_secs) { - panic_with_error!(e, OracleError::CooldownActive); - } - // Directional cap. A rise is always bounded. A fall is bounded only - // when the deployment asks for it, so a real loss can land at once. - let rising = report.nav_per_share >= prev.nav_per_share; - let bps = if rising { - Some(cfg.max_up_bps) - } else { - cfg.max_down_bps - }; - if let Some(bps) = bps { - let diff = (report.nav_per_share - prev.nav_per_share).abs(); - let bound = prev.nav_per_share * (bps as i128) / BPS_DENOM; - if diff > bound { - panic_with_error!(e, OracleError::DeviationExceeded); - } - } + guards::nav_in_band(e, &cfg, input.nav_per_share); + guards::expires_in_future(e, input.expires_at); + guards::proof_hash_not_empty(e, &input.proof_hash); + guards::proof_uri_fits(e, &input.proof_uri); + if let Some(last) = get_latest(e) { + guards::cooldown_elapsed(e, &cfg, &last); + guards::within_deviation(e, &cfg, &last, input.nav_per_share); } + strata_storage::bump_instance(e); + let now = e.ledger().timestamp(); let stored = NavReport { - nav_per_share: report.nav_per_share, - timestamp: now, - expires_at: now.saturating_add(cfg.freshness_duration), + nav_per_share: input.nav_per_share, + expires_at: input + .expires_at + .min(now.saturating_add(cfg.freshness_duration)), + accepted_at: now, + proof_hash: input.proof_hash, + proof_uri: input.proof_uri, }; state::set_latest(e, &stored); @@ -203,53 +642,171 @@ impl NavOracleContract { attester: caller, nav_per_share: stored.nav_per_share, expires_at: stored.expires_at, + proof_hash: stored.proof_hash, + proof_uri: stored.proof_uri, } .publish(e); } + /// Pauses the NAV: the feed reports `Paused`, no report is accepted and + /// the vault prices nothing. Only the admin lifts it. + /// + /// # Arguments + /// + /// * `caller` - A `guardian`. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` is not a `guardian`. + /// + /// # Events + /// + /// * `NavPausedSet`, with `paused` true. + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "guardian")] - pub fn raise_ripcord(e: &Env, caller: Address) { - state::set_ripcord(e, true); - RipcordSet { paused: true }.publish(e); + pub fn pause_nav(e: &Env, caller: Address) { + state::set_nav_paused(e, true); + NavPausedSet { paused: true }.publish(e); } + /// Pauses the NAV, or lifts the pause. + /// + /// # Arguments + /// + /// * `paused` - True pauses, false lifts the pause. + /// + /// # Events + /// + /// * `NavPausedSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn set_ripcord(e: &Env, paused: bool, caller: Address) { - caller.require_auth(); - state::set_ripcord(e, paused); - RipcordSet { paused }.publish(e); + pub fn set_nav_paused(e: &Env, paused: bool) { + state::set_nav_paused(e, paused); + NavPausedSet { paused }.publish(e); } /// Removes the stored record, so one in-band value can land again whatever - /// its distance from the last. Only while the ripcord is raised, so + /// its distance from the last. Only while the NAV is paused, so /// resuming is always a deliberate second act. + /// + /// # Errors + /// + /// * `NavNotPaused` - The NAV is not paused. + /// + /// # Events + /// + /// * `LatestCleared` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn clear_latest(e: &Env, caller: Address) { - caller.require_auth(); - if !ripcord_raised(e) { - panic_with_error!(e, OracleError::RipcordNotRaised); - } + pub fn clear_latest(e: &Env) { + guards::nav_paused(e); state::clear_latest(e); LatestCleared {}.publish(e); } + /// The limits a report must meet: `freshness_duration` and + /// `cooldown_secs` in seconds, `max_up_bps` and `max_down_bps` in basis + /// points, `min_answer` and `max_answer` in WAD. pub fn config(e: &Env) -> OracleConfig { get_config(e) } + /// Replaces the limits. Instant during setup. Afterwards, instant only + /// when the new config changes nothing but a shorter + /// `freshness_duration`; every other change waits. Propose it as + /// `OracleChange::Config`. + /// + /// # Arguments + /// + /// * `config` - The new limits, in the units `config` returns. + /// + /// # Errors + /// + /// * `InvalidConfig` - `config` breaks a bound. + /// * `UseTimelock` - After setup, it changes more than a shorter + /// `freshness_duration`. + /// + /// # Events + /// + /// * `ConfigSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_config(e: &Env, config: OracleConfig) { - validate_config(e, &config); - state::set_config(e, &config); - ConfigSet { - freshness_duration: config.freshness_duration, - cooldown_secs: config.cooldown_secs, - max_up_bps: config.max_up_bps, - max_down_bps: config.max_down_bps, - min_answer: config.min_answer, - max_answer: config.max_answer, + guards::valid_config(e, &config); + guards::direct_config_allowed(e, &config); + store_config(e, &config); + } +} + +#[contractimpl(contracttrait)] +impl OracleFeed for NavOracleContract { + /// `Paused` while the NAV is paused. Otherwise `Valid` when a report is + /// stored, unexpired and in band, and `Stale` when not. + fn state(e: &Env) -> OracleState { + compute_state(e) + } + + /// The stored report's value in WAD: asset base units per share base + /// unit. + /// + /// # Errors + /// + /// * `NoRecord` - No report is stored. + fn nav_per_share(e: &Env) -> i128 { + Self::latest(e).nav_per_share + } + + /// The ledger time, in seconds, the oracle took the stored report. Not + /// the valuation date the reporter had in mind. + /// + /// # Errors + /// + /// * `NoRecord` - No report is stored. + fn attested_at(e: &Env) -> u64 { + Self::latest(e).accepted_at + } + + /// How long a report stays valid after it is accepted, in seconds. + fn freshness_duration(e: &Env) -> u64 { + get_config(e).freshness_duration + } +} + +/// Stands in for a later oracle version in the upgrade test. Never deployed. +#[cfg(feature = "upgrade_probe")] +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ProbeError { + AlreadyMigrated = 3100, +} + +#[cfg(feature = "upgrade_probe")] +#[contractimpl] +impl NavOracleContract { + #[only_admin] + pub fn migrate(e: &Env) { + strata_storage::bump_instance(e); + if upgradeable::get_schema_version(e) >= 2 { + panic_with_error!(e, ProbeError::AlreadyMigrated); } - .publish(e); + upgradeable::set_schema_version(e, 2); + } + + pub fn probe(_e: &Env) -> bool { + true } } diff --git a/contracts/nav-oracle/src/state.rs b/contracts/nav-oracle/src/state.rs index 40e3a719..7b4c2042 100644 --- a/contracts/nav-oracle/src/state.rs +++ b/contracts/nav-oracle/src/state.rs @@ -1,40 +1,60 @@ -use soroban_sdk::{contracttype, symbol_short, Env, Symbol}; +use soroban_sdk::{contracttype, panic_with_error, symbol_short, BytesN, Env, String, Symbol}; + +use crate::OracleError; /// The three-state feed health // -// - `Valid`: ripcord=0 AND now ≤ expires_at +// - `Valid`: the NAV is not paused AND now ≤ expires_at // AND min_answer ≤ nav_per_share ≤ max_answer. Mint AND redeem. -// - `Stale`: ripcord=0 but past heartbeat/expires_at (or no record yet). +// - `Stale`: the NAV is not paused but past heartbeat/expires_at (or no record yet). // Value untrusted; BOTH mint and redeem halt. -// - `Paused`: ripcord=1 (issuer freeze). Value ignored entirely, both -// directions, until the issuer clears ripcord to 0. -pub use bindings::OracleState; +// - `Paused`: the NAV is paused (issuer freeze). Value ignored entirely, both +// directions, until the issuer clears the NAV pause. +pub use strata_bindings::OracleState; pub const ATTESTER_ROLE: Symbol = symbol_short!("attester"); -/// Raises the ripcord in an emergency. Cannot lower it again. +/// Pauses the NAV in an emergency. Cannot unpause it. pub const GUARDIAN_ROLE: Symbol = symbol_short!("guardian"); +/// What the attester submits. The proof is anchored, never verified: the hash +/// commits to the evidence behind the price, and the URI says where to find it. #[contracttype] #[derive(Clone)] -pub struct NavReport { +pub struct NavInput { pub nav_per_share: i128, + /// The reporter's own expiry. The contract keeps the earlier of this and + /// its freshness window. pub expires_at: u64, - pub timestamp: u64, + pub proof_hash: BytesN<32>, + /// Where the evidence can be read. Empty when the hash alone is published. + pub proof_uri: String, } +/// What the contract stores and serves. #[contracttype] #[derive(Clone)] +pub struct NavReport { + pub nav_per_share: i128, + /// The reporter's expiry, capped at `accepted_at` plus `freshness_duration`. + pub expires_at: u64, + /// Ledger time the contract accepted the report. + pub accepted_at: u64, + pub proof_hash: BytesN<32>, + pub proof_uri: String, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct OracleConfig { pub freshness_duration: u64, // before nav is considered stale pub cooldown_secs: u64, // before new record is admitted /// Upward move allowed per attestation. Always set, never zero. pub max_up_bps: u32, - /// Downward move allowed per attestation. `None` leaves falls uncapped, so a - /// loss of any size lands in one attestation. With it unset, `min_answer` is - /// the only bound on a single report, which makes that floor a risk - /// parameter rather than a sanity check. - pub max_down_bps: Option, + /// Downward move allowed per attestation, 1 to 10,000. At 10,000 a loss of + /// any size lands in one attestation and `min_answer` is the only floor. + pub max_down_bps: u32, + /// The lowest valid price, in WAD; at least `MIN_ANSWER`. pub min_answer: i128, pub max_answer: i128, } @@ -43,27 +63,28 @@ pub struct OracleConfig { pub(crate) enum DataKey { Config, Latest, - Ripcord, + NavPaused, } pub(crate) fn set_config(e: &Env, config: &OracleConfig) { - storage::set_instance(e, &DataKey::Config, config); + strata_storage::set_instance(e, &DataKey::Config, config); } pub(crate) fn get_config(e: &Env) -> OracleConfig { - storage::get_instance(e, &DataKey::Config).unwrap() + strata_storage::get_instance(e, &DataKey::Config) + .unwrap_or_else(|| panic_with_error!(e, OracleError::NotInitialized)) } -pub(crate) fn set_ripcord(e: &Env, paused: bool) { - storage::set_instance(e, &DataKey::Ripcord, &paused); +pub(crate) fn set_nav_paused(e: &Env, paused: bool) { + strata_storage::set_instance(e, &DataKey::NavPaused, &paused); } -pub(crate) fn ripcord_raised(e: &Env) -> bool { - storage::get_instance(e, &DataKey::Ripcord).unwrap_or(false) +pub(crate) fn nav_paused(e: &Env) -> bool { + strata_storage::get_instance(e, &DataKey::NavPaused).unwrap_or(false) } pub(crate) fn set_latest(e: &Env, report: &NavReport) { - storage::set_persistent(e, &DataKey::Latest, report); + strata_storage::set_persistent(e, &DataKey::Latest, report); } pub(crate) fn clear_latest(e: &Env) { @@ -71,5 +92,5 @@ pub(crate) fn clear_latest(e: &Env) { } pub(crate) fn get_latest(e: &Env) -> Option { - storage::get_persistent(e, &DataKey::Latest) + strata_storage::get_persistent(e, &DataKey::Latest) } diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs deleted file mode 100644 index d6244642..00000000 --- a/contracts/nav-oracle/src/test.rs +++ /dev/null @@ -1,520 +0,0 @@ -extern crate std; - -use soroban_sdk::{ - symbol_short, - testutils::{storage::Persistent as _, Address as _, Events as _, Ledger as _}, - Address, Env, Event as _, -}; - -use crate::state::DataKey; -use crate::{NavOracleContract, NavOracleContractClient, NavReport, OracleConfig, OracleState}; - -const SCALE: i128 = 1_000_000_000_000_000_000; -const DAY_IN_LEDGERS: u32 = 17_280; - -struct Fixture<'a> { - #[allow(dead_code)] - e: Env, - oracle: NavOracleContractClient<'a>, - admin: Address, - attester: Address, - guardian: Address, -} - -fn config() -> OracleConfig { - OracleConfig { - freshness_duration: 3600, - cooldown_secs: 60, - max_up_bps: 1_000, - max_down_bps: Some(1_000), // 10% - min_answer: SCALE / 2, - max_answer: SCALE * 100, - } -} - -fn setup<'a>() -> Fixture<'a> { - let e = Env::default(); - e.mock_all_auths(); - e.ledger().set_timestamp(10_000); - - let admin = Address::generate(&e); - let attester = Address::generate(&e); - let guardian = Address::generate(&e); - let cfg = config(); - let addr = e.register( - NavOracleContract, - (admin.clone(), attester.clone(), guardian.clone(), cfg), - ); - - Fixture { - oracle: NavOracleContractClient::new(&e, &addr), - admin, - attester, - guardian, - e, - } -} - -fn report(_e: &Env, nav: i128, timestamp: u64, expires_at: u64) -> NavReport { - NavReport { - nav_per_share: nav, - timestamp, - expires_at, - } -} - -#[test] -fn attest_then_valid_and_consumable() { - let f = setup(); - // No record yet: stale, not consumable. - assert_eq!(f.oracle.state(), OracleState::Stale); - assert!(f.oracle.is_stale()); - - let r = report(&f.e, SCALE, 1, 1_000_000); - f.oracle.attest(&r, &f.attester); - - assert_eq!(f.oracle.state(), OracleState::Valid); - assert_eq!(f.oracle.nav_per_share(), SCALE); - f.oracle.ensure_consumable(); // must not panic -} - -#[test] -fn heartbeat_expiry_makes_it_stale() { - let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); - f.oracle.attest(&r, &f.attester); - assert_eq!(f.oracle.state(), OracleState::Valid); - - // Advance past heartbeat (3600s from obs stamp 10_000). - f.e.ledger().set_timestamp(10_000 + 3601); - assert_eq!(f.oracle.state(), OracleState::Stale); - assert!(f.oracle.try_ensure_consumable().is_err()); -} - -#[test] -fn ripcord_pauses_regardless_of_freshness() { - let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); - f.oracle.attest(&r, &f.attester); - assert_eq!(f.oracle.state(), OracleState::Valid); - - f.oracle.set_ripcord(&true, &f.admin); - assert_eq!(f.oracle.state(), OracleState::Paused); - assert!(f.oracle.try_ensure_consumable().is_err()); - - f.oracle.set_ripcord(&false, &f.admin); - assert_eq!(f.oracle.state(), OracleState::Valid); -} - -#[test] -fn attest_rejects_out_of_band() { - let f = setup(); - // Below min_answer. - let r = report(&f.e, SCALE / 4, 1, 1_000_000); - assert!(f.oracle.try_attest(&r, &f.attester).is_err()); - // Zero is out of band (min_answer > 0). - let r0 = report(&f.e, 0, 1, 1_000_000); - assert!(f.oracle.try_attest(&r0, &f.attester).is_err()); -} - -#[test] -fn attest_enforces_cooldown_and_deviation() { - let f = setup(); - let r1 = report(&f.e, SCALE, 1, 1_000_000); - f.oracle.attest(&r1, &f.attester); - - // Cooldown: only 30s later (< 60), reject. - f.e.ledger().set_timestamp(10_030); - let r2 = report(&f.e, SCALE, 2, 1_000_000); - assert!(f.oracle.try_attest(&r2, &f.attester).is_err()); - - // Past cooldown now. - f.e.ledger().set_timestamp(10_100); - // Deviation: +20% exceeds the 10% cap. - let big = report(&f.e, SCALE + SCALE / 5, 2, 1_000_000); - assert!(f.oracle.try_attest(&big, &f.attester).is_err()); - - // Valid update: +5% within cap. - let ok = report(&f.e, SCALE + SCALE / 20, 2, 1_000_000); - f.oracle.attest(&ok, &f.attester); - assert_eq!(f.oracle.nav_per_share(), SCALE + SCALE / 20); -} - -/// The caller's `timestamp` is advisory: the contract stamps ledger time and -/// derives `expires_at` from its own freshness window. -#[test] -fn attest_stamps_its_own_timestamp() { - let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); - f.oracle.attest(&r, &f.attester); - - let stored = f.oracle.latest(); - assert_eq!(stored.timestamp, 10_000); - assert_eq!(stored.expires_at, 10_000 + 3600); -} - -#[test] -fn attesting_publishes_the_stored_valuation() { - let f = setup(); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); - - assert_eq!( - f.e.events().all().filter_by_contract(&f.oracle.address), - [crate::NavAttested { - attester: f.attester.clone(), - nav_per_share: SCALE, - expires_at: 10_000 + 3600, - } - .to_xdr(&f.e, &f.oracle.address)] - ); -} - -#[test] -fn the_latest_valuation_keeps_a_full_ttl_while_it_is_read() { - let f = setup(); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); - - f.e.ledger() - .with_mut(|l| l.sequence_number += 2 * DAY_IN_LEDGERS); - let decayed = f.e.as_contract(&f.oracle.address, || { - f.e.storage().persistent().get_ttl(&DataKey::Latest) - }); - assert!(decayed < storage::PERSISTENT_EXTEND_AMOUNT); - - f.oracle.latest(); - - let restored = f.e.as_contract(&f.oracle.address, || { - f.e.storage().persistent().get_ttl(&DataKey::Latest) - }); - assert_eq!(restored, storage::PERSISTENT_EXTEND_AMOUNT); -} - -#[test] -fn set_config_takes_effect() { - let f = setup(); - let r1 = report(&f.e, SCALE, 1, 1_000_000); - f.oracle.attest(&r1, &f.attester); - - // Tighten the per-attestation deviation band to 1%; a subsequent +5% move - // that the original 10% band would admit is now rejected. - let tight = OracleConfig { - max_up_bps: 100, - max_down_bps: Some(100), - ..config() - }; - f.oracle.set_config(&tight); - - f.e.ledger().set_timestamp(10_100); - let five_pct = report(&f.e, SCALE + SCALE / 20, 2, 1_000_000); - assert!( - f.oracle.try_attest(&five_pct, &f.attester).is_err(), - "the tightened band rejects a +5% move" - ); -} - -#[test] -fn attest_is_role_gated() { - let f = setup(); - let stranger = Address::generate(&f.e); - let r = report(&f.e, SCALE, 1, 1_000_000); - assert!(f.oracle.try_attest(&r, &stranger).is_err()); -} - -#[test] -fn narrowing_the_band_demotes_a_stored_record() { - let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); - f.oracle.attest(&r, &f.attester); - assert_eq!(f.oracle.state(), OracleState::Valid); - - let narrow = OracleConfig { - max_answer: SCALE / 2, - ..config() - }; - f.oracle.set_config(&narrow); - - assert_eq!(f.oracle.state(), OracleState::Stale); - assert!(f.oracle.is_stale()); - assert!(f.oracle.try_ensure_consumable().is_err()); -} - -#[test] -#[should_panic(expected = "#3001")] -fn constructor_rejects_an_unbounded_max_answer() { - let e = Env::default(); - let admin = Address::generate(&e); - let attester = Address::generate(&e); - let cfg = OracleConfig { - max_answer: i128::MAX, - ..config() - }; - e.register( - NavOracleContract, - (admin, attester, Address::generate(&e), cfg), - ); -} - -#[test] -#[should_panic(expected = "#3001")] -fn constructor_rejects_a_deviation_above_one_hundred_percent() { - let e = Env::default(); - let admin = Address::generate(&e); - let attester = Address::generate(&e); - let cfg = OracleConfig { - max_up_bps: 10_001, - max_down_bps: Some(10_001), - ..config() - }; - e.register( - NavOracleContract, - (admin, attester, Address::generate(&e), cfg), - ); -} - -#[test] -fn set_config_rejects_an_unbounded_max_answer() { - let f = setup(); - let cfg = OracleConfig { - max_answer: i128::MAX, - ..config() - }; - assert!(f.oracle.try_set_config(&cfg).is_err()); -} - -#[test] -fn set_config_rejects_a_zero_freshness_duration() { - let f = setup(); - let cfg = OracleConfig { - freshness_duration: 0, - ..config() - }; - assert!(f.oracle.try_set_config(&cfg).is_err()); -} - -#[test] -fn only_admin_can_set_config() { - let f = setup(); - let cfg = config(); - - f.e.set_auths(&[]); - assert!(f.oracle.try_set_config(&cfg).is_err()); - - f.e.mock_all_auths(); - f.oracle.set_config(&cfg); -} - -#[test] -fn the_guardian_raises_the_ripcord_but_lowering_needs_governance() { - let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); - f.oracle.attest(&r, &f.attester); - assert_eq!(f.oracle.state(), OracleState::Valid); - - f.oracle.raise_ripcord(&f.guardian); - assert_eq!(f.oracle.state(), OracleState::Paused); - - f.e.set_auths(&[]); - assert!(f.oracle.try_set_ripcord(&false, &f.guardian).is_err()); - assert!(f.oracle.try_set_ripcord(&false, &f.admin).is_err()); - assert_eq!(f.oracle.state(), OracleState::Paused); - - f.e.mock_all_auths(); - f.oracle.set_ripcord(&false, &f.admin); - assert_eq!(f.oracle.state(), OracleState::Valid); -} - -#[test] -fn raising_the_ripcord_is_limited_to_the_guardian() { - let f = setup(); - let stranger = Address::generate(&f.e); - - assert!(f.oracle.try_raise_ripcord(&stranger).is_err()); - assert!(f.oracle.try_raise_ripcord(&f.attester).is_err()); - assert!(f.oracle.try_raise_ripcord(&f.admin).is_err()); - assert_eq!(f.oracle.state(), OracleState::Stale); -} - -/// Builds the fixture with a caller-chosen cap, so both directions are testable. -fn setup_with(cfg: OracleConfig) -> Fixture<'static> { - let e = Env::default(); - e.mock_all_auths(); - e.ledger().set_timestamp(10_000); - - let admin = Address::generate(&e); - let attester = Address::generate(&e); - let guardian = Address::generate(&e); - let addr = e.register( - NavOracleContract, - (admin.clone(), attester.clone(), guardian.clone(), cfg), - ); - - Fixture { - oracle: NavOracleContractClient::new(&e, &addr), - admin, - attester, - guardian, - e, - } -} - -#[test] -fn a_large_loss_lands_in_one_attestation() { - let mut cfg = config(); - cfg.max_down_bps = None; - let f = setup_with(cfg); - - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); - f.e.ledger().set_timestamp(10_100); - - // A 40% fall, far past the 10% upward cap, is admitted at once. - let crash = report(&f.e, SCALE * 60 / 100, 2, 1_000_000); - f.oracle.attest(&crash, &f.attester); - assert_eq!(f.oracle.nav_per_share(), SCALE * 60 / 100); -} - -#[test] -fn an_unbounded_rise_is_still_refused() { - let mut cfg = config(); - cfg.max_down_bps = None; - let f = setup_with(cfg); - - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); - f.e.ledger().set_timestamp(10_100); - - let spike = report(&f.e, SCALE * 140 / 100, 2, 1_000_000); - assert!(f.oracle.try_attest(&spike, &f.attester).is_err()); -} - -#[test] -fn a_configured_downside_still_bounds_a_fall() { - let f = setup(); - - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); - f.e.ledger().set_timestamp(10_100); - - let crash = report(&f.e, SCALE * 60 / 100, 2, 1_000_000); - assert!(f.oracle.try_attest(&crash, &f.attester).is_err()); -} - -#[test] -#[should_panic(expected = "Error(Contract, #3001)")] -fn constructor_rejects_a_zero_upside_cap() { - let e = Env::default(); - let admin = Address::generate(&e); - let cfg = OracleConfig { - max_up_bps: 0, - ..config() - }; - e.register( - NavOracleContract, - (admin.clone(), admin.clone(), admin, cfg), - ); -} - -#[test] -fn the_record_clears_only_while_the_ripcord_is_raised() { - let f = setup(); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); - - assert!(f.oracle.try_clear_latest(&f.admin).is_err()); - - f.oracle.raise_ripcord(&f.guardian); - f.oracle.clear_latest(&f.admin); - - // With no stored record, one in-band value lands however far it sits. - f.oracle.set_ripcord(&false, &f.admin); - f.e.ledger().set_timestamp(10_100); - let far = report(&f.e, SCALE * 50, 2, 1_000_000); - f.oracle.attest(&far, &f.attester); - assert_eq!(f.oracle.nav_per_share(), SCALE * 50); -} - -#[test] -fn only_admin_can_clear_latest() { - let f = setup(); - let stranger = Address::generate(&f.e); - let r = report(&f.e, SCALE, 1, 1_000_000); - f.oracle.attest(&r, &f.attester); - f.oracle.raise_ripcord(&f.guardian); - - f.e.set_auths(&[]); - assert!(f.oracle.try_clear_latest(&stranger).is_err()); - assert!(f.oracle.try_clear_latest(&f.attester).is_err()); - assert!(f.oracle.try_clear_latest(&f.guardian).is_err()); -} - -#[test] -fn every_oracle_authority_is_readable_and_rotatable() { - let f = setup(); - let next = Address::generate(&f.e); - - assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); - assert_eq!( - f.oracle.get_role_member(&symbol_short!("attester"), &0), - f.attester - ); - - f.oracle - .grant_role(&next, &symbol_short!("guardian"), &f.admin); - f.oracle - .revoke_role(&f.guardian, &symbol_short!("guardian"), &f.admin); - - assert!(f.oracle.try_raise_ripcord(&f.guardian).is_err()); - f.oracle.raise_ripcord(&next); -} - -#[test] -fn the_oracle_admin_cannot_renounce_itself_away() { - let f = setup(); - assert!(f.oracle.try_renounce_admin().is_err()); - assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); -} - -/// Probe: with the downward cap unset, what actually bounds one attestation? -#[test] -fn a_fall_is_bounded_only_by_min_answer() { - let mut cfg = config(); - cfg.max_down_bps = None; - let f = setup_with(cfg); - - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); - f.e.ledger().set_timestamp(10_100); - - // Straight to the floor in one step, whatever the distance. - let floor = config().min_answer; - f.oracle - .attest(&report(&f.e, floor, 2, 1_000_000), &f.attester); - assert_eq!(f.oracle.nav_per_share(), floor); - - // Below the floor is refused, so min_answer is the only bound. - f.e.ledger().set_timestamp(10_200); - assert!(f - .oracle - .try_attest(&report(&f.e, floor - 1, 3, 1_000_000), &f.attester) - .is_err()); -} - -#[test] -fn attested_at_is_when_the_contract_took_the_report_not_when_the_caller_says() { - let f = setup(); - f.e.ledger().set_timestamp(50_000); - - f.oracle - .attest(&report(&f.e, SCALE, 7, 1_000_000), &f.attester); - - assert_eq!(f.oracle.attested_at(), 50_000); -} - -#[test] -fn attested_at_without_a_report_is_refused() { - let f = setup(); - - assert!(f.oracle.try_attested_at().is_err()); -} diff --git a/contracts/nav-oracle/src/test/governance.rs b/contracts/nav-oracle/src/test/governance.rs new file mode 100644 index 00000000..64d8acfd --- /dev/null +++ b/contracts/nav-oracle/src/test/governance.rs @@ -0,0 +1,1255 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, AuthorizedFunction, AuthorizedInvocation, Events as _, Ledger as _, MockAuth, + MockAuthInvoke, + }, + Address, BytesN, Env, Event as _, IntoVal, String, Symbol, Val, +}; +use stellar_access::access_control::{ + AccessControlError, AccessControlStorageKey, ROLE_EXTEND_AMOUNT, +}; +use stellar_access::role_transfer::RoleTransferError; +use strata_storage::INSTANCE_EXTEND_AMOUNT; +use strata_timelock::{Clock, DAY_DURATION, MIN_DELAY}; + +use crate::state::{ATTESTER_ROLE, GUARDIAN_ROLE}; +use crate::{ + shortens_freshness_only, ChangeApplied, ChangeProposed, ConfigSet, NavInput, NavOracleContract, + NavOracleContractClient, OracleChange, OracleConfig, OracleError, OracleState, + ADMIN_ACCEPT_LEDGERS, +}; + +use super::{missing_auth, refused}; + +const SCALE: i128 = 1_000_000_000_000_000_000; +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; + +/// Plays the vault: reports whatever clock a test sets, and counts the reads. +#[contract] +struct ClockStub; + +#[contractimpl] +impl ClockStub { + pub fn set(e: Env, clock: Clock) { + e.storage().instance().set(&symbol_short!("clock"), &clock); + } + + pub fn reads(e: Env) -> u32 { + e.storage() + .instance() + .get(&symbol_short!("reads")) + .unwrap_or(0) + } +} + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for ClockStub { + fn timelock_clock(e: &Env) -> Clock { + let reads = Self::reads(e.clone()) + 1; + e.storage().instance().set(&symbol_short!("reads"), &reads); + e.storage().instance().get(&symbol_short!("clock")).unwrap() + } +} + +type OracleArgs = (Address, Address, Address, OracleConfig); + +struct Fixture<'a> { + e: Env, + oracle: NavOracleContractClient<'a>, + vault: ClockStubClient<'a>, + admin: Address, + attester: Address, + guardian: Address, +} + +impl Fixture<'_> { + fn clock(&self, f: impl FnOnce(&mut Clock)) { + let mut clock = + strata_timelock::ClockSourceClient::new(&self.e, &self.vault.address).timelock_clock(); + f(&mut clock); + self.vault.set(&clock); + } + + fn advance(&self, secs: u64) { + self.e.ledger().with_mut(|l| { + l.timestamp += secs; + l.sequence_number += (secs / 5).max(1) as u32; + }); + } + + fn finish(&self) { + self.oracle.bind_vault(&self.vault.address); + self.oracle.finish_setup(); + } + + fn propose(&self, change: OracleChange) -> u32 { + self.oracle.propose_change(&change) + } + + fn attest(&self, nav_per_share: i128) { + self.oracle + .attest(&input(&self.e, nav_per_share), &self.attester); + } + + /// Lands an `Admin` change for `next` and returns the ledger of the offer. + fn offer_admin(&self, next: &Address) -> u32 { + let id = self.propose(OracleChange::Admin(next.clone())); + self.advance(MIN_DELAY); + self.oracle.apply_change(&id); + self.e.ledger().sequence() + } + + fn instance_ttl(&self) -> u32 { + self.e.as_contract(&self.oracle.address, || { + self.e.storage().instance().get_ttl() + }) + } + + /// Lets the instance decay past its extend threshold, then checks the call + /// extends it back to the full TTL. + fn restores_the_instance(&self, call: impl FnOnce()) { + self.e + .ledger() + .with_mut(|l| l.sequence_number += TWO_DAYS_IN_LEDGERS); + assert!(self.instance_ttl() < INSTANCE_EXTEND_AMOUNT); + call(); + assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); + } + + /// Signs one call to the oracle as `who` and nothing else, so the call + /// carries that account's signature but not the admin's. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.oracle.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + + /// The last call asked `who` alone to sign, once, on exactly this call. + fn signed_once_by( + &self, + who: &Address, + fn_name: &str, + args: soroban_sdk::Vec, + ) { + assert_eq!( + self.e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.oracle.address.clone(), + Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); + } +} + +fn config() -> OracleConfig { + OracleConfig { + freshness_duration: 3_600, + cooldown_secs: 60, + max_up_bps: 1_000, + max_down_bps: 1_000, + min_answer: SCALE / 2, + max_answer: SCALE * 100, + } +} + +fn proof(e: &Env) -> BytesN<32> { + BytesN::from_array(e, &[7; 32]) +} + +fn input(e: &Env, nav_per_share: i128) -> NavInput { + NavInput { + nav_per_share, + expires_at: u64::MAX, + proof_hash: proof(e), + proof_uri: String::from_str(e, "ipfs://strata-proof"), + } +} + +fn setup_with<'a>(register: impl FnOnce(&Env, OracleArgs) -> Address) -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + let admin = Address::generate(&e); + let attester = Address::generate(&e); + let guardian = Address::generate(&e); + let oracle = register( + &e, + (admin.clone(), attester.clone(), guardian.clone(), config()), + ); + let vault = ClockStubClient::new(&e, &e.register(ClockStub, ())); + vault.set(&Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + }); + Fixture { + oracle: NavOracleContractClient::new(&e, &oracle), + vault, + admin, + attester, + guardian, + e, + } +} + +fn setup<'a>() -> Fixture<'a> { + setup_with(|e, args| e.register(NavOracleContract, args)) +} + +#[test] +fn only_a_shorter_freshness_window_is_instant() { + let base = config(); + let waits = [ + OracleConfig { + freshness_duration: 7_200, + ..config() + }, + OracleConfig { + cooldown_secs: 30, + ..config() + }, + OracleConfig { + cooldown_secs: 120, + ..config() + }, + OracleConfig { + max_up_bps: 500, + ..config() + }, + OracleConfig { + max_up_bps: 2_000, + ..config() + }, + OracleConfig { + max_down_bps: 500, + ..config() + }, + OracleConfig { + max_down_bps: 2_000, + ..config() + }, + OracleConfig { + min_answer: SCALE, + ..config() + }, + OracleConfig { + min_answer: SCALE / 4, + ..config() + }, + OracleConfig { + max_answer: SCALE * 50, + ..config() + }, + OracleConfig { + max_answer: SCALE * 200, + ..config() + }, + OracleConfig { + freshness_duration: 1_800, + max_up_bps: 500, + ..config() + }, + ]; + + for next in waits.iter() { + assert!(!shortens_freshness_only(&base, next), "{next:?} waits"); + } + assert!(shortens_freshness_only( + &base, + &OracleConfig { + freshness_duration: 1_800, + ..config() + } + )); + assert!(shortens_freshness_only(&base, &base)); +} + +#[test] +fn a_config_that_tightens_one_field_and_loosens_another_waits() { + let f = setup(); + f.finish(); + let mixed = OracleConfig { + max_up_bps: 500, + freshness_duration: 7_200, + ..config() + }; + + refused(f.oracle.try_set_config(&mixed), OracleError::UseTimelock); + assert_eq!(f.oracle.config(), config()); +} + +#[test] +fn a_shorter_freshness_window_stays_instant_after_setup() { + let f = setup(); + f.finish(); + let shorter = OracleConfig { + freshness_duration: 1_800, + ..config() + }; + + f.oracle.set_config(&shorter); + + assert_eq!(f.oracle.config(), shorter); +} + +#[test] +fn a_shorter_freshness_window_with_a_tighter_field_waits() { + let f = setup(); + f.finish(); + let shorter_and_tighter = OracleConfig { + freshness_duration: 1_800, + max_down_bps: 500, + ..config() + }; + + refused( + f.oracle.try_set_config(&shorter_and_tighter), + OracleError::UseTimelock, + ); + assert_eq!(f.oracle.config(), config()); +} + +#[test] +fn a_shorter_freshness_window_with_a_looser_field_waits() { + let f = setup(); + f.finish(); + let shorter_and_looser = OracleConfig { + freshness_duration: 1_800, + max_up_bps: 2_000, + ..config() + }; + + refused( + f.oracle.try_set_config(&shorter_and_looser), + OracleError::UseTimelock, + ); + assert_eq!(f.oracle.config(), config()); +} + +#[test] +fn a_freshness_cut_below_the_cooldown_is_refused() { + let f = setup(); + f.finish(); + let below_the_cooldown = OracleConfig { + freshness_duration: config().cooldown_secs - 1, + ..config() + }; + + refused( + f.oracle.try_set_config(&below_the_cooldown), + OracleError::InvalidConfig, + ); + assert_eq!(f.oracle.config(), config()); +} + +#[test] +fn a_tighter_cap_or_band_waits_after_setup() { + let f = setup(); + f.finish(); + + for tighter in [ + OracleConfig { + max_down_bps: 500, + ..config() + }, + OracleConfig { + cooldown_secs: 120, + ..config() + }, + OracleConfig { + min_answer: SCALE, + ..config() + }, + ] { + refused(f.oracle.try_set_config(&tighter), OracleError::UseTimelock); + } + assert_eq!(f.oracle.config(), config()); +} + +#[test] +fn any_config_is_instant_during_setup() { + let f = setup(); + let loose = OracleConfig { + freshness_duration: 7_200, + ..config() + }; + + f.oracle.set_config(&loose); + + assert_eq!(f.oracle.config(), loose); +} + +#[test] +fn a_loosened_config_lands_through_the_timelock() { + let f = setup(); + f.finish(); + let loose = OracleConfig { + freshness_duration: 7_200, + ..config() + }; + let id = f.propose(OracleChange::Config(loose.clone())); + + f.advance(MIN_DELAY - 1); + refused(f.oracle.try_apply_change(&id), OracleError::ChangeNotReady); + f.advance(1); + f.oracle.apply_change(&id); + + assert_eq!(f.oracle.config(), loose); + assert_eq!(f.oracle.pending_changes().len(), 0); +} + +#[test] +fn an_invalid_config_cannot_be_proposed() { + let f = setup(); + f.finish(); + let invalid = OracleConfig { + freshness_duration: 0, + ..config() + }; + + refused( + f.oracle.try_propose_change(&OracleChange::Config(invalid)), + OracleError::InvalidConfig, + ); +} + +#[test] +fn a_config_outside_the_bounds_is_refused_when_it_applies() { + let f = setup(); + f.finish(); + let invalid = OracleConfig { + freshness_duration: 0, + ..config() + }; + // Skips the proposal's check, like a change proposed before a later + // version narrowed the bounds. + let id = f.e.as_contract(&f.oracle.address, || { + strata_timelock::queue::propose::( + &f.e, + &OracleChange::Config(invalid), + ) + .unwrap() + }); + f.advance(MIN_DELAY); + + refused(f.oracle.try_apply_change(&id), OracleError::InvalidConfig); + assert_eq!(f.oracle.config(), config()); + assert_eq!(f.oracle.pending_changes().len(), 1); +} + +#[test] +fn setup_is_open_until_finished_and_never_reopens() { + let f = setup(); + assert!(f.oracle.setup_open()); + refused(f.oracle.try_finish_setup(), OracleError::VaultNotBound); + + f.finish(); + + assert!(!f.oracle.setup_open()); + assert_eq!(f.oracle.vault(), Some(f.vault.address.clone())); + refused(f.oracle.try_finish_setup(), OracleError::SetupClosed); + refused( + f.oracle.try_bind_vault(&Address::generate(&f.e)), + OracleError::SetupClosed, + ); +} + +#[test] +fn a_second_vault_cannot_be_bound() { + let f = setup(); + f.oracle.bind_vault(&f.vault.address); + + refused( + f.oracle.try_bind_vault(&Address::generate(&f.e)), + OracleError::SetupClosed, + ); + assert_eq!(f.oracle.vault(), Some(f.vault.address.clone())); +} + +#[test] +fn an_address_that_reports_no_clock_cannot_be_bound() { + let f = setup(); + + missing_auth(f.oracle.try_bind_vault(&Address::generate(&f.e))); + assert_eq!(f.oracle.vault(), None); +} + +#[test] +fn only_the_admin_binds_and_finishes() { + let f = setup(); + let stranger = Address::generate(&f.e); + + f.sign_only_as( + &stranger, + "bind_vault", + (f.vault.address.clone(),).into_val(&f.e), + ); + missing_auth(f.oracle.try_bind_vault(&f.vault.address)); + assert_eq!(f.oracle.vault(), None); + + f.e.mock_all_auths(); + f.oracle.bind_vault(&f.vault.address); + f.sign_only_as(&stranger, "finish_setup", soroban_sdk::Vec::new(&f.e)); + missing_auth(f.oracle.try_finish_setup()); + assert!(f.oracle.setup_open()); +} + +fn implements_the_vault_view() {} + +#[test] +fn the_oracle_answers_the_view_the_vault_reads() { + implements_the_vault_view::(); + let f = setup(); + let view = strata_bindings::VaultBindingClient::new(&f.e, &f.oracle.address); + assert_eq!(view.vault(), None); + assert!(view.setup_open()); + + f.finish(); + + assert_eq!(view.vault(), Some(f.vault.address.clone())); + assert!(!view.setup_open()); +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = setup(); + let next = Address::generate(&f.e); + f.oracle.bind_vault(&f.vault.address); + f.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.oracle.try_finish_setup(), + OracleError::AdminTransferPending, + ); + + f.oracle.transfer_admin_role(&next, &0); + f.oracle.finish_setup(); + assert_eq!( + f.oracle.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn an_expired_hand_over_does_not_hold_setup_open() { + let f = setup(); + f.oracle.bind_vault(&f.vault.address); + f.oracle + .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); + f.e.ledger().with_mut(|l| l.sequence_number += 11); + + f.oracle.finish_setup(); + + assert!(!f.oracle.setup_open()); + assert_eq!( + f.oracle.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn nothing_is_proposed_during_setup() { + let f = setup(); + + refused( + f.oracle.try_propose_change(&OracleChange::Config(config())), + OracleError::SetupOpen, + ); + assert_eq!(f.oracle.pending_changes().len(), 0); +} + +#[test] +fn grants_wait_after_setup_and_land_through_the_timelock() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + + refused( + f.oracle.try_grant_role(&next, &GUARDIAN_ROLE, &f.admin), + OracleError::UseTimelock, + ); + + let grant = f.propose(OracleChange::GrantRole(GUARDIAN_ROLE, next.clone())); + f.advance(MIN_DELAY); + f.oracle.apply_change(&grant); + + f.oracle.pause_nav(&next); + assert_eq!(f.oracle.state(), OracleState::Paused); +} + +#[test] +fn revoking_an_attester_or_a_guardian_stays_instant_after_setup() { + let f = setup(); + f.finish(); + + f.oracle.revoke_role(&f.attester, &ATTESTER_ROLE, &f.admin); + f.oracle.revoke_role(&f.guardian, &GUARDIAN_ROLE, &f.admin); + + assert!(f.oracle.has_role(&f.attester, &ATTESTER_ROLE).is_none()); + assert!(f.oracle.has_role(&f.guardian, &GUARDIAN_ROLE).is_none()); + assert_eq!( + f.oracle.try_attest(&input(&f.e, SCALE), &f.attester), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + assert_eq!( + f.oracle.try_pause_nav(&f.guardian), + Err(Ok(AccessControlError::Unauthorized.into())) + ); +} + +#[test] +fn only_the_admin_revokes_a_role() { + let f = setup(); + f.finish(); + let stranger = Address::generate(&f.e); + + assert_eq!( + f.oracle + .try_revoke_role(&f.attester, &ATTESTER_ROLE, &stranger), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + f.e.set_auths(&[]); + missing_auth( + f.oracle + .try_revoke_role(&f.attester, &ATTESTER_ROLE, &f.admin), + ); + assert!(f.oracle.has_role(&f.attester, &ATTESTER_ROLE).is_some()); +} + +#[test] +fn a_revoke_lands_through_the_timelock() { + let f = setup(); + f.finish(); + + let id = f.propose(OracleChange::RevokeRole(GUARDIAN_ROLE, f.guardian.clone())); + f.advance(MIN_DELAY); + f.oracle.apply_change(&id); + + assert!(f.oracle.has_role(&f.guardian, &GUARDIAN_ROLE).is_none()); +} + +#[test] +fn no_role_ever_gets_a_role_admin() { + let f = setup(); + + refused( + f.oracle.try_set_role_admin(&GUARDIAN_ROLE, &ATTESTER_ROLE), + OracleError::UseTimelock, + ); +} + +#[test] +fn an_admin_hand_over_waits_but_cancelling_one_does_not() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + + refused( + f.oracle + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + OracleError::UseTimelock, + ); + + let id = f.propose(OracleChange::Admin(next.clone())); + f.advance(MIN_DELAY); + f.oracle.apply_change(&id); + f.oracle.transfer_admin_role(&next, &0); + + assert_eq!( + f.oracle.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let id = f.propose(OracleChange::Admin(next.clone())); + f.advance(MIN_DELAY); + + // One admin signature covers the call; the hand-over inside asks for none. + f.sign_only_as(&f.admin, "apply_change", (id,).into_val(&f.e)); + f.oracle.apply_change(&id); + + f.e.mock_all_auths(); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); + f.oracle.accept_admin_transfer(); + assert_eq!(f.oracle.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_is_open_on_its_last_ledger() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let offered_at = f.offer_admin(&next); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.oracle.accept_admin_transfer(); + + assert_eq!(f.oracle.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = setup(); + f.finish(); + let offered_at = f.offer_admin(&Address::generate(&f.e)); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1); + + assert_eq!( + f.oracle.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn the_nav_pause_and_clearing_stay_instant_after_setup() { + let f = setup(); + f.attest(SCALE); + f.finish(); + + f.oracle.pause_nav(&f.guardian); + f.oracle.set_nav_paused(&false); + f.oracle.set_nav_paused(&true); + f.oracle.clear_latest(); + + assert_eq!(f.oracle.state(), OracleState::Paused); + assert_eq!( + f.oracle.try_latest().err(), + Some(Ok(OracleError::NoRecord.into())) + ); +} + +#[test] +fn a_vault_pause_moves_the_eta_and_holds_the_change() { + let f = setup(); + f.finish(); + let id = f.propose(OracleChange::Config(OracleConfig { + freshness_duration: 7_200, + ..config() + })); + let eta = f.oracle.pending_changes().get(0).unwrap().eta; + + f.advance(DAY_DURATION); + f.clock(|c| c.paused = true); + f.advance(3 * DAY_DURATION); + f.clock(|c| c.paused_secs = 3 * DAY_DURATION); + f.advance(MIN_DELAY - 4 * DAY_DURATION); + + refused(f.oracle.try_apply_change(&id), OracleError::VaultPaused); + + f.clock(|c| c.paused = false); + assert_eq!( + f.oracle.pending_changes().get(0).unwrap().eta, + eta + 3 * DAY_DURATION + ); + refused(f.oracle.try_apply_change(&id), OracleError::ChangeNotReady); + + f.advance(3 * DAY_DURATION); + f.oracle.apply_change(&id); + assert_eq!(f.oracle.config().freshness_duration, 7_200); +} + +#[test] +fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { + let f = setup(); + f.finish(); + let id = f.propose(OracleChange::GrantRole( + GUARDIAN_ROLE, + Address::generate(&f.e), + )); + f.clock(|c| c.winding_down = true); + f.advance(MIN_DELAY); + + refused( + f.oracle.try_propose_change(&OracleChange::Config(config())), + OracleError::WindDownActive, + ); + refused(f.oracle.try_apply_change(&id), OracleError::WindDownActive); + f.oracle.cancel_change(&id); +} + +#[test] +fn an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + f.offer_admin(&next); + f.clock(|c| c.winding_down = true); + + refused( + f.oracle.try_accept_admin_transfer(), + OracleError::WindDownActive, + ); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = setup(); + f.finish(); + let id = f.propose(OracleChange::GrantRole( + GUARDIAN_ROLE, + Address::generate(&f.e), + )); + f.clock(|c| { + c.delay = 30 * DAY_DURATION; + c.exit_window = 20 * DAY_DURATION; + }); + f.advance(30 * DAY_DURATION); + + refused( + f.oracle.try_apply_change(&id), + OracleError::DelayBelowNotice, + ); +} + +#[test] +fn one_upgrade_waits_at_a_time() { + let f = setup(); + f.finish(); + f.propose(OracleChange::Upgrade(BytesN::from_array(&f.e, &[7; 32]))); + + refused( + f.oracle + .try_propose_change(&OracleChange::Upgrade(BytesN::from_array(&f.e, &[9; 32]))), + OracleError::UpgradeAlreadyPending, + ); +} + +#[test] +fn only_the_admin_proposes_cancels_and_applies() { + let f = setup(); + f.finish(); + let stranger = Address::generate(&f.e); + let change = OracleChange::Config(config()); + let id = f.propose(change.clone()); + f.advance(MIN_DELAY); + + f.sign_only_as( + &stranger, + "propose_change", + (change.clone(),).into_val(&f.e), + ); + missing_auth(f.oracle.try_propose_change(&change)); + f.sign_only_as(&stranger, "cancel_change", (id,).into_val(&f.e)); + missing_auth(f.oracle.try_cancel_change(&id)); + f.sign_only_as(&stranger, "apply_change", (id,).into_val(&f.e)); + missing_auth(f.oracle.try_apply_change(&id)); + assert_eq!(f.oracle.pending_changes().len(), 1); +} + +#[test] +fn proposing_and_applying_publish_the_change() { + let f = setup(); + f.finish(); + let loose = OracleConfig { + freshness_duration: 7_200, + ..config() + }; + let change = OracleChange::Config(loose.clone()); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + assert_eq!( + f.e.events().all().filter_by_contract(&f.oracle.address), + [ChangeProposed { + id, + change: change.clone(), + eta, + } + .to_xdr(&f.e, &f.oracle.address)] + ); + + f.advance(MIN_DELAY); + f.oracle.apply_change(&id); + assert_eq!( + f.e.events().all().filter_by_contract(&f.oracle.address), + [ + ConfigSet { + freshness_duration: loose.freshness_duration, + cooldown_secs: loose.cooldown_secs, + max_up_bps: loose.max_up_bps, + max_down_bps: loose.max_down_bps, + min_answer: loose.min_answer, + max_answer: loose.max_answer, + } + .to_xdr(&f.e, &f.oracle.address), + ChangeApplied { id, change }.to_xdr(&f.e, &f.oracle.address), + ] + ); +} + +#[test] +fn proposing_reads_the_vault_clock_once() { + let f = setup(); + f.finish(); + let before = f.vault.reads(); + + f.propose(OracleChange::Config(config())); + + assert_eq!(f.vault.reads(), before + 1); +} + +#[test] +fn a_proposal_after_a_past_pause_waits_one_delay_from_now() { + let f = setup(); + f.finish(); + f.clock(|c| c.paused_secs = 3 * DAY_DURATION); + let change = OracleChange::Config(config()); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.oracle.address), + [ChangeProposed { id, change, eta }.to_xdr(&f.e, &f.oracle.address)] + ); + assert_eq!(f.oracle.pending_changes().get(0).unwrap().eta, eta); +} + +#[test] +fn the_schema_version_is_one_after_construction() { + let f = setup(); + + assert_eq!(f.oracle.schema_version(), 1); +} + +#[test] +fn setup_grants_hand_overs_and_changes_restore_the_instance_ttl() { + let f = setup(); + let holder = Address::generate(&f.e); + let next = Address::generate(&f.e); + + f.restores_the_instance(|| f.oracle.grant_role(&holder, &GUARDIAN_ROLE, &f.admin)); + f.restores_the_instance(|| { + f.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)) + }); + f.restores_the_instance(|| f.oracle.transfer_admin_role(&next, &0)); + f.restores_the_instance(|| f.oracle.bind_vault(&f.vault.address)); + f.restores_the_instance(|| f.oracle.finish_setup()); + + let change = OracleChange::Config(config()); + let mut id = 0; + f.restores_the_instance(|| id = f.propose(change.clone())); + f.restores_the_instance(|| f.oracle.cancel_change(&id)); + + let id = f.propose(change); + f.advance(MIN_DELAY); + f.restores_the_instance(|| f.oracle.apply_change(&id)); + + f.offer_admin(&next); + f.restores_the_instance(|| f.oracle.accept_admin_transfer()); +} + +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + +impl Fixture<'_> { + fn persistent_ttl>(&self, key: &K) -> u32 { + self.e.as_contract(&self.oracle.address, || { + self.e.storage().persistent().get_ttl(key) + }) + } + + /// Every entry a grant writes: the role, its member-list slot, the member count, the role list. + fn role_entry_ttls(&self, account: &Address, role: &Symbol) -> [u32; 4] { + let has_role = AccessControlStorageKey::HasRole(account.clone(), role.clone()); + let index: u32 = self.e.as_contract(&self.oracle.address, || { + self.e.storage().persistent().get(&has_role).unwrap() + }); + [ + self.persistent_ttl(&has_role), + self.persistent_ttl(&RoleMemberKey::RoleAccounts(RoleAccountKey { + role: role.clone(), + index, + })), + self.persistent_ttl(&AccessControlStorageKey::RoleAccountsCount(role.clone())), + self.persistent_ttl(&AccessControlStorageKey::ExistingRoles), + ] + } +} + +fn testnet_minimum(e: &Env) { + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); +} + +#[test] +fn the_constructor_writes_each_role_with_the_full_ttl() { + let f = setup_with(|e, args| { + testnet_minimum(e); + e.register(NavOracleContract, args) + }); + + assert_eq!( + f.role_entry_ttls(&f.attester, &ATTESTER_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); + assert_eq!( + f.role_entry_ttls(&f.guardian, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_during_setup_writes_the_role_with_the_full_ttl() { + let f = setup(); + let next = Address::generate(&f.e); + testnet_minimum(&f.e); + + f.oracle.grant_role(&next, &GUARDIAN_ROLE, &f.admin); + + assert_eq!( + f.role_entry_ttls(&next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let id = f.propose(OracleChange::GrantRole(GUARDIAN_ROLE, next.clone())); + f.advance(MIN_DELAY); + testnet_minimum(&f.e); + + f.oracle.apply_change(&id); + + assert_eq!( + f.role_entry_ttls(&next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn each_admin_call_asks_the_admin_for_one_signature() { + let f = setup(); + let holder = Address::generate(&f.e); + let live_until = f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS; + + f.oracle.set_config(&config()); + f.signed_once_by(&f.admin, "set_config", (config(),).into_val(&f.e)); + f.oracle.grant_role(&holder, &ATTESTER_ROLE, &f.admin); + f.signed_once_by( + &f.admin, + "grant_role", + (holder.clone(), ATTESTER_ROLE, f.admin.clone()).into_val(&f.e), + ); + f.oracle.revoke_role(&holder, &ATTESTER_ROLE, &f.admin); + f.signed_once_by( + &f.admin, + "revoke_role", + (holder.clone(), ATTESTER_ROLE, f.admin.clone()).into_val(&f.e), + ); + f.oracle.transfer_admin_role(&holder, &live_until); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), live_until).into_val(&f.e), + ); + f.oracle.transfer_admin_role(&holder, &0); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), 0_u32).into_val(&f.e), + ); + + f.oracle.bind_vault(&f.vault.address); + f.signed_once_by( + &f.admin, + "bind_vault", + (f.vault.address.clone(),).into_val(&f.e), + ); + f.oracle.finish_setup(); + f.signed_once_by(&f.admin, "finish_setup", soroban_sdk::Vec::new(&f.e)); + + let change = OracleChange::Config(config()); + let id = f.oracle.propose_change(&change); + f.signed_once_by(&f.admin, "propose_change", (change.clone(),).into_val(&f.e)); + f.oracle.cancel_change(&id); + f.signed_once_by(&f.admin, "cancel_change", (id,).into_val(&f.e)); + let id = f.oracle.propose_change(&change); + f.advance(MIN_DELAY); + f.oracle.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); + + f.oracle.pause_nav(&f.guardian); + f.oracle.clear_latest(); + f.signed_once_by(&f.admin, "clear_latest", soroban_sdk::Vec::new(&f.e)); + f.oracle.set_nav_paused(&false); + f.signed_once_by(&f.admin, "set_nav_paused", (false,).into_val(&f.e)); +} + +#[test] +fn each_role_call_asks_its_holder_for_one_signature() { + let f = setup(); + let next = Address::generate(&f.e); + + f.oracle.attest(&input(&f.e, SCALE), &f.attester); + f.signed_once_by( + &f.attester, + "attest", + (input(&f.e, SCALE), f.attester.clone()).into_val(&f.e), + ); + f.oracle.pause_nav(&f.guardian); + f.signed_once_by( + &f.guardian, + "pause_nav", + (f.guardian.clone(),).into_val(&f.e), + ); + f.oracle.renounce_role(&ATTESTER_ROLE, &f.attester); + f.signed_once_by( + &f.attester, + "renounce_role", + (ATTESTER_ROLE, f.attester.clone()).into_val(&f.e), + ); + + f.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)); + f.oracle.accept_admin_transfer(); + f.signed_once_by(&next, "accept_admin_transfer", soroban_sdk::Vec::new(&f.e)); +} + +#[cfg(feature = "upgrade_wasm")] +mod with_wasm { + use soroban_sdk::xdr::{ContractEventBody, ScError, ScErrorCode, ScErrorType, ScSymbol, ScVal}; + + use super::*; + + mod oracle_v1 { + soroban_sdk::contractimport!(file = "../../target/wasm32v1-none/release/nav_oracle.wasm"); + } + + mod oracle_probe { + soroban_sdk::contractimport!( + file = "../../target/upgrade-probe/wasm32v1-none/release/nav_oracle.wasm" + ); + } + + /// What a caller sees when the host refuses a call: every host error is + /// narrowed to this one. + fn host_refusal() -> soroban_sdk::Error { + soroban_sdk::Error::from_type_and_code(ScErrorType::Context, ScErrorCode::InvalidAction) + } + + /// Whether the host refused a call to `function` because the contract + /// exports no function of that name. The reason is only in the + /// diagnostic events. + fn exports_no(e: &Env, function: &str) -> bool { + let missing = ScVal::Error(ScError::WasmVm(ScErrorCode::MissingValue)); + let name = ScVal::Symbol(ScSymbol(function.try_into().unwrap())); + e.host() + .get_diagnostic_events() + .unwrap() + .0 + .iter() + .any(|event| { + let ContractEventBody::V0(body) = &event.event.body; + body.topics.contains(&missing) + && matches!(&body.data, ScVal::Vec(Some(args)) if args.contains(&name)) + }) + } + + #[test] + fn the_oracle_exposes_no_immediate_upgrade() { + let f = setup_with(|e, args| e.register(oracle_v1::WASM, args)); + let probe_hash = f.e.deployer().upload_contract_wasm(oracle_probe::WASM); + let upgrader = + stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.oracle.address); + + assert_eq!( + upgrader.try_upgrade(&probe_hash, &f.admin), + Err(Ok(host_refusal())) + ); + assert!(exports_no(&f.e, "upgrade")); + assert_eq!( + oracle_probe::Client::new(&f.e, &f.oracle.address).try_probe(), + Err(Ok(host_refusal())) + ); + assert!(exports_no(&f.e, "probe")); + assert_eq!(f.oracle.schema_version(), 1); + } + + #[test] + fn the_oracle_upgrades_under_a_live_valuation() { + let f = setup_with(|e, args| e.register(oracle_v1::WASM, args)); + f.attest(SCALE); + f.finish(); + let probe_hash = f.e.deployer().upload_contract_wasm(oracle_probe::WASM); + let id = f.propose(OracleChange::Upgrade(probe_hash)); + f.advance(MIN_DELAY); + + f.oracle.apply_change(&id); + + let probe = oracle_probe::Client::new(&f.e, &f.oracle.address); + assert!(probe.probe()); + assert_eq!(probe.schema_version(), 1); + probe.migrate(); + assert_eq!(probe.schema_version(), 2); + assert_eq!( + probe.try_migrate(), + Err(Ok(oracle_probe::ProbeError::AlreadyMigrated.into())) + ); + + let kept = f.oracle.latest(); + assert_eq!( + (kept.nav_per_share, kept.accepted_at, kept.expires_at), + (SCALE, 10_000, 10_000 + config().freshness_duration) + ); + assert_eq!(kept.proof_hash, proof(&f.e)); + assert_eq!(f.oracle.config(), config()); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); + assert_eq!(f.oracle.vault(), Some(f.vault.address.clone())); + assert!(!f.oracle.setup_open()); + assert_eq!(f.oracle.pending_changes().len(), 0); + + f.attest(SCALE + SCALE / 20); + assert_eq!(f.oracle.nav_per_share(), SCALE + SCALE / 20); + f.oracle.pause_nav(&f.guardian); + assert_eq!(f.oracle.state(), OracleState::Paused); + } +} diff --git a/contracts/nav-oracle/src/test/guards.rs b/contracts/nav-oracle/src/test/guards.rs new file mode 100644 index 00000000..db2bc563 --- /dev/null +++ b/contracts/nav-oracle/src/test/guards.rs @@ -0,0 +1,402 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{Address as _, Ledger as _}, + Address, BytesN, Env, InvokeError, String, +}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{ + NavInput, NavOracleContract, NavOracleContractClient, NavReport, OracleChange, OracleConfig, + OracleError, MAX_ANSWER, MAX_FRESHNESS_SECS, MAX_PROOF_URI_LEN, MIN_ANSWER, +}; + +const SCALE: i128 = 1_000_000_000_000_000_000; + +/// Plays the vault: the oracle binds only to an address that reports a clock. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +struct Fixture<'a> { + e: Env, + oracle: NavOracleContractClient<'a>, + vault: Address, + attester: Address, + guardian: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.oracle.bind_vault(&self.vault); + self.oracle.finish_setup(); + } + + fn attest(&self, nav_per_share: i128) { + self.oracle.attest( + &NavInput { + nav_per_share, + expires_at: u64::MAX, + proof_hash: proof(&self.e), + proof_uri: String::from_str(&self.e, "ipfs://strata-proof"), + }, + &self.attester, + ); + } +} + +fn config() -> OracleConfig { + OracleConfig { + freshness_duration: 3_600, + cooldown_secs: 60, + max_up_bps: 1_000, + max_down_bps: 1_000, + min_answer: SCALE / 2, + max_answer: SCALE * 100, + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + let admin = Address::generate(&e); + let attester = Address::generate(&e); + let guardian = Address::generate(&e); + let oracle = NavOracleContractClient::new( + &e, + &e.register( + NavOracleContract, + (admin, attester.clone(), guardian.clone(), config()), + ), + ); + let vault = e.register(VaultStub, ()); + Fixture { + e, + oracle, + vault, + attester, + guardian, + } +} + +/// Runs `guard` inside the oracle, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.oracle.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: OracleError, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +fn proof(e: &Env) -> BytesN<32> { + BytesN::from_array(e, &[7; 32]) +} + +fn report_at(e: &Env, nav_per_share: i128, accepted_at: u64) -> NavReport { + NavReport { + nav_per_share, + expires_at: u64::MAX, + accepted_at, + proof_hash: proof(e), + proof_uri: String::from_str(e, "ipfs://strata-proof"), + } +} + +#[test] +fn valid_config_refuses_a_config_outside_the_bounds() { + let f = setup(); + holds(&f, || guards::valid_config(&f.e, &config())); + + for invalid in [ + OracleConfig { + freshness_duration: 0, + ..config() + }, + OracleConfig { + freshness_duration: MAX_FRESHNESS_SECS + 1, + ..config() + }, + OracleConfig { + cooldown_secs: 0, + ..config() + }, + OracleConfig { + cooldown_secs: 3_601, + ..config() + }, + OracleConfig { + min_answer: 0, + ..config() + }, + OracleConfig { + min_answer: MIN_ANSWER - 1, + ..config() + }, + OracleConfig { + max_answer: SCALE / 4, + ..config() + }, + OracleConfig { + max_answer: MAX_ANSWER + 1, + ..config() + }, + OracleConfig { + max_up_bps: 0, + ..config() + }, + OracleConfig { + max_down_bps: 10_001, + ..config() + }, + ] { + refuses(&f, OracleError::InvalidConfig, || { + guards::valid_config(&f.e, &invalid) + }); + } +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let f = setup(); + holds(&f, || guards::setup_open(&f.e)); + + f.finish_setup(); + refuses(&f, OracleError::SetupClosed, || guards::setup_open(&f.e)); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, OracleError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.oracle.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let f = setup(); + holds(&f, || guards::direct_change_allowed(&f.e)); + + f.finish_setup(); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_change_allowed(&f.e) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 100)); + + f.finish_setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} + +#[test] +fn direct_config_allowed_refuses_all_but_a_shorter_window_after_setup() { + let f = setup(); + let longer_window = OracleConfig { + freshness_duration: 7_200, + ..config() + }; + let shorter_window = OracleConfig { + freshness_duration: 1_800, + ..config() + }; + let tighter_cap = OracleConfig { + max_up_bps: 500, + ..config() + }; + holds(&f, || guards::direct_config_allowed(&f.e, &longer_window)); + + f.finish_setup(); + holds(&f, || guards::direct_config_allowed(&f.e, &config())); + holds(&f, || guards::direct_config_allowed(&f.e, &shorter_window)); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_config_allowed(&f.e, &longer_window) + }); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_config_allowed(&f.e, &tighter_cap) + }); +} + +#[test] +fn change_in_bounds_refuses_an_invalid_config() { + let f = setup(); + let upgrade = OracleChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + let grant = OracleChange::GrantRole(symbol_short!("attester"), Address::generate(&f.e)); + let unbounded = OracleChange::Config(OracleConfig { + max_answer: MAX_ANSWER + 1, + ..config() + }); + + holds(&f, || guards::change_in_bounds(&f.e, &upgrade)); + holds(&f, || guards::change_in_bounds(&f.e, &grant)); + holds(&f, || { + guards::change_in_bounds(&f.e, &OracleChange::Config(config())) + }); + refuses(&f, OracleError::InvalidConfig, || { + guards::change_in_bounds(&f.e, &unbounded) + }); +} + +#[test] +fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { + let f = setup(); + f.finish_setup(); + let upgrade = OracleChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + let grant = OracleChange::GrantRole(symbol_short!("attester"), Address::generate(&f.e)); + holds(&f, || guards::not_a_second_upgrade(&f.e, &upgrade)); + + f.oracle.propose_change(&upgrade); + refuses(&f, OracleError::UpgradeAlreadyPending, || { + guards::not_a_second_upgrade(&f.e, &upgrade) + }); + holds(&f, || guards::not_a_second_upgrade(&f.e, &grant)); +} + +#[test] +fn consumable_refuses_a_feed_that_is_not_valid() { + let f = setup(); + refuses(&f, OracleError::NotConsumable, || guards::consumable(&f.e)); + + f.attest(SCALE); + holds(&f, || guards::consumable(&f.e)); + + f.oracle.pause_nav(&f.guardian); + refuses(&f, OracleError::NotConsumable, || guards::consumable(&f.e)); +} + +#[test] +fn nav_not_paused_refuses_while_the_nav_is_paused() { + let f = setup(); + holds(&f, || guards::nav_not_paused(&f.e)); + + f.oracle.pause_nav(&f.guardian); + refuses(&f, OracleError::NavPaused, || guards::nav_not_paused(&f.e)); +} + +#[test] +fn nav_paused_refuses_until_the_nav_is_paused() { + let f = setup(); + refuses(&f, OracleError::NavNotPaused, || guards::nav_paused(&f.e)); + + f.oracle.pause_nav(&f.guardian); + holds(&f, || guards::nav_paused(&f.e)); +} + +#[test] +fn nav_in_band_refuses_a_value_outside_the_band() { + let f = setup(); + let cfg = config(); + holds(&f, || guards::nav_in_band(&f.e, &cfg, cfg.min_answer)); + holds(&f, || guards::nav_in_band(&f.e, &cfg, cfg.max_answer)); + refuses(&f, OracleError::NavOutOfBand, || { + guards::nav_in_band(&f.e, &cfg, cfg.min_answer - 1) + }); + refuses(&f, OracleError::NavOutOfBand, || { + guards::nav_in_band(&f.e, &cfg, cfg.max_answer + 1) + }); +} + +#[test] +fn expires_in_future_refuses_a_report_born_stale() { + let f = setup(); + let now = f.e.ledger().timestamp(); + holds(&f, || guards::expires_in_future(&f.e, now + 1)); + refuses(&f, OracleError::ExpiresInPast, || { + guards::expires_in_future(&f.e, now) + }); +} + +#[test] +fn proof_hash_not_empty_refuses_an_all_zero_hash() { + let f = setup(); + let mut last_byte_set = [0; 32]; + last_byte_set[31] = 1; + let set = BytesN::from_array(&f.e, &last_byte_set); + let empty = BytesN::from_array(&f.e, &[0; 32]); + + holds(&f, || guards::proof_hash_not_empty(&f.e, &set)); + refuses(&f, OracleError::EmptyProofHash, || { + guards::proof_hash_not_empty(&f.e, &empty) + }); +} + +#[test] +fn proof_uri_fits_refuses_a_uri_past_the_limit() { + let f = setup(); + let at_limit = String::from_bytes(&f.e, &[b'x'; MAX_PROOF_URI_LEN as usize]); + let past_it = String::from_bytes(&f.e, &[b'x'; MAX_PROOF_URI_LEN as usize + 1]); + + holds(&f, || guards::proof_uri_fits(&f.e, &at_limit)); + refuses(&f, OracleError::ProofUriTooLong, || { + guards::proof_uri_fits(&f.e, &past_it) + }); +} + +#[test] +fn cooldown_elapsed_refuses_a_report_inside_the_cooldown() { + let f = setup(); + let cfg = config(); + let now = f.e.ledger().timestamp(); + let elapsed = report_at(&f.e, SCALE, now - cfg.cooldown_secs); + let running = report_at(&f.e, SCALE, now - cfg.cooldown_secs + 1); + + holds(&f, || guards::cooldown_elapsed(&f.e, &cfg, &elapsed)); + refuses(&f, OracleError::CooldownActive, || { + guards::cooldown_elapsed(&f.e, &cfg, &running) + }); +} + +#[test] +fn within_deviation_refuses_a_move_past_either_cap() { + let f = setup(); + let cfg = config(); + let last = report_at(&f.e, SCALE, 0); + let ten_percent = SCALE / 10; + + holds(&f, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE + ten_percent) + }); + holds(&f, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE - ten_percent) + }); + refuses(&f, OracleError::DeviationExceeded, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE + ten_percent + 1) + }); + refuses(&f, OracleError::DeviationExceeded, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE - ten_percent - 1) + }); +} diff --git a/contracts/nav-oracle/src/test/mod.rs b/contracts/nav-oracle/src/test/mod.rs new file mode 100644 index 00000000..82cdbf2e --- /dev/null +++ b/contracts/nav-oracle/src/test/mod.rs @@ -0,0 +1,29 @@ +mod governance; +mod guards; +mod oracle; + +use crate::OracleError; + +/// Asserts a call was refused with exactly this error. +pub(crate) fn refused( + result: Result, Result>, + expected: OracleError, +) { + assert_eq!(result, Err(Ok(expected.into()))); +} + +/// Asserts a call failed for lack of a valid signature, or because the callee +/// answers no interface the host can invoke: either way, the host's own +/// refusal of the invocation, not one of the oracle's named errors. +pub(crate) fn missing_auth( + result: Result, Result>, +) { + use soroban_sdk::xdr::{ScErrorCode, ScErrorType}; + assert_eq!( + result, + Err(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Context, + ScErrorCode::InvalidAction + ))) + ); +} diff --git a/contracts/nav-oracle/src/test/oracle.rs b/contracts/nav-oracle/src/test/oracle.rs new file mode 100644 index 00000000..e824c2e5 --- /dev/null +++ b/contracts/nav-oracle/src/test/oracle.rs @@ -0,0 +1,890 @@ +extern crate std; + +use soroban_sdk::{ + symbol_short, + testutils::{ + storage::Persistent as _, Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke, + }, + Address, BytesN, Env, Event as _, IntoVal, String, +}; + +use stellar_access::access_control::AccessControlError; + +use crate::state::DataKey; +use crate::{ + NavInput, NavOracleContract, NavOracleContractClient, OracleConfig, OracleError, OracleState, +}; + +use super::{missing_auth, refused}; + +const SCALE: i128 = 1_000_000_000_000_000_000; +const DAY_IN_LEDGERS: u32 = 17_280; + +struct Fixture<'a> { + #[allow(dead_code)] + e: Env, + oracle: NavOracleContractClient<'a>, + admin: Address, + attester: Address, + guardian: Address, +} + +impl Fixture<'_> { + /// Signs one call to the oracle as `who` and nothing else. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.oracle.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } +} + +fn config() -> OracleConfig { + OracleConfig { + freshness_duration: 3600, + cooldown_secs: 60, + max_up_bps: 1_000, + max_down_bps: 1_000, // 10% + min_answer: SCALE / 2, + max_answer: SCALE * 100, + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + + let admin = Address::generate(&e); + let attester = Address::generate(&e); + let guardian = Address::generate(&e); + let cfg = config(); + let addr = e.register( + NavOracleContract, + (admin.clone(), attester.clone(), guardian.clone(), cfg), + ); + + Fixture { + oracle: NavOracleContractClient::new(&e, &addr), + admin, + attester, + guardian, + e, + } +} + +fn proof(e: &Env) -> BytesN<32> { + BytesN::from_array(e, &[7; 32]) +} + +fn input(e: &Env, nav: i128, expires_at: u64) -> NavInput { + NavInput { + nav_per_share: nav, + expires_at, + proof_hash: proof(e), + proof_uri: String::from_str(e, "ipfs://strata-proof"), + } +} + +#[test] +fn attest_then_valid_and_consumable() { + let f = setup(); + // No record yet: stale, not consumable. + assert_eq!(f.oracle.state(), OracleState::Stale); + assert!(f.oracle.is_stale()); + + let r = input(&f.e, SCALE, 1_000_000); + f.oracle.attest(&r, &f.attester); + + assert_eq!(f.oracle.state(), OracleState::Valid); + assert_eq!(f.oracle.nav_per_share(), SCALE); + f.oracle.ensure_consumable(); // must not panic +} + +#[test] +fn heartbeat_expiry_makes_it_stale() { + let f = setup(); + let r = input(&f.e, SCALE, 1_000_000); + f.oracle.attest(&r, &f.attester); + assert_eq!(f.oracle.state(), OracleState::Valid); + + // Advance past heartbeat (3600s from obs stamp 10_000). + f.e.ledger().set_timestamp(10_000 + 3601); + assert_eq!(f.oracle.state(), OracleState::Stale); + refused(f.oracle.try_ensure_consumable(), OracleError::NotConsumable); +} + +#[test] +fn the_nav_pause_holds_regardless_of_freshness() { + let f = setup(); + let r = input(&f.e, SCALE, 1_000_000); + f.oracle.attest(&r, &f.attester); + assert_eq!(f.oracle.state(), OracleState::Valid); + + f.oracle.set_nav_paused(&true); + assert_eq!(f.oracle.state(), OracleState::Paused); + refused(f.oracle.try_ensure_consumable(), OracleError::NotConsumable); + + f.oracle.set_nav_paused(&false); + assert_eq!(f.oracle.state(), OracleState::Valid); +} + +#[test] +fn attest_rejects_out_of_band() { + let f = setup(); + // Below min_answer. + let r = input(&f.e, SCALE / 4, 1_000_000); + refused( + f.oracle.try_attest(&r, &f.attester), + OracleError::NavOutOfBand, + ); + // Zero is out of band (min_answer > 0). + let r0 = input(&f.e, 0, 1_000_000); + refused( + f.oracle.try_attest(&r0, &f.attester), + OracleError::NavOutOfBand, + ); +} + +#[test] +fn attest_enforces_cooldown_and_deviation() { + let f = setup(); + let r1 = input(&f.e, SCALE, 1_000_000); + f.oracle.attest(&r1, &f.attester); + + // Cooldown: only 30s later (< 60), reject. + f.e.ledger().set_timestamp(10_030); + let r2 = input(&f.e, SCALE, 1_000_000); + refused( + f.oracle.try_attest(&r2, &f.attester), + OracleError::CooldownActive, + ); + + // Past cooldown now. + f.e.ledger().set_timestamp(10_100); + // Deviation: +20% exceeds the 10% cap. + let big = input(&f.e, SCALE + SCALE / 5, 1_000_000); + refused( + f.oracle.try_attest(&big, &f.attester), + OracleError::DeviationExceeded, + ); + + // Valid update: +5% within cap. + let ok = input(&f.e, SCALE + SCALE / 20, 1_000_000); + f.oracle.attest(&ok, &f.attester); + assert_eq!(f.oracle.nav_per_share(), SCALE + SCALE / 20); +} + +/// The contract stamps ledger time and derives `expires_at` from its own +/// freshness window. +#[test] +fn attest_stamps_its_own_timestamp() { + let f = setup(); + let r = input(&f.e, SCALE, 1_000_000); + f.oracle.attest(&r, &f.attester); + + let stored = f.oracle.latest(); + assert_eq!(stored.accepted_at, 10_000); + assert_eq!(stored.expires_at, 10_000 + 3600); +} + +#[test] +fn attesting_publishes_the_stored_valuation() { + let f = setup(); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.oracle.address), + [crate::NavAttested { + attester: f.attester.clone(), + nav_per_share: SCALE, + expires_at: 10_000 + 3600, + proof_hash: proof(&f.e), + proof_uri: String::from_str(&f.e, "ipfs://strata-proof"), + } + .to_xdr(&f.e, &f.oracle.address)] + ); +} + +#[test] +fn the_latest_valuation_keeps_a_full_ttl_while_it_is_read() { + let f = setup(); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + + f.e.ledger() + .with_mut(|l| l.sequence_number += 2 * DAY_IN_LEDGERS); + let decayed = f.e.as_contract(&f.oracle.address, || { + f.e.storage().persistent().get_ttl(&DataKey::Latest) + }); + assert!(decayed < strata_storage::PERSISTENT_EXTEND_AMOUNT); + + f.oracle.latest(); + + let restored = f.e.as_contract(&f.oracle.address, || { + f.e.storage().persistent().get_ttl(&DataKey::Latest) + }); + assert_eq!(restored, strata_storage::PERSISTENT_EXTEND_AMOUNT); +} + +#[test] +fn set_config_takes_effect() { + let f = setup(); + let r1 = input(&f.e, SCALE, 1_000_000); + f.oracle.attest(&r1, &f.attester); + + // Tighten the per-attestation deviation band to 1%; a subsequent +5% move + // that the original 10% band would admit is now rejected. + let tight = OracleConfig { + max_up_bps: 100, + max_down_bps: 100, + ..config() + }; + f.oracle.set_config(&tight); + + f.e.ledger().set_timestamp(10_100); + let five_pct = input(&f.e, SCALE + SCALE / 20, 1_000_000); + refused( + f.oracle.try_attest(&five_pct, &f.attester), + OracleError::DeviationExceeded, + ); +} + +#[test] +fn attest_is_role_gated() { + let f = setup(); + let stranger = Address::generate(&f.e); + let r = input(&f.e, SCALE, 1_000_000); + assert_eq!( + f.oracle.try_attest(&r, &stranger), + Err(Ok(AccessControlError::Unauthorized.into())) + ); +} + +#[test] +fn narrowing_the_band_demotes_a_stored_record() { + let f = setup(); + let r = input(&f.e, SCALE, 1_000_000); + f.oracle.attest(&r, &f.attester); + assert_eq!(f.oracle.state(), OracleState::Valid); + + let narrow = OracleConfig { + max_answer: SCALE / 2, + ..config() + }; + f.oracle.set_config(&narrow); + + assert_eq!(f.oracle.state(), OracleState::Stale); + assert!(f.oracle.is_stale()); + refused(f.oracle.try_ensure_consumable(), OracleError::NotConsumable); +} + +#[test] +#[should_panic(expected = "#3001")] +fn constructor_rejects_an_unbounded_max_answer() { + let e = Env::default(); + let admin = Address::generate(&e); + let attester = Address::generate(&e); + let cfg = OracleConfig { + max_answer: i128::MAX, + ..config() + }; + e.register( + NavOracleContract, + (admin, attester, Address::generate(&e), cfg), + ); +} + +#[test] +#[should_panic(expected = "#3001")] +fn constructor_rejects_a_deviation_above_one_hundred_percent() { + let e = Env::default(); + let admin = Address::generate(&e); + let attester = Address::generate(&e); + let cfg = OracleConfig { + max_up_bps: 10_001, + max_down_bps: 10_001, + ..config() + }; + e.register( + NavOracleContract, + (admin, attester, Address::generate(&e), cfg), + ); +} + +#[test] +fn set_config_rejects_an_unbounded_max_answer() { + let f = setup(); + let cfg = OracleConfig { + max_answer: i128::MAX, + ..config() + }; + refused(f.oracle.try_set_config(&cfg), OracleError::InvalidConfig); +} + +#[test] +fn set_config_rejects_a_zero_freshness_duration() { + let f = setup(); + let cfg = OracleConfig { + freshness_duration: 0, + ..config() + }; + refused(f.oracle.try_set_config(&cfg), OracleError::InvalidConfig); +} + +#[test] +fn only_admin_can_set_config() { + let f = setup(); + let cfg = config(); + + f.e.set_auths(&[]); + missing_auth(f.oracle.try_set_config(&cfg)); + + f.e.mock_all_auths(); + f.oracle.set_config(&cfg); +} + +#[test] +fn the_guardian_pauses_the_nav_but_unpausing_needs_governance() { + let f = setup(); + let r = input(&f.e, SCALE, 1_000_000); + f.oracle.attest(&r, &f.attester); + assert_eq!(f.oracle.state(), OracleState::Valid); + + f.oracle.pause_nav(&f.guardian); + assert_eq!(f.oracle.state(), OracleState::Paused); + + f.sign_only_as(&f.guardian, "set_nav_paused", (false,).into_val(&f.e)); + missing_auth(f.oracle.try_set_nav_paused(&false)); + f.e.set_auths(&[]); + missing_auth(f.oracle.try_set_nav_paused(&false)); + assert_eq!(f.oracle.state(), OracleState::Paused); + + f.e.mock_all_auths(); + f.oracle.set_nav_paused(&false); + assert_eq!(f.oracle.state(), OracleState::Valid); +} + +#[test] +fn pausing_the_nav_is_limited_to_the_guardian() { + let f = setup(); + let stranger = Address::generate(&f.e); + + for who in [&stranger, &f.attester, &f.admin] { + assert_eq!( + f.oracle.try_pause_nav(who), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + } + assert_eq!(f.oracle.state(), OracleState::Stale); +} + +/// Builds the fixture with a caller-chosen cap, so both directions are testable. +fn setup_with(cfg: OracleConfig) -> Fixture<'static> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + + let admin = Address::generate(&e); + let attester = Address::generate(&e); + let guardian = Address::generate(&e); + let addr = e.register( + NavOracleContract, + (admin.clone(), attester.clone(), guardian.clone(), cfg), + ); + + Fixture { + oracle: NavOracleContractClient::new(&e, &addr), + admin, + attester, + guardian, + e, + } +} + +#[test] +fn a_full_downside_cap_lets_a_large_loss_land_at_once() { + let mut cfg = config(); + cfg.max_down_bps = 10_000; + let f = setup_with(cfg); + + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + f.e.ledger().set_timestamp(10_100); + + // A 40% fall, far past the 10% upward cap, is admitted at once. + let crash = input(&f.e, SCALE * 60 / 100, 1_000_000); + f.oracle.attest(&crash, &f.attester); + assert_eq!(f.oracle.nav_per_share(), SCALE * 60 / 100); +} + +#[test] +fn a_full_downside_cap_still_bounds_a_rise() { + let mut cfg = config(); + cfg.max_down_bps = 10_000; + let f = setup_with(cfg); + + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + f.e.ledger().set_timestamp(10_100); + + let spike = input(&f.e, SCALE * 140 / 100, 1_000_000); + refused( + f.oracle.try_attest(&spike, &f.attester), + OracleError::DeviationExceeded, + ); +} + +#[test] +fn a_configured_downside_still_bounds_a_fall() { + let f = setup(); + + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + f.e.ledger().set_timestamp(10_100); + + let crash = input(&f.e, SCALE * 60 / 100, 1_000_000); + refused( + f.oracle.try_attest(&crash, &f.attester), + OracleError::DeviationExceeded, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3001)")] +fn constructor_rejects_a_zero_upside_cap() { + let e = Env::default(); + let admin = Address::generate(&e); + let cfg = OracleConfig { + max_up_bps: 0, + ..config() + }; + e.register( + NavOracleContract, + (admin.clone(), admin.clone(), admin, cfg), + ); +} + +#[test] +fn the_record_clears_only_while_the_nav_is_paused() { + let f = setup(); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + + refused(f.oracle.try_clear_latest(), OracleError::NavNotPaused); + + f.oracle.pause_nav(&f.guardian); + f.oracle.clear_latest(); + + // With no stored record, one in-band value lands however far it sits. + f.oracle.set_nav_paused(&false); + f.e.ledger().set_timestamp(10_100); + let far = input(&f.e, SCALE * 50, 1_000_000); + f.oracle.attest(&far, &f.attester); + assert_eq!(f.oracle.nav_per_share(), SCALE * 50); +} + +#[test] +fn only_admin_can_clear_latest() { + let f = setup(); + let stranger = Address::generate(&f.e); + let r = input(&f.e, SCALE, 1_000_000); + f.oracle.attest(&r, &f.attester); + f.oracle.pause_nav(&f.guardian); + + for who in [&stranger, &f.attester, &f.guardian] { + f.sign_only_as(who, "clear_latest", soroban_sdk::Vec::new(&f.e)); + missing_auth(f.oracle.try_clear_latest()); + } +} + +#[test] +fn every_oracle_authority_is_readable_and_rotatable() { + let f = setup(); + let next = Address::generate(&f.e); + + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); + assert_eq!( + f.oracle.get_role_member(&symbol_short!("attester"), &0), + f.attester + ); + + f.oracle + .grant_role(&next, &symbol_short!("guardian"), &f.admin); + f.oracle + .revoke_role(&f.guardian, &symbol_short!("guardian"), &f.admin); + + assert_eq!( + f.oracle.try_pause_nav(&f.guardian), + Err(Ok(AccessControlError::Unauthorized.into())) + ); + f.oracle.pause_nav(&next); +} + +#[test] +fn the_oracle_admin_cannot_renounce_itself_away() { + let f = setup(); + refused(f.oracle.try_renounce_admin(), OracleError::AdminRequired); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +/// With the downward cap at 100%, `min_answer` is all that bounds one fall. +#[test] +fn a_full_downside_cap_leaves_min_answer_as_the_only_floor() { + let mut cfg = config(); + cfg.max_down_bps = 10_000; + let f = setup_with(cfg); + + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + f.e.ledger().set_timestamp(10_100); + + // Straight to the floor in one step, whatever the distance. + let floor = config().min_answer; + f.oracle.attest(&input(&f.e, floor, 1_000_000), &f.attester); + assert_eq!(f.oracle.nav_per_share(), floor); + + // Below the floor is refused, so min_answer is the only bound. + f.e.ledger().set_timestamp(10_200); + refused( + f.oracle + .try_attest(&input(&f.e, floor - 1, 1_000_000), &f.attester), + OracleError::NavOutOfBand, + ); +} + +#[test] +fn a_floor_below_a_millionth_of_an_asset_unit_per_share_is_refused() { + let f = setup(); + assert_eq!(crate::MIN_ANSWER, 1_000_000_000_000); + assert_eq!( + f.oracle + .try_set_config(&OracleConfig { + min_answer: crate::MIN_ANSWER - 1, + ..config() + }) + .err(), + Some(Ok(crate::OracleError::InvalidConfig.into())), + ); + f.oracle.set_config(&OracleConfig { + min_answer: crate::MIN_ANSWER, + ..config() + }); +} + +#[test] +fn a_stored_floor_below_the_minimum_stays_until_a_config_change_raises_it() { + let f = setup(); + let old = OracleConfig { + min_answer: 1, + ..config() + }; + f.e.as_contract(&f.oracle.address, || crate::state::set_config(&f.e, &old)); + + assert_eq!(f.oracle.config(), old); + assert_eq!( + f.oracle.try_set_config(&old).err(), + Some(Ok(crate::OracleError::InvalidConfig.into())), + ); + f.oracle.set_config(&OracleConfig { + min_answer: crate::MIN_ANSWER, + ..old + }); +} + +#[test] +fn attested_at_is_when_the_contract_took_the_report() { + let f = setup(); + f.e.ledger().set_timestamp(50_000); + + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + + assert_eq!(f.oracle.attested_at(), 50_000); +} + +#[test] +fn attested_at_without_a_report_is_refused() { + let f = setup(); + + refused(f.oracle.try_attested_at(), OracleError::NoRecord); +} + +#[test] +fn a_reporter_expiry_inside_the_window_is_kept() { + let f = setup(); + f.oracle + .attest(&input(&f.e, SCALE, 10_000 + 600), &f.attester); + + assert_eq!(f.oracle.latest().expires_at, 10_000 + 600); + f.e.ledger().set_timestamp(10_000 + 601); + assert_eq!(f.oracle.state(), OracleState::Stale); +} + +#[test] +fn a_reporter_expiry_past_the_window_is_cut_to_the_window() { + let f = setup(); + f.oracle + .attest(&input(&f.e, SCALE, 10_000 + 10_000), &f.attester); + + assert_eq!(f.oracle.latest().expires_at, 10_000 + 3600); +} + +#[test] +fn attesting_is_refused_while_the_nav_is_paused() { + let f = setup(); + f.oracle.pause_nav(&f.guardian); + + assert_eq!( + f.oracle + .try_attest(&input(&f.e, SCALE, 1_000_000), &f.attester) + .err(), + Some(Ok(crate::OracleError::NavPaused.into())) + ); + + f.oracle.set_nav_paused(&false); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + assert_eq!(f.oracle.state(), OracleState::Valid); +} + +#[test] +fn two_reports_in_one_ledger_are_refused_at_the_shortest_cooldown() { + let mut cfg = config(); + cfg.cooldown_secs = crate::MIN_COOLDOWN_SECS; + let f = setup_with(cfg); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + + assert_eq!( + f.oracle + .try_attest(&input(&f.e, SCALE, 1_000_000), &f.attester) + .err(), + Some(Ok(crate::OracleError::CooldownActive.into())) + ); + + f.e.ledger() + .set_timestamp(10_000 + crate::MIN_COOLDOWN_SECS); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); +} + +fn refuses_config(cfg: OracleConfig) { + let f = setup(); + assert_eq!( + f.oracle.try_set_config(&cfg).err(), + Some(Ok(crate::OracleError::InvalidConfig.into())) + ); +} + +#[test] +fn a_zero_cooldown_is_refused() { + refuses_config(OracleConfig { + cooldown_secs: 0, + ..config() + }); +} + +#[test] +fn a_freshness_window_past_the_maximum_is_refused() { + refuses_config(OracleConfig { + freshness_duration: crate::MAX_FRESHNESS_SECS + 1, + ..config() + }); +} + +#[test] +fn a_max_answer_past_the_ceiling_is_refused() { + refuses_config(OracleConfig { + max_answer: crate::MAX_ANSWER + 1, + ..config() + }); +} + +/// A new report must always be able to replace the last one before the +/// freshness window ends, or the feed would sit stale through every cooldown. +#[test] +fn a_cooldown_longer_than_the_freshness_window_is_refused() { + refuses_config(OracleConfig { + cooldown_secs: 3_601, + ..config() + }); +} + +#[test] +fn a_zero_downside_cap_is_refused() { + refuses_config(OracleConfig { + max_down_bps: 0, + ..config() + }); +} + +#[test] +fn the_bounds_themselves_are_accepted() { + let f = setup(); + f.oracle.set_config(&OracleConfig { + freshness_duration: crate::MAX_FRESHNESS_SECS, + cooldown_secs: crate::MIN_COOLDOWN_SECS, + max_up_bps: 10_000, + max_down_bps: 10_000, + min_answer: crate::MIN_ANSWER, + max_answer: crate::MAX_ANSWER, + }); +} + +fn implements_the_feed() {} + +#[test] +fn the_oracle_answers_the_feed_interface_the_vault_reads() { + implements_the_feed::(); + let f = setup(); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + + let feed = strata_bindings::OracleFeedClient::new(&f.e, &f.oracle.address); + + assert_eq!(feed.state(), OracleState::Valid); + assert_eq!(feed.nav_per_share(), SCALE); + assert_eq!(feed.attested_at(), 10_000); + assert_eq!(feed.freshness_duration(), 3600); +} + +#[test] +fn a_missing_config_is_refused_by_name() { + let f = setup(); + f.e.as_contract(&f.oracle.address, || { + f.e.storage().instance().remove(&DataKey::Config) + }); + + assert_eq!( + f.oracle.try_config(), + Err(Ok(crate::OracleError::NotInitialized.into())) + ); +} + +mod proof_reference { + use soroban_sdk::contracttype; + use soroban_sdk::xdr::{ScErrorCode, ScErrorType}; + + use super::*; + use crate::{OracleError, MAX_PROOF_URI_LEN}; + + #[test] + fn the_latest_report_carries_its_proof() { + let f = setup(); + + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + + let latest = f.oracle.latest(); + assert_eq!(latest.proof_hash, proof(&f.e)); + assert_eq!( + latest.proof_uri, + String::from_str(&f.e, "ipfs://strata-proof") + ); + } + + #[test] + fn a_report_without_a_proof_hash_is_refused() { + let f = setup(); + let mut unproven = input(&f.e, SCALE, 1_000_000); + unproven.proof_hash = BytesN::from_array(&f.e, &[0; 32]); + + assert_eq!( + f.oracle.try_attest(&unproven, &f.attester).err(), + Some(Ok(OracleError::EmptyProofHash.into())), + ); + } + + #[test] + fn a_proof_uri_past_the_limit_is_refused() { + let f = setup(); + let mut long = input(&f.e, SCALE, 1_000_000); + long.proof_uri = String::from_bytes(&f.e, &[b'x'; (MAX_PROOF_URI_LEN + 1) as usize]); + + assert_eq!( + f.oracle.try_attest(&long, &f.attester).err(), + Some(Ok(OracleError::ProofUriTooLong.into())), + ); + } + + #[test] + fn a_proof_uri_at_the_limit_is_stored_and_published() { + let f = setup(); + let mut longest = input(&f.e, SCALE, 1_000_000); + longest.proof_uri = String::from_bytes(&f.e, &[b'x'; MAX_PROOF_URI_LEN as usize]); + + f.oracle.attest(&longest, &f.attester); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.oracle.address), + [crate::NavAttested { + attester: f.attester.clone(), + nav_per_share: SCALE, + expires_at: 10_000 + 3600, + proof_hash: proof(&f.e), + proof_uri: longest.proof_uri.clone(), + } + .to_xdr(&f.e, &f.oracle.address)] + ); + assert_eq!(f.oracle.latest().proof_uri, longest.proof_uri); + } + + #[test] + fn a_hash_without_a_uri_is_enough() { + let f = setup(); + let mut bare = input(&f.e, SCALE, 1_000_000); + bare.proof_uri = String::from_str(&f.e, ""); + + f.oracle.attest(&bare, &f.attester); + + assert_eq!(f.oracle.latest().proof_uri.len(), 0); + } + + /// The report as earlier releases stored it: same fields, same encoding. + #[contracttype] + struct EarlierNavReport { + nav_per_share: i128, + expires_at: u64, + timestamp: u64, + } + + /// What a caller sees when the host cannot run a call, such as one that + /// reads an entry it cannot decode. A contract error carries its own code. + fn host_refusal() -> soroban_sdk::Error { + soroban_sdk::Error::from_type_and_code(ScErrorType::Context, ScErrorCode::InvalidAction) + } + + #[test] + fn a_report_of_the_earlier_shape_is_cleared_and_replaced() { + let f = setup(); + f.e.as_contract(&f.oracle.address, || { + f.e.storage().persistent().set( + &DataKey::Latest, + &EarlierNavReport { + nav_per_share: SCALE, + expires_at: 10_000 + 3600, + timestamp: 10_000, + }, + ) + }); + // Past the cooldown and inside the cap: only the old entry can refuse it. + f.e.ledger().set_timestamp(10_100); + let next = input(&f.e, SCALE + SCALE / 20, 1_000_000); + + assert_eq!(f.oracle.try_latest().err(), Some(Ok(host_refusal()))); + assert_eq!(f.oracle.try_state().err(), Some(Ok(host_refusal()))); + assert_eq!( + f.oracle.try_attest(&next, &f.attester).err(), + Some(Ok(host_refusal())) + ); + + f.oracle.pause_nav(&f.guardian); + assert_eq!(f.oracle.state(), OracleState::Paused); + f.oracle.clear_latest(); + f.oracle.set_nav_paused(&false); + f.oracle.attest(&next, &f.attester); + + let latest = f.oracle.latest(); + assert_eq!(latest.nav_per_share, SCALE + SCALE / 20); + assert_eq!(latest.accepted_at, 10_100); + assert_eq!(latest.proof_hash, proof(&f.e)); + assert_eq!( + latest.proof_uri, + String::from_str(&f.e, "ipfs://strata-proof") + ); + } +} diff --git a/contracts/share-token/Cargo.toml b/contracts/share-token/Cargo.toml index 3aecab2c..d8588513 100644 --- a/contracts/share-token/Cargo.toml +++ b/contracts/share-token/Cargo.toml @@ -15,8 +15,15 @@ stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } stellar-contract-utils = { workspace = true } +strata-storage = { path = "../../crates/strata-storage" } +strata-timelock = { path = "../../crates/strata-timelock" } +strata-bindings = { path = "../../crates/strata-bindings" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } compliance = { path = "../compliance" } identity-verifier = { path = "../identity-verifier" } + +[features] +# Builds a stand-in for a later token version, used only by the upgrade tests. +upgrade_probe = [] diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index e69d419a..a796f740 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,4 +1,8 @@ use crate::error::ShareTokenError; +use crate::event::LegalTransfer; +use crate::extend; +use crate::governance::{self, TokenChange, TokenPendingChange}; +use crate::guards; use crate::roles::COMPLIANCE_ROLE; use soroban_sdk::{ @@ -6,17 +10,41 @@ use soroban_sdk::{ }; use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; -use stellar_macros::{only_admin, only_any_role, only_role}; +use stellar_contract_utils::upgradeable; +use stellar_macros::{only_admin, only_role}; use stellar_tokens::{ - fungible::{Base, FungibleToken}, + fungible::{Base, ContractOverrides, FungibleToken}, rwa::{RWAToken, RWA}, }; +use strata_bindings::VaultBinding; + +/// Longest order reference a legal transfer accepts, in bytes. +pub const MAX_REFERENCE_LEN: u32 = 128; #[contract] pub struct ShareToken; #[contractimpl] impl ShareToken { + /// Creates the token with 7 decimals, its admin, its compliance authority + /// and the two contracts that check each `transfer` and mint. + /// + /// # Arguments + /// + /// * `name`, `symbol` - The token's metadata. + /// * `admin` - Pauses and changes the token. + /// * `compliance_authority` - Gets the `compliance` role: freezes, and + /// with the admin, legal transfers and recoveries. + /// * `compliance` - The compliance contract. + /// * `identity_verifier` - The identity verifier. + /// + /// # Events + /// + /// * `RoleGranted`, `ComplianceSet` and `IdentityVerifierSet`. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor( e: &Env, name: String, @@ -28,41 +56,584 @@ impl ShareToken { ) { Base::set_metadata(e, 7, name, symbol); access_control::set_admin(e, &admin); - access_control::grant_role_no_auth( - e, - &compliance_authority, - &Symbol::new(e, COMPLIANCE_ROLE), - &admin, - ); + let compliance_role = Symbol::new(e, COMPLIANCE_ROLE); + access_control::grant_role_no_auth(e, &compliance_authority, &compliance_role, &admin); + extend::role_entries(e, &compliance_authority, &compliance_role); RWA::set_compliance(e, &compliance); RWA::set_identity_verifier(e, &identity_verifier); + upgradeable::set_schema_version(e, 1); + strata_storage::bump_instance(e); + } + + /// The storage layout version, `1` until an upgrade migrates it. + pub fn schema_version(e: &Env) -> u32 { + upgradeable::get_schema_version(e) + } + + /// Names the vault whose timelock this token's changes wait for. Once, and + /// only during setup. + /// + /// # Arguments + /// + /// * `vault` - The vault. A contract that does not answer + /// `timelock_clock` fails the call. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has finished, or a vault is already bound. + /// + /// # Events + /// + /// * `VaultBound` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn bind_vault(e: &Env, vault: Address) { + strata_storage::bump_instance(e); + governance::bind_vault(e, &vault); + } + + /// Ends the window in which admin changes are instant. It never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `VaultNotBound` - No vault is bound. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn finish_setup(e: &Env) { + strata_storage::bump_instance(e); + governance::finish_setup(e); + } + + /// Queues a change behind the vault's timelock and returns its id. + /// + /// # Arguments + /// + /// * `change` - An upgrade, a role grant or revoke, an admin hand-over, + /// or a new compliance contract or identity verifier. + /// + /// # Errors + /// + /// * `ComplianceNotBound` - The new compliance contract does not list + /// this token, or answers `VaultBinding` but does not name this token's + /// vault with its setup closed. + /// * `VerifierRefusesVault` - The new verifier does not accept the vault. + /// * `UpgradeAlreadyPending` - An upgrade already waits. + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `WindDownActive` - The vault is winding down, and the change is not + /// a grant of `compliance`. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds; time the vault spends + /// paused from now on moves it later. + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn propose_change(e: &Env, change: TokenChange) -> u32 { + strata_storage::bump_instance(e); + governance::propose(e, change) + } + + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn cancel_change(e: &Env, id: u32) { + strata_storage::bump_instance(e); + governance::cancel(e, id); + } + + /// Lands a change whose delay has run. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down, and the change is not + /// a grant of `compliance`. + /// * `VaultPaused` - The vault is paused. + /// * `DelayBelowNotice` - The change's delay is shorter than the vault's + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time the vault spent paused + /// since, has not run. + /// * `ComplianceNotBound`, `VerifierRefusesVault` - The new contract no + /// longer passes the check `propose_change` made. + /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account + /// without the role. + /// * An `Upgrade` whose Wasm is not on the network fails the call. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `RoleGranted`, + /// `RoleRevoked`, `AdminTransferInitiated`, `ComplianceSet` or + /// `IdentityVerifierSet`. + /// + /// # Authorization + /// + /// The admin signs. + #[only_admin] + pub fn apply_change(e: &Env, id: u32) { + strata_storage::bump_instance(e); + governance::apply(e, id); + } + + /// Every change still waiting, with the time in seconds it may land on + /// the vault's clock as it reads now. + pub fn pending_changes(e: &Env) -> Vec { + governance::pending(e) + } + + /// Moves shares under a legal order, at once. The pause, freezes and the + /// compliance rules' check do not apply. + /// + /// # Arguments + /// + /// * `from` - The account debited. + /// * `to` - The account credited. + /// * `amount` - Share base units. + /// * `reference` - The order's reference, 1 to 128 bytes. + /// * `caller` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `compliance`. + /// * `CoSignRequired` - `caller` is the admin. + /// * `InvalidReference` - `reference` is empty or too long. + /// * `VaultAddress` - `from` or `to` is the vault. + /// * `RWAError::IdentityVerificationFailed` - `to` is not allowlisted. + /// * `RWAError::InsufficientBalance` - `amount` exceeds the balance. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer`, then `LegalTransfer`; `TokensUnfrozen` first when frozen + /// shares move. + /// + /// # Authorization + /// + /// `caller` and the admin sign, as two different accounts. + #[only_role(caller, "compliance")] + pub fn legal_transfer( + e: &Env, + from: Address, + to: Address, + amount: i128, + reference: String, + caller: Address, + ) { + guards::admin_co_signs(e, &caller); + guards::valid_reference(e, &reference); + guards::not_vault_address(e, &from); + guards::not_vault_address(e, &to); + guards::identity_verified(e, &to); + strata_storage::bump_instance(e); + RWA::forced_transfer(e, &from, &to, amount); + extend::balances(e, &[&from, &to]); + LegalTransfer { + from, + to, + amount, + reference, + } + .publish(e); + } +} + +#[contractimpl(contracttrait)] +impl VaultBinding for ShareToken { + /// The vault whose timelock this token's changes wait for, once + /// `bind_vault` has named it. + fn vault(e: &Env) -> Option
      { + strata_timelock::setup::source(e) + } + + /// Whether setup is still open, so admin changes are instant. + fn setup_open(e: &Env) -> bool { + strata_timelock::setup::is_open(e) } } #[contractimpl(contracttrait)] impl Pausable for ShareToken { - #[only_admin] + /// Stops `transfer` and `transfer_from`. Mints, burns, the vault's escrow + /// moves and legal transfers go on. + /// + /// # Arguments + /// + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `caller` is not the admin. + /// * `PausableError::EnforcedPause` - The token is already paused. + /// + /// # Events + /// + /// * `Paused` + /// + /// # Authorization + /// + /// `caller` signs. fn pause(e: &Env, caller: Address) { + guards::caller_is_admin(e, &caller); caller.require_auth(); + strata_storage::bump_instance(e); pausable::pause(e); } - #[only_admin] + + /// Lets `transfer` and `transfer_from` run again. + /// + /// # Arguments + /// + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `caller` is not the admin. + /// * `PausableError::ExpectedPause` - The token is not paused. + /// + /// # Events + /// + /// * `Unpaused` + /// + /// # Authorization + /// + /// `caller` signs. fn unpause(e: &Env, caller: Address) { + guards::caller_is_admin(e, &caller); caller.require_auth(); + strata_storage::bump_instance(e); pausable::unpause(e); } } +/// OpenZeppelin leaves the instance's lifetime to the token, so every call +/// that moves shares extends it before handing over to the library. So do +/// `balance` and `total_supply`, which may be the only calls during a wind-down. #[contractimpl(contracttrait)] impl FungibleToken for ShareToken { type ContractType = RWA; + + /// Moves shares between two accounts. Refused while the token is paused, + /// when either end is frozen or fails the identity check, and when the + /// compliance rules refuse the move. + /// + /// # Arguments + /// + /// * `from` - The sender. + /// * `to` - The receiver. A muxed address is accepted; the event leaves + /// out its id. + /// * `amount` - Share base units. + /// + /// # Errors + /// + /// * `VaultAddress` - `to` is the vault. + /// * `PausableError::EnforcedPause` - The token is paused. + /// * `RWAError::AddressFrozen` - `from` or `to` is frozen. + /// * `RWAError::InsufficientFreeTokens` - `amount` exceeds `from`'s + /// unfrozen shares. + /// * `RWAError::IdentityVerificationFailed` - `from` or `to` is not + /// allowlisted. + /// * `RWAError::TransferNotCompliant` - The compliance rules refuse it. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer` + /// + /// # Authorization + /// + /// `from` signs. + fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { + guards::not_vault_address(e, &to.address()); + strata_storage::bump_instance(e); + ::transfer(e, &from, &to, amount); + extend::balances(e, &[&from, &to.address()]); + } + + /// Moves shares against `spender`'s allowance, with the checks of + /// `transfer`. + /// + /// # Arguments + /// + /// * `spender` - Spends its allowance. + /// * `from` - The sender. + /// * `to` - The receiver. + /// * `amount` - Share base units. + /// + /// # Errors + /// + /// * `VaultAddress` - `to` is the vault. + /// * The errors of `transfer`. + /// * `FungibleTokenError::InsufficientAllowance` - `amount` exceeds the + /// allowance. + /// + /// # Events + /// + /// * `Transfer` + /// + /// # Authorization + /// + /// `spender` signs. + fn transfer_from(e: &Env, spender: Address, from: Address, to: Address, amount: i128) { + guards::not_vault_address(e, &to); + strata_storage::bump_instance(e); + ::transfer_from(e, &spender, &from, &to, amount); + extend::balances(e, &[&from, &to]); + } + + /// Sets how many of `owner`'s shares `spender` may move, replacing any + /// earlier allowance. + /// + /// # Arguments + /// + /// * `owner` - The holder. + /// * `spender` - The account allowed to spend. + /// * `amount` - Share base units. + /// * `live_until_ledger` - The last ledger number the allowance lasts. + /// + /// # Errors + /// + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `FungibleTokenError::InvalidLiveUntilLedger` - The ledger is beyond + /// the longest entry lifetime, or has passed while `amount` is positive. + /// + /// # Events + /// + /// * `Approve` + /// + /// # Authorization + /// + /// `owner` signs. + fn approve(e: &Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) { + strata_storage::bump_instance(e); + ::approve( + e, + &owner, + &spender, + amount, + live_until_ledger, + ); + } + + /// All shares in circulation, in share base units. Reading it extends + /// the token's instance. + fn total_supply(e: &Env) -> i128 { + strata_storage::bump_instance(e); + ::total_supply(e) + } + + /// The shares `account` holds, in share base units. Reading it extends + /// the token's instance. + /// + /// # Arguments + /// + /// * `account` - The holder. + fn balance(e: &Env, account: Address) -> i128 { + strata_storage::bump_instance(e); + ::balance(e, &account) + } } #[contractimpl(contracttrait)] impl AccessControl for ShareToken { + /// Grants `role` to `account` at once. Only during setup; after it, propose + /// a `GrantRole` change. + /// + /// # Arguments + /// + /// * `account` - The account that gets the role. + /// * `role` - `manager` (the vault) or `compliance`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// + /// # Events + /// + /// * `RoleGranted`, unless `account` already holds `role`. + /// + /// # Authorization + /// + /// `caller` signs. + fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); + access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); + } + + /// Takes `role` from `account`. Instant for `compliance`. Revoking + /// `manager` waits like a grant after setup, because the vault needs it + /// to pay claims: propose a `RevokeRole` change. Revoking the last + /// `compliance` holder stays instant; a successor's grant waits one + /// delay, during a wind-down too. + /// + /// # Arguments + /// + /// * `account` - The account that loses the role. + /// * `role` - `manager` or `compliance`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - `manager`, after setup. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// * `AccessControlError::RoleNotHeld` - `account` does not hold `role`. + /// + /// # Events + /// + /// * `RoleRevoked` + /// + /// # Authorization + /// + /// `caller` signs. + fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { + guards::direct_revoke_allowed(e, &role); + strata_storage::bump_instance(e); + access_control::revoke_role(e, &account, &role, &caller); + } + + /// Gives up `role`. The last `compliance` holder cannot: without one, no + /// freeze can be lifted until a grant waits out the timelock. + /// + /// # Arguments + /// + /// * `role` - The role to give up. + /// * `caller` - The holder. + /// + /// # Errors + /// + /// * `LastComplianceHolder` - `role` is `compliance` and has at most one + /// holder. + /// * `AccessControlError::RoleNotHeld` - `caller` does not hold `role`. + /// + /// # Events + /// + /// * `RoleRevoked` + /// + /// # Authorization + /// + /// `caller` signs. + fn renounce_role(e: &Env, role: Symbol, caller: Address) { + guards::not_last_compliance_holder(e, &role); + strata_storage::bump_instance(e); + access_control::renounce_role(e, &role, &caller); + } + + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. + fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { + guards::direct_hand_over_allowed(e, live_until_ledger); + strata_storage::bump_instance(e); + access_control::transfer_admin_role(e, &new_admin, live_until_ledger); + } + + /// Makes the nominee of a pending hand-over the admin. Refused while the + /// vault winds down: no admin power changes hands then. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `RoleTransferError::NoPendingTransfer` - No offer is pending. + /// * `RoleTransferError::TransferExpired` - The offer's last ledger has + /// passed. + /// + /// # Events + /// + /// * `AdminTransferCompleted` + /// + /// # Authorization + /// + /// The nominee signs. + fn accept_admin_transfer(e: &Env) { + guards::vault_not_winding_down(e); + strata_storage::bump_instance(e); + access_control::accept_admin_transfer(e); + } + + /// Refused. A role admin could grant that role with no delay. + /// + /// # Arguments + /// + /// * `role`, `admin_role` - Not read. + /// + /// # Errors + /// + /// * `UseTimelock` - Always. + fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { + panic_with_error!(e, ShareTokenError::UseTimelock); + } + /// Refused. A token without an admin could never be unpaused, never rotate - /// the manager role and never replace the vault that holds it. - /// `transfer_admin_role` and `accept_admin_transfer` are the way out. + /// the manager role and never replace the vault that holds it. The way out + /// is a hand-over: propose and apply an `Admin` change (`transfer_admin_role` + /// during setup), then the nominee calls `accept_admin_transfer`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, ShareTokenError::AdminRequired); } @@ -70,19 +641,131 @@ impl AccessControl for ShareToken { #[contractimpl(contracttrait)] impl RWAToken for ShareToken { + /// Mints shares: the vault paying a priced deposit. The pause does not + /// apply. + /// + /// # Arguments + /// + /// * `to` - The account credited. + /// * `amount` - Share base units. + /// * `operator` - A `manager`: the vault. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` is not a `manager`. + /// * `RWAError::IdentityVerificationFailed` - `to` is not allowlisted. + /// * `RWAError::MintNotCompliant` - The compliance rules refuse it. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `FungibleTokenError::MathOverflow` - The supply would overflow. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Mint` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "manager")] fn mint(e: &Env, to: Address, amount: i128, operator: Address) { + strata_storage::bump_instance(e); RWA::mint(e, &to, amount); + extend::balances(e, &[&to]); } + + /// Burns shares, frozen ones included, without their holder's + /// signature: the vault burns the escrow it holds, and a holder's shares + /// surrendered in a wind-down. The pause does not apply. + /// + /// # Arguments + /// + /// * `user_address` - The account debited. + /// * `amount` - Share base units. + /// * `operator` - A `manager`: the vault. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` is not a `manager`. + /// * `RWAError::InsufficientBalance` - `amount` exceeds the balance. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Burn`; `TokensUnfrozen` first when frozen shares burn. + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "manager")] fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { + strata_storage::bump_instance(e); RWA::burn(e, &user_address, amount); + extend::balances(e, &[&user_address]); } - /// The vault escrows redemptions through it; compliance intervenes with it. - #[only_any_role(operator, ["manager", "compliance"])] + + /// Moves shares without the sender's signature: the vault escrows + /// redemptions and returns escrow through it. The pause, freezes, the + /// identity check and the compliance rules' check do not apply. Compliance + /// moves shares with `legal_transfer` instead. + /// + /// # Arguments + /// + /// * `from` - The sender. + /// * `to` - The receiver. + /// * `amount` - Share base units. + /// * `operator` - A `manager`: the vault. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` is not a `manager`. + /// * `RWAError::InsufficientBalance` - `amount` exceeds `from`'s balance. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer`; `TokensUnfrozen` first when frozen shares move. + /// + /// # Authorization + /// + /// `operator` signs. + #[only_role(operator, "manager")] fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { + strata_storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); + extend::balances(e, &[&from, &to]); } + + /// Moves a lost account's whole balance, and any freeze on it, to the + /// recovery account the identity verifier names. Returns false when there + /// was nothing to move. + /// + /// # Arguments + /// + /// * `old_account` - The lost account. + /// * `new_account` - Its recovery account. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `CoSignRequired` - `operator` is the admin. + /// * `VaultAddress` - Either account is the vault. + /// * `RWAError::IdentityVerificationFailed` - `new_account` is not + /// allowlisted. + /// * `RWAError::IdentityMismatch` - The verifier names another recovery + /// account, or none. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer` and `RecoverySuccess`, with the freeze events when a + /// freeze carries over. + /// + /// # Authorization + /// + /// `operator` and the admin sign, as two different accounts. #[only_role(operator, "compliance")] fn recover_balance( e: &Env, @@ -90,28 +773,187 @@ impl RWAToken for ShareToken { new_account: Address, operator: Address, ) -> bool { - RWA::recover_balance(e, &old_account, &new_account) + guards::admin_co_signs(e, &operator); + guards::not_vault_address(e, &old_account); + guards::not_vault_address(e, &new_account); + strata_storage::bump_instance(e); + let recovered = RWA::recover_balance(e, &old_account, &new_account); + extend::balances(e, &[&new_account]); + extend::address_frozen(e, &new_account); + extend::frozen_tokens(e, &new_account); + recovered } + + /// Freezes or unfreezes an account: a frozen account can neither send nor + /// receive a `transfer`, and the vault holds what it owes the account. + /// Never freezes the vault's address once the token is bound to it. + /// + /// # Arguments + /// + /// * `user_address` - The account. + /// * `freeze` - True freezes, false unfreezes. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `VaultAddress` - Freezing the vault's address. + /// + /// # Events + /// + /// * `AddressFrozen` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { + guards::not_freezing_vault(e, &user_address, freeze); + strata_storage::bump_instance(e); RWA::set_address_frozen(e, &user_address, freeze); + extend::address_frozen(e, &user_address); } + + /// Freezes part of an account's shares, which then cannot be transferred. + /// Never on the vault's address once the token is bound to it. + /// + /// # Arguments + /// + /// * `user_address` - The account. + /// * `amount` - Share base units added to the frozen amount. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `VaultAddress` - `user_address` is the vault. + /// * `RWAError::LessThanZero` - `amount` is negative. + /// * `RWAError::InsufficientBalance` - The frozen amount would exceed the + /// balance. + /// + /// # Events + /// + /// * `TokensFrozen` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + guards::not_vault_address(e, &user_address); + strata_storage::bump_instance(e); RWA::freeze_partial_tokens(e, &user_address, amount); + extend::frozen_tokens(e, &user_address); } + + /// Unfreezes part of an account's frozen shares. + /// + /// # Arguments + /// + /// * `user_address` - The account. + /// * `amount` - Share base units taken off the frozen amount. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `RWAError::LessThanZero` - `amount` is negative. + /// * `RWAError::InsufficientFreeTokens` - `amount` exceeds the frozen + /// shares. + /// + /// # Events + /// + /// * `TokensUnfrozen` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "compliance")] fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + strata_storage::bump_instance(e); RWA::unfreeze_partial_tokens(e, &user_address, amount); + extend::frozen_tokens(e, &user_address); } - #[only_admin] + + /// Points the token at another compliance contract. Instant during setup; + /// after it, propose a `Compliance` change. + /// + /// # Arguments + /// + /// * `compliance` - The compliance contract. + /// * `operator` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `operator` is not the admin. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `ComplianceSet` + /// + /// # Authorization + /// + /// `operator` signs. fn set_compliance(e: &Env, compliance: Address, operator: Address) { + guards::caller_is_admin(e, &operator); operator.require_auth(); + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); RWA::set_compliance(e, &compliance); } - #[only_admin] + + /// Points the token at another identity verifier. Instant during setup; + /// after it, propose an `IdentityVerifier` change. + /// + /// # Arguments + /// + /// * `identity_verifier` - The identity verifier. + /// * `operator` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `operator` is not the admin. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `IdentityVerifierSet` + /// + /// # Authorization + /// + /// `operator` signs. fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { + guards::caller_is_admin(e, &operator); operator.require_auth(); + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); RWA::set_identity_verifier(e, &identity_verifier); } } + +/// Stands in for a later token version in the upgrade tests. Never deployed. +#[cfg(feature = "upgrade_probe")] +#[soroban_sdk::contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ProbeError { + AlreadyMigrated = 7100, +} + +#[cfg(feature = "upgrade_probe")] +#[contractimpl] +impl ShareToken { + #[only_admin] + pub fn migrate(e: &Env) { + strata_storage::bump_instance(e); + if upgradeable::get_schema_version(e) >= 2 { + panic_with_error!(e, ProbeError::AlreadyMigrated); + } + upgradeable::set_schema_version(e, 2); + } + + pub fn probe(_e: &Env) -> bool { + true + } +} diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 1188392d..206910ef 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -1,13 +1,75 @@ use soroban_sdk::contracterror; -/// Share token failures. Codes are stable once assigned and are never reused; a -/// removed variant leaves its number retired. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum ShareTokenError { - /// The token must always have an admin: `pause`, `unpause` and role - /// management need it. The admin hands over through `transfer_admin_role` - /// and `accept_admin_transfer` instead. - AdminRequired = 7000, +strata_bindings::error_enum! { + strata_bindings::TOKEN_ERRORS, + + /// Share token failures, coded 7000 to 7999. Codes are stable once assigned + /// and are never reused; a removed variant leaves its number retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum ShareTokenError { + /// The call needs the admin. `pause`, `unpause`, `set_compliance` and + /// `set_identity_verifier` refuse any other caller. `renounce_admin` is + /// always refused, because the token must always have an admin: hand it + /// over instead by proposing and applying an `Admin` change + /// (`transfer_admin_role` during setup), then the nominee calls + /// `accept_admin_transfer`. + AdminRequired = 7000, + /// No pending change has this id. + ChangeNotFound = 7001, + /// The change's delay has not elapsed. + ChangeNotReady = 7002, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 7003, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 7004, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 7005, + /// Only possible while setup is open, and only once. + SetupClosed = 7006, + /// Setup cannot finish before the token is bound to its vault. + VaultNotBound = 7007, + /// The vault is winding down, and the change is not a grant of + /// `compliance`. No admin hand-over is accepted then either. + WindDownActive = 7008, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 7009, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 7010, + /// An upgrade already waits. Cancel it before proposing another. + UpgradeAlreadyPending = 7011, + /// The vault's own address cannot be frozen, and no transfer, legal + /// transfer or recovery moves shares into it or out of it: its balance + /// is other investors' escrow. + VaultAddress = 7012, + /// A legal transfer or a recovery needs the compliance authority and the + /// admin as two different accounts, so no single key moves value. + CoSignRequired = 7013, + /// A legal transfer names the order behind it: a reference of 1 to 128 + /// bytes. + InvalidReference = 7015, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 7016, + /// The proposed compliance contract's `linked_tokens` leaves out this + /// token, so every mint, transfer and burn would fail. Or it answers + /// `vault()` and `setup_open()` but does not name this token's vault + /// with its setup closed, so its rules could change without the + /// vault's delay. One that leaves either view unanswered is an + /// external module and passes, on trust. + ComplianceNotBound = 7017, + /// The proposed identity verifier refuses the vault's address or does + /// not answer `verify_identity`. The vault needs that check to convert + /// a delisted holder's deposit, and a verifier that cannot answer makes + /// every holder look delisted. + VerifierRefusesVault = 7018, + /// Renouncing `compliance` is refused while it has at most one holder. + /// Lifting a freeze needs the role, so it cannot be given up; grant a + /// successor first. The admin can still revoke it. + LastComplianceHolder = 7019, + } } diff --git a/contracts/share-token/src/event.rs b/contracts/share-token/src/event.rs new file mode 100644 index 00000000..6a08551b --- /dev/null +++ b/contracts/share-token/src/event.rs @@ -0,0 +1,43 @@ +use soroban_sdk::{contractevent, Address, String}; + +use crate::governance::TokenChange; + +#[contractevent] +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: TokenChange, + pub eta: u64, +} + +#[contractevent] +pub struct ChangeCancelled { + #[topic] + pub id: u32, +} + +#[contractevent] +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: TokenChange, +} + +#[contractevent] +pub struct SetupFinished {} + +#[contractevent] +pub struct VaultBound { + #[topic] + pub vault: Address, +} + +#[contractevent] +pub struct LegalTransfer { + #[topic] + pub from: Address, + #[topic] + pub to: Address, + pub amount: i128, + pub reference: String, +} diff --git a/contracts/share-token/src/extend.rs b/contracts/share-token/src/extend.rs new file mode 100644 index 00000000..352dbaa2 --- /dev/null +++ b/contracts/share-token/src/extend.rs @@ -0,0 +1,28 @@ +//! OpenZeppelin extends an entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env, Symbol}; +use stellar_access::access_control; +use stellar_tokens::{fungible::Base, rwa::RWA}; + +pub(crate) fn balances(e: &Env, accounts: &[&Address]) { + for account in accounts { + Base::balance(e, account); + } +} + +pub(crate) fn address_frozen(e: &Env, account: &Address) { + RWA::is_frozen(e, account); +} + +pub(crate) fn frozen_tokens(e: &Env, account: &Address) { + RWA::get_frozen_tokens(e, account); +} + +/// The role, its member-list slot, the member count and the role list: all a grant writes. +pub(crate) fn role_entries(e: &Env, account: &Address, role: &Symbol) { + if let Some(index) = access_control::has_role(e, account, role) { + access_control::get_role_member(e, role, index); + } + access_control::get_role_member_count(e, role); + access_control::get_existing_roles(e); +} diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs new file mode 100644 index 00000000..2323ed63 --- /dev/null +++ b/contracts/share-token/src/governance.rs @@ -0,0 +1,182 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use stellar_access::{ + access_control::{self, AccessControlStorageKey}, + role_transfer, +}; +use stellar_contract_utils::upgradeable; +use stellar_tokens::rwa::RWA; +use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; + +use crate::error::ShareTokenError; +use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; +use crate::extend; +use crate::guards; +use crate::roles::COMPLIANCE_ROLE; +use crate::ShareToken; + +/// Seven days of five-second ledgers: how long the next admin has to accept a +/// hand-over. +pub const ADMIN_ACCEPT_LEDGERS: u32 = 7 * 17_280; + +/// A privileged change that waits for the vault's timelock before it lands. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TokenChange { + Upgrade(BytesN<32>), + GrantRole(Symbol, Address), + RevokeRole(Symbol, Address), + Admin(Address), + Compliance(Address), + IdentityVerifier(Address), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenPendingChange { + pub id: u32, + pub change: TokenChange, + pub eta: u64, +} + +/// The token keeps no clock of its own: it reads the vault's. +impl TimelockHost for ShareToken { + fn clock(e: &Env) -> Result { + strata_timelock::clock::from_source(e) + } +} + +/// The vault's clock with its wind-down ignored, so a `compliance` grant still +/// lands after the normal delay: without a holder no freeze can be lifted. +struct ComplianceGrantHost; + +impl TimelockHost for ComplianceGrantHost { + fn clock(e: &Env) -> Result { + let mut clock = ::clock(e)?; + clock.winding_down = false; + Ok(clock) + } +} + +fn grants_compliance(e: &Env, change: &TokenChange) -> bool { + matches!(change, TokenChange::GrantRole(role, _) if *role == Symbol::new(e, COMPLIANCE_ROLE)) +} + +fn refuse(e: &Env, refusal: Refusal) -> ! { + match refusal { + Refusal::WindingDown => panic_with_error!(e, ShareTokenError::WindDownActive), + Refusal::Paused => panic_with_error!(e, ShareTokenError::VaultPaused), + Refusal::NotElapsed => panic_with_error!(e, ShareTokenError::ChangeNotReady), + Refusal::BelowExitWindow => panic_with_error!(e, ShareTokenError::DelayBelowNotice), + Refusal::NotFound => panic_with_error!(e, ShareTokenError::ChangeNotFound), + Refusal::TooMany => panic_with_error!(e, ShareTokenError::TooManyPendingChanges), + Refusal::SetupOpen => panic_with_error!(e, ShareTokenError::SetupOpen), + Refusal::SetupClosed | Refusal::AlreadyBound => { + panic_with_error!(e, ShareTokenError::SetupClosed) + } + Refusal::Unbound => panic_with_error!(e, ShareTokenError::VaultNotBound), + } +} + +fn vault_clock(e: &Env) -> Clock { + ::clock(e).unwrap_or_else(|r| refuse(e, r)) +} + +pub(crate) fn bind_vault(e: &Env, vault: &Address) { + setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); + VaultBound { + vault: vault.clone(), + } + .publish(e); +} + +pub(crate) fn finish_setup(e: &Env) { + guards::setup_open(e); + guards::no_hand_over_pending(e); + setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); + SetupFinished {}.publish(e); +} + +pub(crate) fn propose(e: &Env, change: TokenChange) -> u32 { + guards::valid_change(e, &change); + guards::not_a_second_upgrade(e, &change); + let queued = if grants_compliance(e, &change) { + queue::propose::(e, &change) + } else { + queue::propose::(e, &change) + }; + let id = queued.unwrap_or_else(|r| refuse(e, r)); + let (_, schedule) = + queue::pending::(e, id).unwrap_or_else(|| refuse(e, Refusal::NotFound)); + // No pause has run since the proposal, so the eta needs no second clock read. + ChangeProposed { + id, + change, + eta: schedule.proposed_at.saturating_add(schedule.delay), + } + .publish(e); + id +} + +pub(crate) fn cancel(e: &Env, id: u32) { + queue::cancel(e, id).unwrap_or_else(|r| refuse(e, r)); + ChangeCancelled { id }.publish(e); +} + +pub(crate) fn apply(e: &Env, id: u32) { + let (waiting, _) = + queue::pending::(e, id).unwrap_or_else(|| refuse(e, Refusal::NotFound)); + let ready = if grants_compliance(e, &waiting) { + queue::take_ready::(e, id) + } else { + queue::take_ready::(e, id) + }; + let change = ready.unwrap_or_else(|r| refuse(e, r)); + // The new contract may have changed since the proposal; this check decides. + guards::valid_change(e, &change); + let admin = access_control::get_admin(e) + .unwrap_or_else(|| panic_with_error!(e, ShareTokenError::AdminRequired)); + match &change { + TokenChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + TokenChange::GrantRole(role, account) => { + access_control::grant_role_no_auth(e, account, role, &admin); + extend::role_entries(e, account, role); + } + TokenChange::RevokeRole(role, account) => { + access_control::revoke_role_no_auth(e, account, role, &admin) + } + TokenChange::Admin(next) => offer_admin(e, &admin, next), + TokenChange::Compliance(rules) => RWA::set_compliance(e, rules), + TokenChange::IdentityVerifier(verifier) => RWA::set_identity_verifier(e, verifier), + } + ChangeApplied { id, change }.publish(e); +} + +/// The first step of the two-step hand-over, without the admin authorisation +/// `transfer_admin_role` would ask for a second time inside `apply_change`. +fn offer_admin(e: &Env, admin: &Address, next: &Address) { + let live_until = e.ledger().sequence().saturating_add(ADMIN_ACCEPT_LEDGERS); + role_transfer::transfer_role(e, next, &AccessControlStorageKey::PendingAdmin, live_until); + access_control::emit_admin_transfer_initiated(e, admin, next, live_until); +} + +/// Every change still waiting, with the moment it may land on the vault's +/// clock as it reads now. +pub(crate) fn pending(e: &Env) -> Vec { + let mut out = Vec::new(e); + let open = queue::open_ids(e); + if open.is_empty() { + // Skips the vault, which is not bound yet during setup. + return out; + } + let clock = vault_clock(e); + for id in open.iter() { + if let Some((change, schedule)) = queue::pending::(e, id) { + out.push_back(TokenPendingChange { + id, + change, + eta: schedule.eta(&clock), + }); + } + } + out +} diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs new file mode 100644 index 00000000..324cab1b --- /dev/null +++ b/contracts/share-token/src/guards.rs @@ -0,0 +1,171 @@ +use soroban_sdk::{panic_with_error, Address, Env, String, Symbol}; +use stellar_access::{ + access_control::{self, AccessControlStorageKey}, + role_transfer, +}; +use stellar_tokens::rwa::{ + identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, RWA, +}; +use strata_bindings::VaultBindingClient; +use strata_timelock::{queue, setup, TimelockHost}; + +use crate::error::ShareTokenError; +use crate::governance::TokenChange; +use crate::roles::{COMPLIANCE_ROLE, MANAGER_ROLE}; +use crate::ShareToken; +use crate::MAX_REFERENCE_LEN; + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ShareTokenError::SetupClosed); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, ShareTokenError::AdminTransferPending); + } +} + +/// Passes before a vault is bound: there is no wind-down to read yet. +pub(crate) fn vault_not_winding_down(e: &Env) { + if ::clock(e).is_ok_and(|clock| clock.winding_down) { + panic_with_error!(e, ShareTokenError::WindDownActive); + } +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ShareTokenError::UseTimelock); + } +} + +/// Taking `manager` away stops every claim, so after setup it waits like a +/// grant. Taking any other role away moves no value and stays instant. +pub(crate) fn direct_revoke_allowed(e: &Env, role: &Symbol) { + if *role == MANAGER_ROLE { + direct_change_allowed(e); + } +} + +/// Lifting a freeze needs `compliance`; only the admin may leave it without a holder. +pub(crate) fn not_last_compliance_holder(e: &Env, role: &Symbol) { + if *role == Symbol::new(e, COMPLIANCE_ROLE) + && access_control::get_role_member_count(e, role) <= 1 + { + panic_with_error!(e, ShareTokenError::LastComplianceHolder); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} + +/// One upgrade at a time, so a standing eta is never quietly replaced. +pub(crate) fn not_a_second_upgrade(e: &Env, change: &TokenChange) { + if matches!(change, TokenChange::Upgrade(_)) && upgrade_pending(e) { + panic_with_error!(e, ShareTokenError::UpgradeAlreadyPending); + } +} + +fn upgrade_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id) + .is_some_and(|(change, _)| matches!(change, TokenChange::Upgrade(_))) + }) +} + +/// What a change must meet when it is proposed and again when it applies: +/// rules bound here, or a verifier that accepts the vault. +pub(crate) fn valid_change(e: &Env, change: &TokenChange) { + match change { + TokenChange::Compliance(rules) => compliance_bound_here(e, rules), + TokenChange::IdentityVerifier(verifier) => verifier_accepts_vault(e, verifier), + TokenChange::Upgrade(_) + | TokenChange::GrantRole(..) + | TokenChange::RevokeRole(..) + | TokenChange::Admin(_) => {} + } +} + +/// The same rule the vault's `finish_setup` applies to compliance contracts. +pub(crate) fn compliance_bound_here(e: &Env, rules: &Address) { + if !bound_here_or_external(e, rules) || !lists_this_token(e, rules) { + panic_with_error!(e, ShareTokenError::ComplianceNotBound); + } +} + +pub(crate) fn verifier_accepts_vault(e: &Env, verifier: &Address) { + let accepted = setup::source(e).is_some_and(|vault| { + matches!( + IdentityVerifierClient::new(e, verifier).try_verify_identity(&vault), + Ok(Ok(())) + ) + }); + if !accepted { + panic_with_error!(e, ShareTokenError::VerifierRefusesVault); + } +} + +fn bound_here_or_external(e: &Env, contract: &Address) -> bool { + let binding = VaultBindingClient::new(e, contract); + match (binding.try_vault(), binding.try_setup_open()) { + (Ok(Ok(named)), Ok(Ok(open))) => { + setup::source(e).is_some_and(|vault| named == Some(vault)) && !open + } + // Either view failing marks an external module; its governance is a trust assumption. + _ => true, + } +} + +fn lists_this_token(e: &Env, rules: &Address) -> bool { + matches!( + TokenBinderClient::new(e, rules).try_linked_tokens(), + Ok(Ok(linked)) if linked.contains(e.current_contract_address()) + ) +} + +/// Stands in for `#[only_admin]` where an OpenZeppelin trait fixes the caller. +pub(crate) fn caller_is_admin(e: &Env, caller: &Address) { + if access_control::get_admin(e).as_ref() != Some(caller) { + panic_with_error!(e, ShareTokenError::AdminRequired); + } +} + +/// The admin signs too, and is a different account from the caller. +pub(crate) fn admin_co_signs(e: &Env, caller: &Address) { + let admin = access_control::enforce_admin_auth(e); + if admin == *caller { + panic_with_error!(e, ShareTokenError::CoSignRequired); + } +} + +pub(crate) fn valid_reference(e: &Env, reference: &String) { + if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN { + panic_with_error!(e, ShareTokenError::InvalidReference); + } +} + +pub(crate) fn not_vault_address(e: &Env, account: &Address) { + if setup::source(e).as_ref() == Some(account) { + panic_with_error!(e, ShareTokenError::VaultAddress); + } +} + +/// Unfreezing the vault's address stays possible; freezing it never is. +pub(crate) fn not_freezing_vault(e: &Env, account: &Address, freeze: bool) { + if freeze { + not_vault_address(e, account); + } +} + +/// Fails with the identity verifier's own error. +pub(crate) fn identity_verified(e: &Env, account: &Address) { + IdentityVerifierClient::new(e, &RWA::identity_verifier(e)).verify_identity(account); +} diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index d4b250f9..57ff7cf9 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -1,9 +1,53 @@ +//! # Design +//! +//! The vault's share token: OpenZeppelin's RWA token, which checks identity and +//! the compliance rules on every `transfer` and mint; forced transfers skip +//! both. The kit adds its roles, its guards and the vault's timelock. +//! +//! - **No clock of its own.** After setup, upgrades, grants, `manager` revokes, +//! hand-overs and swaps wait for the vault's timelock. The cost: a vault +//! pause holds them; a wind-down refuses all but a `compliance` grant, the +//! only way to lift a freeze then; it waits the delay, and with the admin +//! its holder can still move wallet shares. +//! - **Only the vault is manager.** It mints, burns and moves anyone's shares; +//! a forced move skips the token's checks, so the vault checks receivers. The +//! cost: granting it waits; revoking too, as claims need it. +//! - **Two signers move a holder's shares.** A legal transfer or recovery needs +//! `compliance` and the admin, as two accounts. The cost: an order needs +//! both. A zero-share order passes and is published. +//! - **The vault's address is off limits:** once bound, it is never frozen, +//! no order or transfer moves shares in, and no order moves them out, since +//! it holds others' escrow. The cost: orders cannot reach escrow; a frozen +//! holder's exits wait. +//! - **The pause halts trading only.** The admin's transfer pause stops +//! `transfer` and `transfer_from`; mints, burns, escrow moves and legal +//! transfers go on. The cost: it is no emergency stop for the vault. +//! - **A swap must fit the vault.** New rules must list this token, and rules +//! that answer `VaultBinding` must run on the vault's clock with their setup +//! closed; a new verifier must accept the vault. Both are checked when +//! proposed and when applied. The cost: as at setup, an external module that +//! does not answer is taken on trust, and its rules can change with no delay. + #![no_std] mod contract; mod error; +mod event; +mod extend; +mod governance; +mod guards; mod roles; pub use contract::*; pub use error::ShareTokenError; +pub use event::{ + ChangeApplied, ChangeCancelled, ChangeProposed, LegalTransfer, SetupFinished, VaultBound, +}; +pub use governance::{TokenChange, TokenPendingChange, ADMIN_ACCEPT_LEDGERS}; #[cfg(test)] mod test; +#[cfg(test)] +mod test_governance; +#[cfg(test)] +mod test_guards; +#[cfg(test)] +mod test_ttl; diff --git a/contracts/share-token/src/roles.rs b/contracts/share-token/src/roles.rs index 16a31009..1893c792 100644 --- a/contracts/share-token/src/roles.rs +++ b/contracts/share-token/src/roles.rs @@ -1,4 +1,10 @@ -/// Role for allowlist-driven interventions: freeze, forced transfer, recovery. -/// Held by the compliance authority. Minting, burning and escrow use the -/// `manager` role, which governance grants to the vault. +use soroban_sdk::{symbol_short, Symbol}; + +/// Role for allowlist-driven interventions: freezes, plus legal transfers and +/// recovery with the admin's co-signature. Held by the compliance authority. +/// Minting, burning and escrow use the `manager` role, which governance grants +/// to the vault. pub const COMPLIANCE_ROLE: &str = "compliance"; + +/// The vault's role. Without it the vault cannot mint or burn, so no claim pays. +pub const MANAGER_ROLE: Symbol = symbol_short!("manager"); diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 8c45c7a6..f8288f77 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -1,15 +1,29 @@ +extern crate std; + use soroban_sdk::{ symbol_short, - testutils::{Address as _, MockAuth, MockAuthInvoke}, - Address, Env, IntoVal, String, Symbol, + testutils::{ + storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, + Ledger as _, MockAuth, MockAuthInvoke, + }, + Address, Env, IntoVal, String, Symbol, Val, Vec, }; use stellar_access::access_control::{self as access_control, AccessControlError}; use stellar_tokens::rwa::RWAError; +use strata_storage::INSTANCE_EXTEND_AMOUNT; -use crate::{ShareToken, ShareTokenClient}; +use crate::{ShareToken, ShareTokenClient, ShareTokenError}; use compliance::{Compliance, ComplianceClient}; use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; +/// Asserts a call was refused with exactly this error. +fn refused( + result: Result, Result>, + expected: ShareTokenError, +) { + assert_eq!(result, Err(Ok(expected.into()))); +} + #[test] fn manager_mints_to_allowlisted_account() { let e = Env::default(); @@ -40,7 +54,7 @@ fn manager_mints_to_allowlisted_account() { token.grant_role(&manager, &symbol_short!("manager"), &admin); compliance.bind_token(&token.address, &admin); // compliance rejects ops from unbound tokens - identity.allow(&receiver, &true, &admin); + identity.allow(&receiver, &true); token.mint(&receiver, &100, &manager); @@ -48,7 +62,6 @@ fn manager_mints_to_allowlisted_account() { } struct Fixture<'a> { - #[allow(dead_code)] e: Env, token: ShareTokenClient<'a>, admin: Address, @@ -60,6 +73,25 @@ struct Fixture<'a> { identity_verifier: Address, } +impl Fixture<'_> { + /// The last call asked each of `signers`, in order, to sign it once, and nobody else. + fn signed_once_by(&self, signers: &[&Address], fn_name: &str, args: Vec) { + let call = AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.token.address.clone(), + Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }; + let expected: std::vec::Vec<_> = signers + .iter() + .map(|who| ((*who).clone(), call.clone())) + .collect(); + assert_eq!(self.e.auths(), expected); + } +} + fn setup_fixture(e: &Env) -> Fixture<'_> { let admin = Address::generate(e); let manager = Address::generate(e); @@ -109,8 +141,11 @@ fn unauthorized_caller_cannot_pause_or_unpause() { let stranger = Address::generate(&e); // Stranger and manager holding no admin role cannot pause. - assert!(f.token.try_pause(&stranger).is_err()); - assert!(f.token.try_pause(&f.manager).is_err()); + refused(f.token.try_pause(&stranger), ShareTokenError::AdminRequired); + refused( + f.token.try_pause(&f.manager), + ShareTokenError::AdminRequired, + ); e.mock_all_auths(); f.token.pause(&f.admin); @@ -118,8 +153,14 @@ fn unauthorized_caller_cannot_pause_or_unpause() { // Stranger cannot unpause. e.set_auths(&[]); - assert!(f.token.try_unpause(&stranger).is_err()); - assert!(f.token.try_unpause(&f.manager).is_err()); + refused( + f.token.try_unpause(&stranger), + ShareTokenError::AdminRequired, + ); + refused( + f.token.try_unpause(&f.manager), + ShareTokenError::AdminRequired, + ); e.mock_all_auths(); f.token.unpause(&f.admin); @@ -135,8 +176,8 @@ fn unauthorized_caller_cannot_mint() { let receiver = Address::generate(&e); // Only manager holds the mint role; stranger and admin are refused. - assert!(f.token.try_mint(&receiver, &100, &stranger).is_err()); - assert!(f.token.try_mint(&receiver, &100, &f.admin).is_err()); + assert_unauthorized(f.token.try_mint(&receiver, &100, &stranger)); + assert_unauthorized(f.token.try_mint(&receiver, &100, &f.admin)); } #[test] @@ -147,8 +188,8 @@ fn unauthorized_caller_cannot_burn() { let stranger = Address::generate(&e); let user = Address::generate(&e); - assert!(f.token.try_burn(&user, &100, &stranger).is_err()); - assert!(f.token.try_burn(&user, &100, &f.admin).is_err()); + assert_unauthorized(f.token.try_burn(&user, &100, &stranger)); + assert_unauthorized(f.token.try_burn(&user, &100, &f.admin)); } #[test] @@ -160,14 +201,8 @@ fn unauthorized_caller_cannot_forced_transfer() { let from = Address::generate(&e); let to = Address::generate(&e); - assert!(f - .token - .try_forced_transfer(&from, &to, &100, &stranger) - .is_err()); - assert!(f - .token - .try_forced_transfer(&from, &to, &100, &f.admin) - .is_err()); + assert_unauthorized(f.token.try_forced_transfer(&from, &to, &100, &stranger)); + assert_unauthorized(f.token.try_forced_transfer(&from, &to, &100, &f.admin)); } #[test] @@ -179,22 +214,10 @@ fn unauthorized_caller_cannot_freeze_or_recover() { let user = Address::generate(&e); let new_user = Address::generate(&e); - assert!(f - .token - .try_set_address_frozen(&user, &true, &stranger) - .is_err()); - assert!(f - .token - .try_freeze_partial_tokens(&user, &100, &stranger) - .is_err()); - assert!(f - .token - .try_unfreeze_partial_tokens(&user, &100, &stranger) - .is_err()); - assert!(f - .token - .try_recover_balance(&user, &new_user, &stranger) - .is_err()); + assert_unauthorized(f.token.try_set_address_frozen(&user, &true, &stranger)); + assert_unauthorized(f.token.try_freeze_partial_tokens(&user, &100, &stranger)); + assert_unauthorized(f.token.try_unfreeze_partial_tokens(&user, &100, &stranger)); + assert_unauthorized(f.token.try_recover_balance(&user, &new_user, &stranger)); } #[test] @@ -206,12 +229,18 @@ fn unauthorized_caller_cannot_set_compliance_or_verifier() { let new_verifier = Address::generate(&e); // Without the admin's signature, nobody swaps the rules. - assert!(f.token.try_set_compliance(&new_comp, &stranger).is_err()); - assert!(f - .token - .try_set_identity_verifier(&new_verifier, &stranger) - .is_err()); - assert!(f.token.try_set_compliance(&new_comp, &f.manager).is_err()); + refused( + f.token.try_set_compliance(&new_comp, &stranger), + ShareTokenError::AdminRequired, + ); + refused( + f.token.try_set_identity_verifier(&new_verifier, &stranger), + ShareTokenError::AdminRequired, + ); + refused( + f.token.try_set_compliance(&new_comp, &f.manager), + ShareTokenError::AdminRequired, + ); } #[test] @@ -220,7 +249,7 @@ fn the_admin_cannot_renounce_itself_out_of_the_token() { e.mock_all_auths(); let f = setup_fixture(&e); - assert!(f.token.try_renounce_admin().is_err()); + refused(f.token.try_renounce_admin(), ShareTokenError::AdminRequired); // Still governed: the admin-only paths keep working. f.token.pause(&f.admin); @@ -228,6 +257,28 @@ fn the_admin_cannot_renounce_itself_out_of_the_token() { assert_eq!(f.token.get_admin(), Some(f.admin.clone())); } +#[test] +fn the_last_compliance_holder_cannot_renounce() { + let e = Env::default(); + e.mock_all_auths(); + let f = setup_fixture(&e); + let role = Symbol::new(&e, "compliance"); + + assert_eq!( + f.token + .try_renounce_role(&role, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::LastComplianceHolder.into())), + ); + let backup = Address::generate(&e); + f.token.grant_role(&backup, &role, &f.admin); + f.token.renounce_role(&role, &f.compliance_authority); + assert_eq!( + f.token.try_renounce_role(&role, &backup).err(), + Some(Ok(ShareTokenError::LastComplianceHolder.into())), + ); +} + #[test] fn admin_handover_in_two_steps_still_works() { let e = Env::default(); @@ -245,7 +296,7 @@ fn admin_handover_in_two_steps_still_works() { // The old admin no longer administers; the new one does. e.set_auths(&[]); - assert!(f.token.try_pause(&f.admin).is_err()); + refused(f.token.try_pause(&f.admin), ShareTokenError::AdminRequired); e.mock_all_auths(); f.token.pause(&new_admin); assert!(f.token.paused()); @@ -280,7 +331,7 @@ fn the_compliance_authority_freezes_and_moves_balances() { let (f, identity) = setup_live(&e); let holder = Address::generate(&e); let target = Address::generate(&e); - identity.allow(&holder, &true, &f.admin); + identity.allow(&holder, &true); f.token.mint(&holder, &1_000, &f.manager); f.token @@ -297,8 +348,14 @@ fn the_compliance_authority_freezes_and_moves_balances() { .unfreeze_partial_tokens(&holder, &400, &f.compliance_authority); assert_eq!(f.token.get_frozen_tokens(&holder), 0); - f.token - .forced_transfer(&holder, &target, &250, &f.compliance_authority); + identity.allow(&target, &true); + f.token.legal_transfer( + &holder, + &target, + &250, + &String::from_str(&e, "court-order-2026-117"), + &f.compliance_authority, + ); assert_eq!(f.token.balance(&target), 250); } @@ -308,7 +365,7 @@ fn the_compliance_authority_reaches_recovery() { let (f, identity) = setup_live(&e); let lost = Address::generate(&e); let replacement = Address::generate(&e); - identity.allow(&replacement, &true, &f.admin); + identity.allow(&replacement, &true); // The role check passes; the testnet verifier names no recovery target. assert_eq!( @@ -344,15 +401,16 @@ fn the_compliance_authority_cannot_mint_burn_or_swap_the_rules() { }]); }; signed_by_compliance("set_compliance", &other); - assert!(f - .token - .try_set_compliance(&other, &f.compliance_authority) - .is_err()); + refused( + f.token.try_set_compliance(&other, &f.compliance_authority), + ShareTokenError::AdminRequired, + ); signed_by_compliance("set_identity_verifier", &other); - assert!(f - .token - .try_set_identity_verifier(&other, &f.compliance_authority) - .is_err()); + refused( + f.token + .try_set_identity_verifier(&other, &f.compliance_authority), + ShareTokenError::AdminRequired, + ); } #[test] @@ -361,7 +419,7 @@ fn the_manager_escrows_but_does_not_intervene() { let (f, identity) = setup_live(&e); let holder = Address::generate(&e); let new_holder = Address::generate(&e); - identity.allow(&holder, &true, &f.admin); + identity.allow(&holder, &true); f.token.mint(&holder, &1_000, &f.manager); assert_unauthorized(f.token.try_set_address_frozen(&holder, &true, &f.manager)); @@ -413,3 +471,671 @@ fn the_constructor_grants_the_compliance_role_and_no_manager() { .has_role(&f.admin, &symbol_short!("manager")) .is_none()); } + +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +fn instance_ttl(e: &Env, id: &Address) -> u32 { + e.as_contract(id, || e.storage().instance().get_ttl()) +} + +fn age(e: &Env, ledgers: u32) { + e.ledger().with_mut(|l| l.sequence_number += ledgers); +} + +/// A live token whose instance has decayed past the extend threshold, with +/// two allowlisted holders, the first holding 100 shares. +fn decayed_live_token(e: &Env) -> (Fixture<'_>, Address, Address) { + let (f, identity) = setup_live(e); + let holder = Address::generate(e); + let other = Address::generate(e); + identity.allow(&holder, &true); + identity.allow(&other, &true); + f.token.mint(&holder, &100, &f.manager); + + age(e, TWO_DAYS_IN_LEDGERS); + assert!(instance_ttl(e, &f.token.address) < INSTANCE_EXTEND_AMOUNT); + (f, holder, other) +} + +#[test] +fn construction_writes_the_instance_with_the_full_ttl() { + let e = Env::default(); + let f = setup_fixture(&e); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + f.token.transfer(&holder, &other, &10); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_from_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + let live_until = e.ledger().sequence() + 3 * 17_280; + f.token.approve(&holder, &other, &10, &live_until); + age(&e, TWO_DAYS_IN_LEDGERS); + assert!(instance_ttl(&e, &f.token.address) < INSTANCE_EXTEND_AMOUNT); + + f.token.transfer_from(&other, &holder, &other, &10); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn an_approval_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + let live_until = e.ledger().sequence() + 100; + f.token.approve(&holder, &other, &10, &live_until); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_vaults_mint_burn_and_escrow_restore_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + f.token.mint(&other, &5, &f.manager); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); + + age(&e, TWO_DAYS_IN_LEDGERS); + f.token.forced_transfer(&holder, &other, &5, &f.manager); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); + + age(&e, TWO_DAYS_IN_LEDGERS); + f.token.burn(&other, &5, &f.manager); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn compliance_interventions_restore_the_instance_ttl() { + let e = Env::default(); + let (f, holder, _) = decayed_live_token(&e); + + f.token + .set_address_frozen(&holder, &true, &f.compliance_authority); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_legal_transfer_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + f.token.legal_transfer( + &holder, + &other, + &10, + &String::from_str(&e, "court-order-2026-117"), + &f.compliance_authority, + ); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn reading_a_balance_or_the_supply_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, _) = decayed_live_token(&e); + + f.token.balance(&holder); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); + + age(&e, TWO_DAYS_IN_LEDGERS); + f.token.total_supply(); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn each_role_call_asks_its_holder_for_one_signature() { + let e = Env::default(); + let (f, identity) = setup_live(&e); + let holder = Address::generate(&e); + let other = Address::generate(&e); + identity.allow(&holder, &true); + identity.allow(&other, &true); + + f.token.mint(&holder, &1_000, &f.manager); + f.signed_once_by( + &[&f.manager], + "mint", + (holder.clone(), 1_000_i128, f.manager.clone()).into_val(&e), + ); + f.token.burn(&holder, &100, &f.manager); + f.signed_once_by( + &[&f.manager], + "burn", + (holder.clone(), 100_i128, f.manager.clone()).into_val(&e), + ); + f.token.forced_transfer(&holder, &other, &100, &f.manager); + f.signed_once_by( + &[&f.manager], + "forced_transfer", + (holder.clone(), other, 100_i128, f.manager.clone()).into_val(&e), + ); + + let compliance = &f.compliance_authority; + f.token.freeze_partial_tokens(&holder, &50, compliance); + f.signed_once_by( + &[compliance], + "freeze_partial_tokens", + (holder.clone(), 50_i128, compliance.clone()).into_val(&e), + ); + f.token.unfreeze_partial_tokens(&holder, &50, compliance); + f.signed_once_by( + &[compliance], + "unfreeze_partial_tokens", + (holder.clone(), 50_i128, compliance.clone()).into_val(&e), + ); + f.token.set_address_frozen(&holder, &true, compliance); + f.signed_once_by( + &[compliance], + "set_address_frozen", + (holder, true, compliance.clone()).into_val(&e), + ); + + let role = Symbol::new(&e, "compliance"); + f.token.grant_role(&Address::generate(&e), &role, &f.admin); + f.token.renounce_role(&role, compliance); + f.signed_once_by( + &[compliance], + "renounce_role", + (role, compliance.clone()).into_val(&e), + ); +} + +mod legal_transfer { + use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::Events as _, + xdr::{ScErrorCode, ScErrorType}, + Event as _, + }; + use stellar_tokens::rwa::identity_verification; + use strata_timelock::{Clock, MIN_DELAY}; + + use super::*; + use crate::{LegalTransfer, ShareTokenError, MAX_REFERENCE_LEN}; + + /// Plays the vault: the token binds only to an address that reports a + /// clock. + #[contract] + struct VaultStub; + + #[contractimpl(contracttrait)] + impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } + } + + fn order(e: &Env) -> String { + String::from_str(e, "court-order-2026-117") + } + + /// Real rules and verifier, a holder with 1,000 shares, an allowlisted + /// destination and a bound vault that holds 500 shares of escrow. + fn live(e: &Env) -> (Fixture<'_>, Address, Address, Address) { + let (f, identity) = setup_live(e); + let holder = Address::generate(e); + let destination = Address::generate(e); + let vault = e.register(VaultStub, ()); + for who in [&holder, &destination, &vault] { + identity.allow(who, &true); + } + f.token.mint(&holder, &1_000, &f.manager); + f.token.mint(&vault, &500, &f.manager); + f.token.bind_vault(&vault); + (f, holder, destination, vault) + } + + /// A missing signature fails in the host, not as a contract error. + fn missing_signature( + result: Result, Result>, + ) { + assert_eq!( + result.err(), + Some(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Context, + ScErrorCode::InvalidAction + ))), + ); + } + + #[test] + fn compliance_and_the_admin_move_shares_together() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + f.token.legal_transfer( + &holder, + &destination, + &250, + &order(&e), + &f.compliance_authority, + ); + + let events = e.events().all().filter_by_contract(&f.token.address); + assert_eq!( + events.events().last(), + Some( + &LegalTransfer { + from: holder.clone(), + to: destination.clone(), + amount: 250, + reference: order(&e), + } + .to_xdr(&e, &f.token.address) + ) + ); + assert_eq!(f.token.balance(&holder), 750); + assert_eq!(f.token.balance(&destination), 250); + } + + #[test] + fn a_zero_share_legal_transfer_passes_and_is_published() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + f.token.legal_transfer( + &holder, + &destination, + &0, + &order(&e), + &f.compliance_authority, + ); + + let events = e.events().all().filter_by_contract(&f.token.address); + assert_eq!( + events.events().last(), + Some( + &LegalTransfer { + from: holder.clone(), + to: destination.clone(), + amount: 0, + reference: order(&e), + } + .to_xdr(&e, &f.token.address) + ) + ); + assert_eq!(f.token.balance(&holder), 1_000); + assert_eq!(f.token.balance(&destination), 0); + } + + #[test] + fn compliance_alone_cannot_move_shares() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + let invoke = MockAuthInvoke { + contract: &f.token.address, + fn_name: "legal_transfer", + args: ( + holder.clone(), + destination.clone(), + 250i128, + order(&e), + f.compliance_authority.clone(), + ) + .into_val(&e), + sub_invokes: &[], + }; + let transfer = || { + f.token.try_legal_transfer( + &holder, + &destination, + &250, + &order(&e), + &f.compliance_authority, + ) + }; + + e.mock_auths(&[MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }]); + missing_signature(transfer()); + assert_eq!(f.token.balance(&holder), 1_000); + + e.mock_auths(&[ + MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }, + MockAuth { + address: &f.admin, + invoke: &invoke, + }, + ]); + assert_eq!(transfer(), Ok(Ok(()))); + assert_eq!(f.token.balance(&destination), 250); + } + + #[test] + fn nothing_leaves_the_vault_address() { + let e = Env::default(); + let (f, _, destination, vault) = live(&e); + + assert_eq!( + f.token + .try_legal_transfer( + &vault, + &destination, + &100, + &order(&e), + &f.compliance_authority + ) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&vault), 500); + } + + #[test] + fn nothing_lands_on_the_vault_address() { + let e = Env::default(); + let (f, holder, _, vault) = live(&e); + + assert_eq!( + f.token + .try_legal_transfer(&holder, &vault, &100, &order(&e), &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn a_transfer_into_the_vault_is_refused() { + let e = Env::default(); + let (f, holder, destination, vault) = live(&e); + + assert_eq!( + f.token.try_transfer(&holder, &vault, &100).err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + f.token + .approve(&holder, &destination, &100, &(e.ledger().sequence() + 100)); + assert_eq!( + f.token + .try_transfer_from(&destination, &holder, &vault, &100) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + f.token.transfer(&holder, &destination, &100); + assert_eq!(f.token.balance(&vault), 500); + } + + #[test] + fn the_destination_must_pass_the_identity_check() { + let e = Env::default(); + let (f, holder, _, _) = live(&e); + let stranger = Address::generate(&e); + + assert_eq!( + f.token + .try_legal_transfer( + &holder, + &stranger, + &100, + &order(&e), + &f.compliance_authority + ) + .err(), + Some(Ok(RWAError::IdentityVerificationFailed.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn the_order_reference_is_required_and_bounded() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + let bytes = [b'x'; MAX_REFERENCE_LEN as usize + 1]; + let reference_of = |len: u32| String::from_bytes(&e, &bytes[..len as usize]); + + for reference in [reference_of(0), reference_of(MAX_REFERENCE_LEN + 1)] { + assert_eq!( + f.token + .try_legal_transfer( + &holder, + &destination, + &100, + &reference, + &f.compliance_authority + ) + .err(), + Some(Ok(ShareTokenError::InvalidReference.into())), + ); + } + + for reference in [reference_of(1), reference_of(MAX_REFERENCE_LEN)] { + f.token.legal_transfer( + &holder, + &destination, + &100, + &reference, + &f.compliance_authority, + ); + } + assert_eq!(f.token.balance(&destination), 200); + } + + #[test] + fn one_account_cannot_co_sign_with_itself() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + f.token + .grant_role(&f.admin, &Symbol::new(&e, "compliance"), &f.admin); + + assert_eq!( + f.token + .try_legal_transfer(&holder, &destination, &100, &order(&e), &f.admin) + .err(), + Some(Ok(ShareTokenError::CoSignRequired.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn compliance_no_longer_forces_transfers() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + assert_unauthorized(f.token.try_forced_transfer( + &holder, + &destination, + &100, + &f.compliance_authority, + )); + } + + /// Accepts every account and names one recovery target; the kit's + /// verifier names none, so no recovery could succeed with it. + #[contract] + struct RecoveringVerifier; + + #[contracttype] + enum RecoveryKey { + Target, + } + + #[contractimpl] + impl RecoveringVerifier { + pub fn set_target(e: Env, target: Address) { + e.storage().instance().set(&RecoveryKey::Target, &target); + } + } + + #[contractimpl(contracttrait)] + impl identity_verification::IdentityVerifier for RecoveringVerifier { + fn verify_identity(_e: &Env, _account: &Address) {} + + fn recovery_target(e: &Env, _old_account: &Address) -> Option
      { + e.storage().instance().get(&RecoveryKey::Target) + } + + fn set_claim_topics_and_issuers( + _e: &Env, + _claim_topics_and_issuers: Address, + _operator: Address, + ) { + } + } + + /// The live fixture's setup is still open, so the verifier swap is instant. + fn recovering_to(e: &Env, f: &Fixture, target: &Address) { + let verifier = RecoveringVerifierClient::new(e, &e.register(RecoveringVerifier, ())); + verifier.set_target(target); + f.token.set_identity_verifier(&verifier.address, &f.admin); + } + + #[test] + fn recovery_needs_the_admin_too() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + recovering_to(&e, &f, &destination); + let invoke = MockAuthInvoke { + contract: &f.token.address, + fn_name: "recover_balance", + args: ( + holder.clone(), + destination.clone(), + f.compliance_authority.clone(), + ) + .into_val(&e), + sub_invokes: &[], + }; + + e.mock_auths(&[MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }]); + missing_signature(f.token.try_recover_balance( + &holder, + &destination, + &f.compliance_authority, + )); + assert_eq!(f.token.balance(&holder), 1_000); + + e.mock_auths(&[ + MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }, + MockAuth { + address: &f.admin, + invoke: &invoke, + }, + ]); + assert!(f + .token + .recover_balance(&holder, &destination, &f.compliance_authority)); + assert_eq!(f.token.balance(&holder), 0); + assert_eq!(f.token.balance(&destination), 1_000); + } + + #[test] + fn a_legal_transfer_or_a_recovery_asks_each_signer_once() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + f.token.legal_transfer( + &holder, + &destination, + &250, + &order(&e), + &f.compliance_authority, + ); + f.signed_once_by( + &[&f.compliance_authority, &f.admin], + "legal_transfer", + ( + holder.clone(), + destination.clone(), + 250_i128, + order(&e), + f.compliance_authority.clone(), + ) + .into_val(&e), + ); + + recovering_to(&e, &f, &destination); + f.token + .recover_balance(&holder, &destination, &f.compliance_authority); + f.signed_once_by( + &[&f.compliance_authority, &f.admin], + "recover_balance", + (holder, destination, f.compliance_authority.clone()).into_val(&e), + ); + } + + #[test] + fn recovery_never_takes_the_vault_balance() { + let e = Env::default(); + let (f, _, destination, vault) = live(&e); + recovering_to(&e, &f, &destination); + + assert_eq!( + f.token + .try_recover_balance(&vault, &destination, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&vault), 500); + } + + #[test] + fn recovery_never_lands_on_the_vault_address() { + let e = Env::default(); + let (f, holder, _, vault) = live(&e); + recovering_to(&e, &f, &vault); + + assert_eq!( + f.token + .try_recover_balance(&holder, &vault, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn the_vault_address_cannot_be_frozen() { + let e = Env::default(); + let (f, _, _, vault) = live(&e); + + assert_eq!( + f.token + .try_set_address_frozen(&vault, &true, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!( + f.token + .try_freeze_partial_tokens(&vault, &100, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + f.token + .set_address_frozen(&vault, &false, &f.compliance_authority); + assert!(!f.token.is_frozen(&vault)); + assert_eq!(f.token.get_frozen_tokens(&vault), 0); + } +} diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs new file mode 100644 index 00000000..c74bb71a --- /dev/null +++ b/contracts/share-token/src/test_governance.rs @@ -0,0 +1,1197 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{ + storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, + Events as _, Ledger as _, MockAuth, MockAuthInvoke, + }, + vec, Address, BytesN, Env, Event as _, IntoVal, String, Symbol, Vec, +}; +use stellar_access::role_transfer::RoleTransferError; +use strata_storage::INSTANCE_EXTEND_AMOUNT; +use strata_timelock::{Clock, DAY_DURATION, MIN_DELAY}; + +use crate::{ + ChangeApplied, ChangeProposed, ShareToken, ShareTokenClient, ShareTokenError, TokenChange, + ADMIN_ACCEPT_LEDGERS, +}; +use compliance::{Compliance, ComplianceClient}; +use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; + +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +/// Plays the vault: reports whatever clock a test sets, and counts the reads. +#[contract] +struct ClockStub; + +#[contractimpl] +impl ClockStub { + pub fn set(e: Env, clock: Clock) { + e.storage().instance().set(&symbol_short!("clock"), &clock); + } + + pub fn reads(e: Env) -> u32 { + e.storage() + .instance() + .get(&symbol_short!("reads")) + .unwrap_or(0) + } +} + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for ClockStub { + fn timelock_clock(e: &Env) -> Clock { + let reads = Self::reads(e.clone()) + 1; + e.storage().instance().set(&symbol_short!("reads"), &reads); + e.storage().instance().get(&symbol_short!("clock")).unwrap() + } +} + +fn clock_stub<'a>(e: &Env) -> ClockStubClient<'a> { + let vault = ClockStubClient::new(e, &e.register(ClockStub, ())); + vault.set(&Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + }); + vault +} + +/// Compliance rules whose answers a test sets, and may change after a swap to +/// them is proposed. +#[contract] +struct RulesStub; + +#[contractimpl] +impl RulesStub { + pub fn report(e: Env, vault: Option
      , setup_open: bool, linked: Vec
      ) { + let store = e.storage().instance(); + store.set(&symbol_short!("vault"), &vault); + store.set(&symbol_short!("open"), &setup_open); + store.set(&symbol_short!("linked"), &linked); + } + + /// Every view fails from now on. + pub fn silence(e: Env) { + let store = e.storage().instance(); + store.remove(&symbol_short!("vault")); + store.remove(&symbol_short!("open")); + store.remove(&symbol_short!("linked")); + } + + /// One view, `vault` or `open`, fails from now on. + pub fn forget(e: Env, view: Symbol) { + e.storage().instance().remove(&view); + } + + pub fn linked_tokens(e: Env) -> Vec
      { + e.storage() + .instance() + .get(&symbol_short!("linked")) + .unwrap() + } +} + +#[contractimpl(contracttrait)] +impl strata_bindings::VaultBinding for RulesStub { + fn vault(e: &Env) -> Option
      { + e.storage().instance().get(&symbol_short!("vault")).unwrap() + } + + fn setup_open(e: &Env) -> bool { + e.storage().instance().get(&symbol_short!("open")).unwrap() + } +} + +/// Rules from outside the kit, such as OpenZeppelin's, with no binding view. +#[contract] +struct ExternalRules; + +#[contractimpl] +impl ExternalRules { + pub fn list(e: Env, linked: Vec
      ) { + e.storage() + .instance() + .set(&symbol_short!("linked"), &linked); + } + + pub fn linked_tokens(e: Env) -> Vec
      { + e.storage() + .instance() + .get(&symbol_short!("linked")) + .unwrap() + } +} + +/// A verifier that accepts every account until a test silences it. +#[contract] +struct VerifierStub; + +#[contractimpl] +impl VerifierStub { + pub fn silence(e: Env) { + e.storage().instance().set(&symbol_short!("silent"), &true); + } + + pub fn verify_identity(e: Env, _account: Address) { + assert!(!e.storage().instance().has(&symbol_short!("silent"))); + } +} + +struct Fixture<'a> { + e: Env, + token: ShareTokenClient<'a>, + vault: ClockStubClient<'a>, + admin: Address, +} + +impl Fixture<'_> { + fn clock(&self, f: impl FnOnce(&mut Clock)) { + let mut clock = + strata_timelock::ClockSourceClient::new(&self.e, &self.vault.address).timelock_clock(); + f(&mut clock); + self.vault.set(&clock); + } + + fn advance(&self, secs: u64) { + self.e.ledger().with_mut(|l| { + l.timestamp += secs; + l.sequence_number += (secs / 5).max(1) as u32; + }); + } + + fn finish(&self) { + self.token.bind_vault(&self.vault.address); + self.token.finish_setup(); + } + + fn propose(&self, change: TokenChange) -> u32 { + self.token.propose_change(&change) + } + + fn rules(&self) -> ComplianceClient<'_> { + ComplianceClient::new(&self.e, &self.e.register(Compliance, (self.admin.clone(),))) + } + + /// Rules the token may swap to: they list the token and run on this + /// vault's clock with their setup finished. + fn rules_bound_here(&self) -> Address { + let rules = self.rules(); + rules.bind_token(&self.token.address, &self.admin); + rules.bind_vault(&self.vault.address); + rules.finish_setup(); + rules.address + } + + /// Stub rules that qualify until a test changes their answers. + fn rules_stub_bound_here(&self) -> RulesStubClient<'_> { + let rules = RulesStubClient::new(&self.e, &self.e.register(RulesStub, ())); + rules.report( + &Some(self.vault.address.clone()), + &false, + &vec![&self.e, self.token.address.clone()], + ); + rules + } + + fn rules_from_outside(&self, linked: &Vec
      ) -> ExternalRulesClient<'_> { + let rules = ExternalRulesClient::new(&self.e, &self.e.register(ExternalRules, ())); + rules.list(linked); + rules + } + + fn verifier(&self) -> IdentityVerifierClient<'_> { + IdentityVerifierClient::new( + &self.e, + &self.e.register(IdentityVerifier, (self.admin.clone(),)), + ) + } + + fn verifier_accepting_the_vault(&self) -> Address { + let verifier = self.verifier(); + verifier.allow(&self.vault.address, &true); + verifier.address + } + + /// Lands an `Admin` change for `next` and returns the ledger of the offer. + fn offer_admin(&self, next: &Address) -> u32 { + let id = self.propose(TokenChange::Admin(next.clone())); + self.advance(MIN_DELAY); + self.token.apply_change(&id); + self.e.ledger().sequence() + } + + fn instance_ttl(&self) -> u32 { + self.e.as_contract(&self.token.address, || { + self.e.storage().instance().get_ttl() + }) + } + + /// Lets the instance decay past its extend threshold, then checks the call + /// extends it back to the full TTL. + fn restores_the_instance(&self, call: impl FnOnce()) { + self.e + .ledger() + .with_mut(|l| l.sequence_number += TWO_DAYS_IN_LEDGERS); + assert!(self.instance_ttl() < INSTANCE_EXTEND_AMOUNT); + call(); + assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); + } + + /// Signs one call to the token as `who` and nothing else, so the call + /// carries that account's signature but not the admin's. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.token.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + + /// The last call asked `who` alone to sign, once, on exactly this call. + fn signed_once_by( + &self, + who: &Address, + fn_name: &str, + args: soroban_sdk::Vec, + ) { + assert_eq!( + self.e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.token.address.clone(), + Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + let admin = Address::generate(&e); + let token = e.register( + ShareToken, + ( + String::from_str(&e, "Strata Vault USDC"), + String::from_str(&e, "bvUSDC"), + admin.clone(), + Address::generate(&e), + Address::generate(&e), + Address::generate(&e), + ), + ); + let vault = clock_stub(&e); + Fixture { + token: ShareTokenClient::new(&e, &token), + vault, + admin, + e, + } +} + +fn refused( + result: Result, Result>, + expected: ShareTokenError, +) { + assert_eq!(result, Err(Ok(expected.into()))); +} + +/// Asserts a call failed for lack of a valid signature, or because the callee +/// answers no interface the host can invoke: either way, the host's own +/// refusal of the invocation, not one of the token's named errors. +fn missing_auth( + result: Result, Result>, +) { + use soroban_sdk::xdr::{ScErrorCode, ScErrorType}; + assert_eq!( + result, + Err(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Context, + ScErrorCode::InvalidAction + ))) + ); +} + +fn manager() -> Symbol { + symbol_short!("manager") +} + +fn compliance(e: &Env) -> Symbol { + Symbol::new(e, "compliance") +} + +#[test] +fn setup_is_open_until_finished_and_never_reopens() { + let f = setup(); + assert!(f.token.setup_open()); + refused(f.token.try_finish_setup(), ShareTokenError::VaultNotBound); + + f.finish(); + + assert!(!f.token.setup_open()); + assert_eq!(f.token.vault(), Some(f.vault.address.clone())); + refused(f.token.try_finish_setup(), ShareTokenError::SetupClosed); + refused( + f.token.try_bind_vault(&Address::generate(&f.e)), + ShareTokenError::SetupClosed, + ); +} + +#[test] +fn a_second_vault_cannot_be_bound() { + let f = setup(); + f.token.bind_vault(&f.vault.address); + + refused( + f.token.try_bind_vault(&Address::generate(&f.e)), + ShareTokenError::SetupClosed, + ); + assert_eq!(f.token.vault(), Some(f.vault.address.clone())); +} + +#[test] +fn an_address_that_reports_no_clock_cannot_be_bound() { + let f = setup(); + + missing_auth(f.token.try_bind_vault(&Address::generate(&f.e))); + assert_eq!(f.token.vault(), None); +} + +#[test] +fn only_the_admin_binds_and_finishes() { + let f = setup(); + let stranger = Address::generate(&f.e); + + f.sign_only_as( + &stranger, + "bind_vault", + (f.vault.address.clone(),).into_val(&f.e), + ); + missing_auth(f.token.try_bind_vault(&f.vault.address)); + assert_eq!(f.token.vault(), None); + + f.e.mock_all_auths(); + f.token.bind_vault(&f.vault.address); + f.sign_only_as(&stranger, "finish_setup", soroban_sdk::Vec::new(&f.e)); + missing_auth(f.token.try_finish_setup()); + assert!(f.token.setup_open()); +} + +fn implements_the_vault_view() {} + +#[test] +fn the_token_answers_the_view_the_vault_reads() { + implements_the_vault_view::(); + let f = setup(); + let view = strata_bindings::VaultBindingClient::new(&f.e, &f.token.address); + assert_eq!(view.vault(), None); + assert!(view.setup_open()); + + f.finish(); + + assert_eq!(view.vault(), Some(f.vault.address.clone())); + assert!(!view.setup_open()); +} + +#[test] +fn grants_and_manager_revokes_wait_after_setup() { + let f = setup(); + let early = Address::generate(&f.e); + f.token.grant_role(&early, &manager(), &f.admin); + assert!(f.token.has_role(&early, &manager()).is_some()); + + f.finish(); + + refused( + f.token + .try_grant_role(&Address::generate(&f.e), &manager(), &f.admin), + ShareTokenError::UseTimelock, + ); + refused( + f.token.try_revoke_role(&early, &manager(), &f.admin), + ShareTokenError::UseTimelock, + ); + assert!(f.token.has_role(&early, &manager()).is_some()); +} + +#[test] +fn rule_and_verifier_swaps_wait_after_setup() { + let f = setup(); + f.finish(); + + refused( + f.token + .try_set_compliance(&Address::generate(&f.e), &f.admin), + ShareTokenError::UseTimelock, + ); + refused( + f.token + .try_set_identity_verifier(&Address::generate(&f.e), &f.admin), + ShareTokenError::UseTimelock, + ); +} + +#[test] +fn revoking_compliance_stays_instant_after_setup() { + let f = setup(); + let officer = Address::generate(&f.e); + f.token.grant_role(&officer, &compliance(&f.e), &f.admin); + f.finish(); + + f.token.revoke_role(&officer, &compliance(&f.e), &f.admin); + + assert!(f.token.has_role(&officer, &compliance(&f.e)).is_none()); +} + +#[test] +fn an_admin_hand_over_waits_but_cancelling_one_does_not() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + + refused( + f.token + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + ShareTokenError::UseTimelock, + ); + + let id = f.propose(TokenChange::Admin(next.clone())); + f.advance(MIN_DELAY); + f.token.apply_change(&id); + f.token.transfer_admin_role(&next, &0); + + assert_eq!( + f.token.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = setup(); + let next = Address::generate(&f.e); + f.token.bind_vault(&f.vault.address); + f.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.token.try_finish_setup(), + ShareTokenError::AdminTransferPending, + ); + + f.token.transfer_admin_role(&next, &0); + f.token.finish_setup(); + assert_eq!( + f.token.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn an_expired_hand_over_does_not_hold_setup_open() { + let f = setup(); + f.token.bind_vault(&f.vault.address); + f.token + .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); + f.e.ledger().with_mut(|l| l.sequence_number += 11); + + f.token.finish_setup(); + + assert!(!f.token.setup_open()); + assert_eq!( + f.token.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn no_role_ever_gets_a_role_admin() { + let f = setup(); + + refused( + f.token + .try_set_role_admin(&manager(), &symbol_short!("guardian")), + ShareTokenError::UseTimelock, + ); +} + +#[test] +fn nothing_is_proposed_during_setup() { + let f = setup(); + + refused( + f.token + .try_propose_change(&TokenChange::GrantRole(manager(), f.admin.clone())), + ShareTokenError::SetupOpen, + ); + assert_eq!(f.token.pending_changes().len(), 0); +} + +#[test] +fn a_grant_lands_one_vault_delay_after_it_was_proposed() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + + let id = f.propose(TokenChange::GrantRole(manager(), next.clone())); + let pending = f.token.pending_changes(); + assert_eq!(pending.len(), 1); + assert_eq!( + pending.get(0).unwrap().eta, + f.e.ledger().timestamp() + MIN_DELAY + ); + + f.advance(MIN_DELAY - 1); + refused( + f.token.try_apply_change(&id), + ShareTokenError::ChangeNotReady, + ); + + f.advance(1); + f.token.apply_change(&id); + assert!(f.token.has_role(&next, &manager()).is_some()); + assert_eq!(f.token.pending_changes().len(), 0); +} + +#[test] +fn a_revoke_lands_through_the_timelock() { + let f = setup(); + let holder = Address::generate(&f.e); + f.token.grant_role(&holder, &manager(), &f.admin); + f.finish(); + + let id = f.propose(TokenChange::RevokeRole(manager(), holder.clone())); + f.advance(MIN_DELAY); + f.token.apply_change(&id); + + assert!(f.token.has_role(&holder, &manager()).is_none()); +} + +#[test] +fn a_vault_pause_moves_the_eta_and_holds_the_change() { + let f = setup(); + f.finish(); + let id = f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); + let eta = f.token.pending_changes().get(0).unwrap().eta; + + f.advance(DAY_DURATION); + f.clock(|c| c.paused = true); + f.advance(3 * DAY_DURATION); + f.clock(|c| c.paused_secs = 3 * DAY_DURATION); + f.advance(MIN_DELAY); + + refused(f.token.try_apply_change(&id), ShareTokenError::VaultPaused); + + f.clock(|c| c.paused = false); + assert_eq!( + f.token.pending_changes().get(0).unwrap().eta, + eta + 3 * DAY_DURATION + ); + f.advance(3 * DAY_DURATION); + f.token.apply_change(&id); +} + +#[test] +fn the_proposal_event_carries_the_eta_pending_changes_reports_then() { + let f = setup(); + f.finish(); + f.clock(|c| { + c.paused = true; + c.paused_secs = DAY_DURATION; + }); + + let change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + let id = f.token.propose_change(&change); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.token.address), + [ChangeProposed { id, change, eta }.to_xdr(&f.e, &f.token.address)] + ); + assert_eq!(f.token.pending_changes().get(0).unwrap().eta, eta); + + f.clock(|c| c.paused_secs = 2 * DAY_DURATION); + assert_eq!( + f.token.pending_changes().get(0).unwrap().eta, + eta + DAY_DURATION + ); +} + +#[test] +fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { + let f = setup(); + f.finish(); + let id = f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); + f.clock(|c| c.winding_down = true); + f.advance(MIN_DELAY); + + refused( + f.token + .try_propose_change(&TokenChange::GrantRole(manager(), Address::generate(&f.e))), + ShareTokenError::WindDownActive, + ); + refused( + f.token.try_propose_change(&TokenChange::RevokeRole( + compliance(&f.e), + Address::generate(&f.e), + )), + ShareTokenError::WindDownActive, + ); + refused( + f.token.try_apply_change(&id), + ShareTokenError::WindDownActive, + ); + f.token.cancel_change(&id); +} + +#[test] +fn a_compliance_grant_lands_through_the_timelock_during_a_wind_down() { + let f = setup(); + f.finish(); + f.clock(|c| c.winding_down = true); + let successor = Address::generate(&f.e); + let id = f.propose(TokenChange::GrantRole(compliance(&f.e), successor.clone())); + + refused( + f.token.try_apply_change(&id), + ShareTokenError::ChangeNotReady, + ); + f.advance(MIN_DELAY); + f.token.apply_change(&id); + assert!(f.token.has_role(&successor, &compliance(&f.e)).is_some()); +} + +#[test] +fn a_compliance_grant_proposed_before_the_wind_down_lands_during_it() { + let f = setup(); + f.finish(); + let successor = Address::generate(&f.e); + let id = f.propose(TokenChange::GrantRole(compliance(&f.e), successor.clone())); + f.clock(|c| c.winding_down = true); + f.advance(MIN_DELAY); + + f.token.apply_change(&id); + assert!(f.token.has_role(&successor, &compliance(&f.e)).is_some()); +} + +#[test] +fn a_paused_vault_still_holds_a_compliance_grant_in_a_wind_down() { + let f = setup(); + f.finish(); + f.clock(|c| { + c.winding_down = true; + c.paused = true; + }); + let id = f.propose(TokenChange::GrantRole( + compliance(&f.e), + Address::generate(&f.e), + )); + f.advance(MIN_DELAY); + + refused(f.token.try_apply_change(&id), ShareTokenError::VaultPaused); +} + +#[test] +fn governance_still_revokes_the_last_compliance_holder_at_once() { + let f = setup(); + f.finish(); + let holder = f.token.get_role_member(&compliance(&f.e), &0); + + f.token.revoke_role(&holder, &compliance(&f.e), &f.admin); + assert_eq!(f.token.get_role_member_count(&compliance(&f.e)), 0); +} + +#[test] +fn an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + f.offer_admin(&next); + f.clock(|c| c.winding_down = true); + + refused( + f.token.try_accept_admin_transfer(), + ShareTokenError::WindDownActive, + ); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = setup(); + f.finish(); + let id = f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); + f.clock(|c| { + c.delay = 30 * DAY_DURATION; + c.exit_window = 20 * DAY_DURATION; + }); + f.advance(30 * DAY_DURATION); + + refused( + f.token.try_apply_change(&id), + ShareTokenError::DelayBelowNotice, + ); +} + +#[test] +fn one_upgrade_waits_at_a_time() { + let f = setup(); + f.finish(); + f.propose(TokenChange::Upgrade(BytesN::from_array(&f.e, &[7; 32]))); + + refused( + f.token + .try_propose_change(&TokenChange::Upgrade(BytesN::from_array(&f.e, &[9; 32]))), + ShareTokenError::UpgradeAlreadyPending, + ); +} + +#[test] +fn only_the_admin_proposes_cancels_and_applies() { + let f = setup(); + f.finish(); + let stranger = Address::generate(&f.e); + let change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); + let id = f.propose(change.clone()); + f.advance(MIN_DELAY); + + f.sign_only_as( + &stranger, + "propose_change", + (change.clone(),).into_val(&f.e), + ); + missing_auth(f.token.try_propose_change(&change)); + f.sign_only_as(&stranger, "cancel_change", (id,).into_val(&f.e)); + missing_auth(f.token.try_cancel_change(&id)); + f.sign_only_as(&stranger, "apply_change", (id,).into_val(&f.e)); + missing_auth(f.token.try_apply_change(&id)); + assert_eq!(f.token.pending_changes().len(), 1); +} + +#[test] +fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let id = f.propose(TokenChange::Admin(next.clone())); + f.advance(MIN_DELAY); + + // One admin signature covers the call; the hand-over inside asks for none. + f.sign_only_as(&f.admin, "apply_change", (id,).into_val(&f.e)); + f.token.apply_change(&id); + + f.e.mock_all_auths(); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); + f.token.accept_admin_transfer(); + assert_eq!(f.token.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_is_open_on_its_last_ledger() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let offered_at = f.offer_admin(&next); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.token.accept_admin_transfer(); + + assert_eq!(f.token.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = setup(); + f.finish(); + let offered_at = f.offer_admin(&Address::generate(&f.e)); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1); + + assert_eq!( + f.token.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn applied_rule_and_verifier_changes_take_effect() { + let f = setup(); + f.finish(); + let rules = f.rules_bound_here(); + let verifier = f.verifier_accepting_the_vault(); + let first = f.propose(TokenChange::Compliance(rules.clone())); + let second = f.propose(TokenChange::IdentityVerifier(verifier.clone())); + f.advance(MIN_DELAY); + + f.token.apply_change(&first); + f.token.apply_change(&second); + + assert_eq!(f.token.compliance(), rules); + assert_eq!(f.token.identity_verifier(), verifier); +} + +#[test] +fn rules_off_this_vaults_clock_or_without_the_token_are_never_proposed() { + let f = setup(); + f.finish(); + let unlisted = f.rules(); + unlisted.bind_vault(&f.vault.address); + unlisted.finish_setup(); + let elsewhere = f.rules(); + elsewhere.bind_token(&f.token.address, &f.admin); + elsewhere.bind_vault(&clock_stub(&f.e).address); + elsewhere.finish_setup(); + let in_setup = f.rules(); + in_setup.bind_token(&f.token.address, &f.admin); + in_setup.bind_vault(&f.vault.address); + let outside_for_another_token = f.rules_from_outside(&vec![&f.e, Address::generate(&f.e)]); + let not_a_contract = Address::generate(&f.e); + + for rules in [ + unlisted.address, + elsewhere.address, + in_setup.address, + outside_for_another_token.address, + not_a_contract, + ] { + refused( + f.token.try_propose_change(&TokenChange::Compliance(rules)), + ShareTokenError::ComplianceNotBound, + ); + } + assert_eq!(f.token.pending_changes().len(), 0); +} + +/// Proposes a swap to rules that qualify, lets `turn` change their answers, +/// then checks the swap no longer lands. +fn refused_once_the_rules_turn(f: &Fixture, turn: impl FnOnce(&RulesStubClient)) { + let rules = f.rules_stub_bound_here(); + let id = f.propose(TokenChange::Compliance(rules.address.clone())); + turn(&rules); + f.advance(MIN_DELAY); + + refused( + f.token.try_apply_change(&id), + ShareTokenError::ComplianceNotBound, + ); + assert_ne!(f.token.compliance(), rules.address); +} + +#[test] +fn rules_that_stop_qualifying_are_never_installed() { + let f = setup(); + f.finish(); + let here = Some(f.vault.address.clone()); + let listed = vec![&f.e, f.token.address.clone()]; + + refused_once_the_rules_turn(&f, |rules| rules.report(&here, &false, &vec![&f.e])); + refused_once_the_rules_turn(&f, |rules| { + rules.report(&Some(clock_stub(&f.e).address), &false, &listed) + }); + refused_once_the_rules_turn(&f, |rules| rules.report(&None, &false, &listed)); + refused_once_the_rules_turn(&f, |rules| rules.report(&here, &true, &listed)); + refused_once_the_rules_turn(&f, |rules| rules.silence()); +} + +/// Their own governance is a trust assumption, as at the vault's setup. +#[test] +fn rules_from_outside_the_kit_are_installed_while_they_list_the_token() { + let f = setup(); + f.finish(); + let listed = vec![&f.e, f.token.address.clone()]; + let kept = f.rules_from_outside(&listed); + let dropped = f.rules_from_outside(&listed); + let first = f.propose(TokenChange::Compliance(kept.address.clone())); + let second = f.propose(TokenChange::Compliance(dropped.address.clone())); + dropped.list(&vec![&f.e]); + f.advance(MIN_DELAY); + + refused( + f.token.try_apply_change(&second), + ShareTokenError::ComplianceNotBound, + ); + f.token.apply_change(&first); + assert_eq!(f.token.compliance(), kept.address); +} + +/// Like `refused_once_the_rules_turn`, but the swap still lands. +fn installed_once_the_rules_turn(f: &Fixture, turn: impl FnOnce(&RulesStubClient)) { + let rules = f.rules_stub_bound_here(); + let id = f.propose(TokenChange::Compliance(rules.address.clone())); + turn(&rules); + f.advance(MIN_DELAY); + + f.token.apply_change(&id); + assert_eq!(f.token.compliance(), rules.address); +} + +/// As at the vault's setup, once one view fails, the other is ignored. +#[test] +fn rules_that_stop_answering_a_binding_view_are_installed_as_external() { + let f = setup(); + f.finish(); + let here = Some(f.vault.address.clone()); + let listed = vec![&f.e, f.token.address.clone()]; + + installed_once_the_rules_turn(&f, |rules| { + rules.report(&Some(clock_stub(&f.e).address), &false, &listed); + rules.forget(&symbol_short!("open")); + }); + installed_once_the_rules_turn(&f, |rules| { + rules.report(&here, &true, &listed); + rules.forget(&symbol_short!("vault")); + }); +} + +#[test] +fn a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed() { + let f = setup(); + f.finish(); + let refusing = f.verifier().address; + let not_a_verifier = f.vault.address.clone(); + let not_a_contract = Address::generate(&f.e); + + for verifier in [refusing, not_a_verifier, not_a_contract] { + refused( + f.token + .try_propose_change(&TokenChange::IdentityVerifier(verifier)), + ShareTokenError::VerifierRefusesVault, + ); + } + assert_eq!(f.token.pending_changes().len(), 0); +} + +#[test] +fn a_verifier_that_stops_accepting_the_vault_is_never_installed() { + let f = setup(); + f.finish(); + let delisting = f.verifier(); + delisting.allow(&f.vault.address, &true); + let silenced = VerifierStubClient::new(&f.e, &f.e.register(VerifierStub, ())); + let first = f.propose(TokenChange::IdentityVerifier(delisting.address.clone())); + let second = f.propose(TokenChange::IdentityVerifier(silenced.address.clone())); + + delisting.allow(&f.vault.address, &false); + silenced.silence(); + f.advance(MIN_DELAY); + + for id in [first, second] { + refused( + f.token.try_apply_change(&id), + ShareTokenError::VerifierRefusesVault, + ); + } + assert_eq!(f.token.pending_changes().len(), 2); +} + +#[test] +fn proposing_and_applying_publish_the_change() { + let f = setup(); + f.finish(); + let change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + assert_eq!( + f.e.events().all().filter_by_contract(&f.token.address), + [ChangeProposed { + id, + change: change.clone(), + eta, + } + .to_xdr(&f.e, &f.token.address)] + ); + + f.advance(MIN_DELAY); + f.token.apply_change(&id); + let events = f.e.events().all().filter_by_contract(&f.token.address); + assert_eq!( + events.events().last(), + Some(&ChangeApplied { id, change }.to_xdr(&f.e, &f.token.address)) + ); +} + +#[test] +fn proposing_reads_the_vault_clock_once() { + let f = setup(); + f.finish(); + let before = f.vault.reads(); + + f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); + + assert_eq!(f.vault.reads(), before + 1); +} + +#[test] +fn a_proposal_after_a_past_pause_waits_one_delay_from_now() { + let f = setup(); + f.finish(); + f.clock(|c| c.paused_secs = 3 * DAY_DURATION); + let change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.token.address), + [ChangeProposed { id, change, eta }.to_xdr(&f.e, &f.token.address)] + ); + assert_eq!(f.token.pending_changes().get(0).unwrap().eta, eta); +} + +#[test] +fn the_schema_version_is_one_after_construction() { + let f = setup(); + + assert_eq!(f.token.schema_version(), 1); +} + +#[test] +fn every_admin_call_restores_the_instance_ttl() { + let f = setup(); + let holder = Address::generate(&f.e); + let next = Address::generate(&f.e); + + f.restores_the_instance(|| f.token.grant_role(&holder, &manager(), &f.admin)); + f.restores_the_instance(|| f.token.revoke_role(&holder, &manager(), &f.admin)); + f.restores_the_instance(|| { + f.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)) + }); + f.restores_the_instance(|| f.token.transfer_admin_role(&next, &0)); + f.restores_the_instance(|| f.token.bind_vault(&f.vault.address)); + f.restores_the_instance(|| f.token.finish_setup()); + + let grant = TokenChange::GrantRole(manager(), Address::generate(&f.e)); + let mut id = 0; + f.restores_the_instance(|| id = f.propose(grant.clone())); + f.restores_the_instance(|| f.token.cancel_change(&id)); + + let id = f.propose(grant); + f.advance(MIN_DELAY); + f.restores_the_instance(|| f.token.apply_change(&id)); + + f.offer_admin(&next); + f.restores_the_instance(|| f.token.accept_admin_transfer()); +} + +#[test] +fn each_admin_call_asks_the_admin_for_one_signature() { + let f = setup(); + let holder = Address::generate(&f.e); + let rules = Address::generate(&f.e); + let verifier = Address::generate(&f.e); + let live_until = f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS; + + f.token.pause(&f.admin); + f.signed_once_by(&f.admin, "pause", (f.admin.clone(),).into_val(&f.e)); + f.token.unpause(&f.admin); + f.signed_once_by(&f.admin, "unpause", (f.admin.clone(),).into_val(&f.e)); + f.token.set_compliance(&rules, &f.admin); + f.signed_once_by( + &f.admin, + "set_compliance", + (rules.clone(), f.admin.clone()).into_val(&f.e), + ); + f.token.set_identity_verifier(&verifier, &f.admin); + f.signed_once_by( + &f.admin, + "set_identity_verifier", + (verifier, f.admin.clone()).into_val(&f.e), + ); + f.token.grant_role(&holder, &manager(), &f.admin); + f.signed_once_by( + &f.admin, + "grant_role", + (holder.clone(), manager(), f.admin.clone()).into_val(&f.e), + ); + f.token.revoke_role(&holder, &manager(), &f.admin); + f.signed_once_by( + &f.admin, + "revoke_role", + (holder.clone(), manager(), f.admin.clone()).into_val(&f.e), + ); + f.token.transfer_admin_role(&holder, &live_until); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), live_until).into_val(&f.e), + ); + f.token.transfer_admin_role(&holder, &0); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), 0_u32).into_val(&f.e), + ); + + f.token.bind_vault(&f.vault.address); + f.signed_once_by( + &f.admin, + "bind_vault", + (f.vault.address.clone(),).into_val(&f.e), + ); + f.token.finish_setup(); + f.signed_once_by(&f.admin, "finish_setup", soroban_sdk::Vec::new(&f.e)); + + let change = TokenChange::Compliance(f.rules_bound_here()); + let id = f.token.propose_change(&change); + f.signed_once_by(&f.admin, "propose_change", (change.clone(),).into_val(&f.e)); + f.token.cancel_change(&id); + f.signed_once_by(&f.admin, "cancel_change", (id,).into_val(&f.e)); + + let id = f.token.propose_change(&TokenChange::Admin(holder.clone())); + f.advance(MIN_DELAY); + f.token.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); + f.token.accept_admin_transfer(); + f.signed_once_by( + &holder, + "accept_admin_transfer", + soroban_sdk::Vec::new(&f.e), + ); +} + +#[test] +fn a_caller_other_than_the_admin_is_refused_even_when_it_signs() { + let f = setup(); + let stranger = Address::generate(&f.e); + + refused(f.token.try_pause(&stranger), ShareTokenError::AdminRequired); + f.token.pause(&f.admin); + refused( + f.token.try_unpause(&stranger), + ShareTokenError::AdminRequired, + ); + refused( + f.token + .try_set_compliance(&Address::generate(&f.e), &stranger), + ShareTokenError::AdminRequired, + ); + refused( + f.token + .try_set_identity_verifier(&Address::generate(&f.e), &stranger), + ShareTokenError::AdminRequired, + ); + assert!(f.token.paused()); +} diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs new file mode 100644 index 00000000..6c99d594 --- /dev/null +++ b/contracts/share-token/src/test_guards.rs @@ -0,0 +1,342 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::Address as _, + xdr::{ScErrorCode, ScErrorType}, + Address, BytesN, Env, InvokeError, String, Symbol, +}; +use stellar_tokens::rwa::RWAError; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{ShareToken, ShareTokenClient, ShareTokenError, TokenChange, MAX_REFERENCE_LEN}; +use compliance::{Compliance, ComplianceClient}; +use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; + +/// Plays the vault: the token binds only to an address that reports a clock. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +struct Fixture<'a> { + e: Env, + token: ShareTokenClient<'a>, + identity: IdentityVerifierClient<'a>, + vault: Address, + admin: Address, + compliance_authority: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.token.bind_vault(&self.vault); + self.token.finish_setup(); + } + + /// Rules on the vault's clock with their setup finished. + fn rules_listing(&self, tokens: &[&Address]) -> Address { + let rules = + ComplianceClient::new(&self.e, &self.e.register(Compliance, (self.admin.clone(),))); + for token in tokens { + rules.bind_token(token, &self.admin); + } + rules.bind_vault(&self.vault); + rules.finish_setup(); + rules.address + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance_authority = Address::generate(&e); + let identity = IdentityVerifierClient::new(&e, &e.register(IdentityVerifier, (admin.clone(),))); + let token = ShareTokenClient::new( + &e, + &e.register( + ShareToken, + ( + String::from_str(&e, "Strata Vault USDC"), + String::from_str(&e, "bvUSDC"), + admin.clone(), + compliance_authority.clone(), + Address::generate(&e), + identity.address.clone(), + ), + ), + ); + let vault = e.register(VaultStub, ()); + Fixture { + e, + token, + identity, + vault, + admin, + compliance_authority, + } +} + +/// Runs `guard` inside the token, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.token.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: impl Into, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let f = setup(); + holds(&f, || guards::setup_open(&f.e)); + + f.finish_setup(); + refuses(&f, ShareTokenError::SetupClosed, || { + guards::setup_open(&f.e) + }); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, ShareTokenError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.token.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { + let f = setup(); + f.finish_setup(); + let upgrade = TokenChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + let grant = TokenChange::GrantRole(symbol_short!("manager"), Address::generate(&f.e)); + holds(&f, || guards::not_a_second_upgrade(&f.e, &upgrade)); + + f.token.propose_change(&upgrade); + refuses(&f, ShareTokenError::UpgradeAlreadyPending, || { + guards::not_a_second_upgrade(&f.e, &upgrade) + }); + holds(&f, || guards::not_a_second_upgrade(&f.e, &grant)); +} + +#[test] +fn compliance_bound_here_refuses_rules_without_the_token_or_naming_a_vault_before_one_is_bound() { + let f = setup(); + let listing = f.rules_listing(&[&f.token.address]); + refuses(&f, ShareTokenError::ComplianceNotBound, || { + guards::compliance_bound_here(&f.e, &listing) + }); + + f.finish_setup(); + holds(&f, || guards::compliance_bound_here(&f.e, &listing)); + for rules in [f.rules_listing(&[]), Address::generate(&f.e)] { + refuses(&f, ShareTokenError::ComplianceNotBound, || { + guards::compliance_bound_here(&f.e, &rules) + }); + } +} + +#[test] +fn verifier_accepts_vault_refuses_a_verifier_that_refuses_or_cannot_answer() { + let f = setup(); + f.identity.allow(&f.vault, &true); + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::verifier_accepts_vault(&f.e, &f.identity.address) + }); + + f.finish_setup(); + holds(&f, || { + guards::verifier_accepts_vault(&f.e, &f.identity.address) + }); + for verifier in [f.vault.clone(), Address::generate(&f.e)] { + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::verifier_accepts_vault(&f.e, &verifier) + }); + } + f.identity.allow(&f.vault, &false); + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::verifier_accepts_vault(&f.e, &f.identity.address) + }); +} + +#[test] +fn valid_change_checks_only_rule_and_verifier_swaps() { + let f = setup(); + f.finish_setup(); + f.identity.allow(&f.vault, &true); + let stranger = Address::generate(&f.e); + for change in [ + TokenChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])), + TokenChange::GrantRole(symbol_short!("manager"), stranger.clone()), + TokenChange::RevokeRole(symbol_short!("manager"), stranger.clone()), + TokenChange::Admin(stranger.clone()), + TokenChange::Compliance(f.rules_listing(&[&f.token.address])), + TokenChange::IdentityVerifier(f.identity.address.clone()), + ] { + holds(&f, || guards::valid_change(&f.e, &change)); + } + + refuses(&f, ShareTokenError::ComplianceNotBound, || { + guards::valid_change(&f.e, &TokenChange::Compliance(stranger.clone())) + }); + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::valid_change(&f.e, &TokenChange::IdentityVerifier(stranger.clone())) + }); +} + +#[test] +fn caller_is_admin_refuses_anyone_but_the_admin() { + let f = setup(); + let stranger = Address::generate(&f.e); + holds(&f, || guards::caller_is_admin(&f.e, &f.admin)); + + for who in [&f.compliance_authority, &stranger] { + refuses(&f, ShareTokenError::AdminRequired, || { + guards::caller_is_admin(&f.e, who) + }); + } +} + +#[test] +fn admin_co_signs_refuses_one_account_signing_as_both() { + let f = setup(); + holds(&f, || guards::admin_co_signs(&f.e, &f.compliance_authority)); + refuses(&f, ShareTokenError::CoSignRequired, || { + guards::admin_co_signs(&f.e, &f.admin) + }); + + f.e.set_auths(&[]); + assert_eq!( + run(&f, || guards::admin_co_signs(&f.e, &f.compliance_authority)), + Err(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Auth, + ScErrorCode::InvalidAction + ))) + ); +} + +#[test] +fn valid_reference_refuses_an_empty_or_overlong_reference() { + let f = setup(); + let bytes = [b'x'; MAX_REFERENCE_LEN as usize + 1]; + let reference = |len: u32| String::from_bytes(&f.e, &bytes[..len as usize]); + let (empty, shortest, longest, overlong) = ( + reference(0), + reference(1), + reference(MAX_REFERENCE_LEN), + reference(MAX_REFERENCE_LEN + 1), + ); + + holds(&f, || guards::valid_reference(&f.e, &shortest)); + holds(&f, || guards::valid_reference(&f.e, &longest)); + refuses(&f, ShareTokenError::InvalidReference, || { + guards::valid_reference(&f.e, &empty) + }); + refuses(&f, ShareTokenError::InvalidReference, || { + guards::valid_reference(&f.e, &overlong) + }); +} + +#[test] +fn not_vault_address_refuses_the_bound_vault() { + let f = setup(); + let holder = Address::generate(&f.e); + holds(&f, || guards::not_vault_address(&f.e, &f.vault)); + + f.token.bind_vault(&f.vault); + refuses(&f, ShareTokenError::VaultAddress, || { + guards::not_vault_address(&f.e, &f.vault) + }); + holds(&f, || guards::not_vault_address(&f.e, &holder)); +} + +#[test] +fn not_freezing_vault_refuses_only_a_freeze_of_the_vault() { + let f = setup(); + let holder = Address::generate(&f.e); + f.token.bind_vault(&f.vault); + + refuses(&f, ShareTokenError::VaultAddress, || { + guards::not_freezing_vault(&f.e, &f.vault, true) + }); + holds(&f, || guards::not_freezing_vault(&f.e, &f.vault, false)); + holds(&f, || guards::not_freezing_vault(&f.e, &holder, true)); +} + +#[test] +fn identity_verified_refuses_an_account_the_verifier_rejects() { + let f = setup(); + let listed = Address::generate(&f.e); + let stranger = Address::generate(&f.e); + f.identity.allow(&listed, &true); + + holds(&f, || guards::identity_verified(&f.e, &listed)); + refuses(&f, RWAError::IdentityVerificationFailed, || { + guards::identity_verified(&f.e, &stranger) + }); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let f = setup(); + holds(&f, || guards::direct_change_allowed(&f.e)); + + f.finish_setup(); + refuses(&f, ShareTokenError::UseTimelock, || { + guards::direct_change_allowed(&f.e) + }); +} + +#[test] +fn direct_revoke_allowed_refuses_only_the_manager_role_after_setup() { + let f = setup(); + let compliance = Symbol::new(&f.e, "compliance"); + holds(&f, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("manager")) + }); + + f.finish_setup(); + holds(&f, || guards::direct_revoke_allowed(&f.e, &compliance)); + refuses(&f, ShareTokenError::UseTimelock, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("manager")) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 100)); + + f.finish_setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, ShareTokenError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} diff --git a/contracts/share-token/src/test_ttl.rs b/contracts/share-token/src/test_ttl.rs new file mode 100644 index 00000000..cbbee7c4 --- /dev/null +++ b/contracts/share-token/src/test_ttl.rs @@ -0,0 +1,413 @@ +//! A new balance, freeze or role entry gets its full TTL in the call that creates it. + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, + testutils::{storage::Persistent as _, Address as _, Ledger as _}, + Address, Env, IntoVal, String, Symbol, Val, +}; +use stellar_access::access_control::{AccessControlStorageKey, ROLE_EXTEND_AMOUNT}; +use stellar_tokens::{ + fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}, + rwa::{identity_verification, RWAStorageKey, FROZEN_EXTEND_AMOUNT}, +}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::{ShareToken, ShareTokenClient, TokenChange}; +use compliance::{Compliance, ComplianceClient}; + +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; +/// Mainnet's, in ledgers: longer than any extend target. +const MAINNET_MIN_PERSISTENT_TTL: u32 = 2_073_600; +const DAY_IN_LEDGERS: u32 = 17_280; + +/// Accepts every account and names the recovery target a test sets. +#[contract] +struct OpenVerifier; + +#[contractimpl] +impl OpenVerifier { + pub fn set_recovery_target(e: Env, target: Address) { + e.storage() + .instance() + .set(&symbol_short!("target"), &target); + } +} + +#[contractimpl(contracttrait)] +impl identity_verification::IdentityVerifier for OpenVerifier { + fn verify_identity(_e: &Env, _account: &Address) {} + + fn recovery_target(e: &Env, _old_account: &Address) -> Option
      { + e.storage().instance().get(&symbol_short!("target")) + } + + fn set_claim_topics_and_issuers( + _e: &Env, + _claim_topics_and_issuers: Address, + _operator: Address, + ) { + } +} + +/// Plays the vault: its clock lets a timelocked change land. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + +struct Fixture<'a> { + e: Env, + token: ShareTokenClient<'a>, + verifier: OpenVerifierClient<'a>, + admin: Address, + compliance_authority: Address, +} + +impl Fixture<'_> { + fn persistent_ttl>(&self, key: &K) -> u32 { + self.e.as_contract(&self.token.address, || { + self.e.storage().persistent().get_ttl(key) + }) + } + + fn balance_ttl(&self, account: &Address) -> u32 { + self.persistent_ttl(&FungibleStorageKey::Balance(account.clone())) + } + + /// Every entry a grant writes: the role, its member-list slot, the member count, the role list. + fn role_entry_ttls(&self, account: &Address, role: &Symbol) -> [u32; 4] { + let has_role = AccessControlStorageKey::HasRole(account.clone(), role.clone()); + let index: u32 = self.e.as_contract(&self.token.address, || { + self.e.storage().persistent().get(&has_role).unwrap() + }); + [ + self.persistent_ttl(&has_role), + self.persistent_ttl(&RoleMemberKey::RoleAccounts(RoleAccountKey { + role: role.clone(), + index, + })), + self.persistent_ttl(&AccessControlStorageKey::RoleAccountsCount(role.clone())), + self.persistent_ttl(&AccessControlStorageKey::ExistingRoles), + ] + } + + /// A manager to mint with, and a holder of `shares`. + fn holder(&self, shares: i128) -> (Address, Address) { + let manager = Address::generate(&self.e); + self.token + .grant_role(&manager, &manager_role(), &self.admin); + let holder = Address::generate(&self.e); + self.token.mint(&holder, &shares, &manager); + (manager, holder) + } + + fn order(&self) -> String { + String::from_str(&self.e, "court-order-2026-117") + } + + fn age_days(&self, days: u32) { + self.e + .ledger() + .with_mut(|l| l.sequence_number += days * DAY_IN_LEDGERS); + } +} + +fn manager_role() -> Symbol { + symbol_short!("manager") +} + +/// A token in setup on a ledger with testnet's minimum lifetime; its verifier accepts everyone. +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); + let admin = Address::generate(&e); + let compliance_authority = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + let verifier = OpenVerifierClient::new(&e, &e.register(OpenVerifier, ())); + let token = ShareTokenClient::new( + &e, + &e.register( + ShareToken, + ( + String::from_str(&e, "Strata Vault USDC"), + String::from_str(&e, "bvUSDC"), + admin.clone(), + compliance_authority.clone(), + rules.address.clone(), + verifier.address.clone(), + ), + ), + ); + rules.bind_token(&token.address, &admin); + Fixture { + e, + token, + verifier, + admin, + compliance_authority, + } +} + +#[test] +fn a_mint_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + + let (_, holder) = f.holder(100); + + assert_eq!(f.balance_ttl(&holder), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let to = Address::generate(&f.e); + + f.token.transfer(&holder, &to, &40); + + assert_eq!(f.balance_ttl(&to), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_from_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let spender = Address::generate(&f.e); + let to = Address::generate(&f.e); + f.token + .approve(&holder, &spender, &40, &(f.e.ledger().sequence() + 100)); + + f.token.transfer_from(&spender, &holder, &to, &40); + + assert_eq!(f.balance_ttl(&to), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn an_escrow_writes_the_receiving_balance_with_the_full_ttl() { + let f = setup(); + let (manager, holder) = f.holder(100); + let vault = Address::generate(&f.e); + + f.token.forced_transfer(&holder, &vault, &40, &manager); + + assert_eq!(f.balance_ttl(&vault), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_legal_transfer_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let to = Address::generate(&f.e); + + f.token + .legal_transfer(&holder, &to, &40, &f.order(), &f.compliance_authority); + + assert_eq!(f.balance_ttl(&to), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_recovery_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let new_account = Address::generate(&f.e); + f.verifier.set_recovery_target(&new_account); + + assert!(f + .token + .recover_balance(&holder, &new_account, &f.compliance_authority)); + + assert_eq!(f.balance_ttl(&new_account), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_recovery_writes_the_freezes_it_carries_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + f.token + .freeze_partial_tokens(&holder, &30, &f.compliance_authority); + f.token + .set_address_frozen(&holder, &true, &f.compliance_authority); + let new_account = Address::generate(&f.e); + f.verifier.set_recovery_target(&new_account); + + assert!(f + .token + .recover_balance(&holder, &new_account, &f.compliance_authority)); + + assert_eq!( + f.persistent_ttl(&RWAStorageKey::FrozenTokens(new_account.clone())), + FROZEN_EXTEND_AMOUNT + ); + assert_eq!( + f.persistent_ttl(&RWAStorageKey::AddressFrozen(new_account)), + FROZEN_EXTEND_AMOUNT + ); +} + +/// OpenZeppelin writes a zero balance for an empty account that sends nothing. +#[test] +fn sending_nothing_from_an_empty_account_writes_its_balance_with_the_full_ttl() { + let f = setup(); + let (manager, _) = f.holder(100); + let to = Address::generate(&f.e); + let empty = || Address::generate(&f.e); + let (by_transfer, by_transfer_from, by_escrow, by_order, by_burn) = + (empty(), empty(), empty(), empty(), empty()); + + f.token.transfer(&by_transfer, &to, &0); + f.token.transfer_from(&to, &by_transfer_from, &to, &0); + f.token.forced_transfer(&by_escrow, &to, &0, &manager); + f.token + .legal_transfer(&by_order, &to, &0, &f.order(), &f.compliance_authority); + f.token.burn(&by_burn, &0, &manager); + + for account in [by_transfer, by_transfer_from, by_escrow, by_order, by_burn] { + assert_eq!(f.balance_ttl(&account), BALANCE_EXTEND_AMOUNT); + } +} + +#[test] +fn a_freeze_writes_its_entry_with_the_full_ttl() { + let f = setup(); + let account = Address::generate(&f.e); + + f.token + .set_address_frozen(&account, &true, &f.compliance_authority); + + assert_eq!( + f.persistent_ttl(&RWAStorageKey::AddressFrozen(account)), + FROZEN_EXTEND_AMOUNT + ); +} + +#[test] +fn a_partial_freeze_writes_its_entry_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let empty = Address::generate(&f.e); + + f.token + .freeze_partial_tokens(&holder, &30, &f.compliance_authority); + f.token + .unfreeze_partial_tokens(&empty, &0, &f.compliance_authority); + + for account in [holder, empty] { + assert_eq!( + f.persistent_ttl(&RWAStorageKey::FrozenTokens(account)), + FROZEN_EXTEND_AMOUNT + ); + } +} + +#[test] +fn the_constructor_writes_the_compliance_role_with_the_full_ttl() { + let f = setup(); + + assert_eq!( + f.role_entry_ttls(&f.compliance_authority, &Symbol::new(&f.e, "compliance")), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_during_setup_writes_the_role_with_the_full_ttl() { + let f = setup(); + let vault = Address::generate(&f.e); + + f.token.grant_role(&vault, &manager_role(), &f.admin); + + assert_eq!( + f.role_entry_ttls(&vault, &manager_role()), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { + let f = setup(); + f.token.bind_vault(&f.e.register(VaultStub, ())); + f.token.finish_setup(); + let next = Address::generate(&f.e); + let id = f + .token + .propose_change(&TokenChange::GrantRole(manager_role(), next.clone())); + f.e.ledger().with_mut(|l| { + l.timestamp += MIN_DELAY; + l.sequence_number += (MIN_DELAY / 5) as u32; + }); + + f.token.apply_change(&id); + + assert_eq!( + f.role_entry_ttls(&next, &manager_role()), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn under_mainnets_minimum_a_new_balance_is_first_renewed_on_day_91() { + let f = setup(); + f.e.ledger() + .set_min_persistent_entry_ttl(MAINNET_MIN_PERSISTENT_TTL); + let (_, holder) = f.holder(100); + assert_eq!(f.balance_ttl(&holder), MAINNET_MIN_PERSISTENT_TTL - 1); + + f.age_days(90); + f.token.balance(&holder); + assert_eq!( + f.balance_ttl(&holder), + MAINNET_MIN_PERSISTENT_TTL - 1 - 90 * DAY_IN_LEDGERS + ); + + f.age_days(1); + f.token.balance(&holder); + assert_eq!(f.balance_ttl(&holder), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn under_mainnets_minimum_a_new_role_is_first_renewed_on_day_31() { + let f = setup(); + f.e.ledger() + .set_min_persistent_entry_ttl(MAINNET_MIN_PERSISTENT_TTL); + let vault = Address::generate(&f.e); + f.token.grant_role(&vault, &manager_role(), &f.admin); + let has_role = AccessControlStorageKey::HasRole(vault.clone(), manager_role()); + assert_eq!(f.persistent_ttl(&has_role), MAINNET_MIN_PERSISTENT_TTL - 1); + + f.age_days(30); + f.token.has_role(&vault, &manager_role()); + assert_eq!( + f.persistent_ttl(&has_role), + MAINNET_MIN_PERSISTENT_TTL - 1 - 30 * DAY_IN_LEDGERS + ); + + f.age_days(1); + f.token.has_role(&vault, &manager_role()); + assert_eq!(f.persistent_ttl(&has_role), ROLE_EXTEND_AMOUNT); +} diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs deleted file mode 100644 index 80e77826..00000000 --- a/crates/bindings/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -#![no_std] - -use soroban_sdk::{contractclient, contracttype, Address, Env}; - -#[contracttype] -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum OracleState { - Valid, - Stale, - Paused, -} - -#[contractclient(name = "OracleFeedClient")] -pub trait OracleFeed { - fn state(e: &Env) -> OracleState; - fn nav_per_share(e: &Env) -> i128; - fn attested_at(e: &Env) -> u64; -} - -#[contractclient(name = "ShareClient")] -pub trait Share { - fn mint(e: &Env, to: Address, amount: i128, operator: Address); - fn burn(e: &Env, user_address: Address, amount: i128, operator: Address); - fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address); - fn transfer(e: &Env, from: Address, to: Address, amount: i128); - fn total_supply(e: &Env) -> i128; - fn balance(e: &Env, id: Address) -> i128; -} diff --git a/crates/bindings/Cargo.toml b/crates/strata-bindings/Cargo.toml similarity index 83% rename from crates/bindings/Cargo.toml rename to crates/strata-bindings/Cargo.toml index a7fb4a54..119a584c 100644 --- a/crates/bindings/Cargo.toml +++ b/crates/strata-bindings/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "bindings" +name = "strata-bindings" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/strata-bindings/src/errors.rs b/crates/strata-bindings/src/errors.rs new file mode 100644 index 00000000..1197ca54 --- /dev/null +++ b/crates/strata-bindings/src/errors.rs @@ -0,0 +1,100 @@ +//! The block of error codes each kit contract owns. A failure inside a called +//! contract reaches the caller with that contract's code, so the blocks never +//! overlap. + +/// An inclusive block of contract error codes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ErrorRange { + pub first: u32, + pub last: u32, +} + +impl ErrorRange { + pub const fn contains(&self, code: u32) -> bool { + self.first <= code && code <= self.last + } +} + +pub const ORACLE_ERRORS: ErrorRange = ErrorRange { + first: 3000, + last: 3999, +}; +pub const VAULT_ERRORS: ErrorRange = ErrorRange { + first: 6000, + last: 6999, +}; +pub const TOKEN_ERRORS: ErrorRange = ErrorRange { + first: 7000, + last: 7999, +}; +pub const COMPLIANCE_ERRORS: ErrorRange = ErrorRange { + first: 8000, + last: 8999, +}; + +const _: () = { + let ranges = [ORACLE_ERRORS, VAULT_ERRORS, TOKEN_ERRORS, COMPLIANCE_ERRORS]; + let mut i = 1; + while i < ranges.len() { + assert!( + ranges[i - 1].last < ranges[i].first, + "two contracts' error ranges overlap" + ); + i += 1; + } +}; + +/// Declares a contract's error enum. The build fails if a variant's code +/// leaves the range the contract owns. +/// +/// ``` +/// strata_bindings::error_enum! { +/// strata_bindings::VAULT_ERRORS, +/// #[repr(u32)] +/// pub enum DemoError { +/// First = 6000, +/// Last = 6999, +/// } +/// } +/// assert_eq!(DemoError::Last as u32, 6999); +/// ``` +/// +/// ```compile_fail,E0080 +/// strata_bindings::error_enum! { +/// strata_bindings::VAULT_ERRORS, +/// #[repr(u32)] +/// pub enum DemoError { +/// First = 6000, +/// FromTheToken = 7000, +/// } +/// } +/// ``` +#[macro_export] +macro_rules! error_enum { + ( + $range:path, + $(#[$attr:meta])* + $vis:vis enum $name:ident { + // `tt`, not `literal`: `contracterror` reads each code as a bare literal. + $($(#[$variant_attr:meta])* $variant:ident = $code:tt),+ $(,)? + } + ) => { + $(#[$attr])* + $vis enum $name { + $($(#[$variant_attr])* $variant = $code,)+ + } + + const _: () = { + let range: $crate::ErrorRange = $range; + $(assert!( + range.contains($code), + concat!( + stringify!($name), + "::", + stringify!($variant), + " is outside its contract's error range", + ), + );)+ + }; + }; +} diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs new file mode 100644 index 00000000..1226f12d --- /dev/null +++ b/crates/strata-bindings/src/lib.rs @@ -0,0 +1,50 @@ +//! # Design +//! +//! The interfaces the kit's contracts share, and their error code blocks. +//! +//! - **The compiler checks the feed.** The vault prices from `OracleFeed`, +//! which the kit's oracle and any adopter's feed implement. The cost: a feed +//! must answer its four calls as written, or use an adapter. +//! - **Bound contracts report their binding.** The vault's `finish_setup` and +//! the token's compliance swaps read `VaultBinding`. The cost: both accept a +//! compliance contract that does not answer it, on trust, as external. +//! - **Fixed blocks of error codes.** The vault, token, oracle and compliance +//! contract each own one; `error_enum!` fails the build outside it, +//! `errors.rs` on overlaps. The cost: removed codes stay retired. + +#![no_std] + +mod errors; + +use soroban_sdk::{contracttrait, contracttype, Address, Env}; + +pub use errors::{ErrorRange, COMPLIANCE_ERRORS, ORACLE_ERRORS, TOKEN_ERRORS, VAULT_ERRORS}; + +#[contracttype] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum OracleState { + Valid, + Stale, + Paused, +} + +/// The feed a vault prices epochs from. The kit's oracle implements it, and +/// so does any feed an adopter brings: the compiler checks both sides. +#[contracttrait] +pub trait OracleFeed { + fn state(e: &Env) -> OracleState; + fn nav_per_share(e: &Env) -> i128; + /// Ledger time the standing report was accepted. + fn attested_at(e: &Env) -> u64; + /// How long a report stays valid after it is accepted. + fn freshness_duration(e: &Env) -> u64; +} + +/// What a contract that runs on a vault's timelock reports to that vault. +#[contracttrait] +pub trait VaultBinding { + /// The vault whose clock this contract reads, once bound. + fn vault(e: &Env) -> Option
      ; + /// Whether this contract's admin changes are still instant. + fn setup_open(e: &Env) -> bool; +} diff --git a/crates/strata-bindings/tests/role_checks.rs b/crates/strata-bindings/tests/role_checks.rs new file mode 100644 index 00000000..c404b52f --- /dev/null +++ b/crates/strata-bindings/tests/role_checks.rs @@ -0,0 +1,215 @@ +//! `#[only_role(caller, "")]` and its siblings spell a role as a string +//! literal. This ties each literal to the role constant it must match, so a +//! misspelt one fails here instead of granting the wrong role silently. + +use std::fs; +use std::path::{Path, PathBuf}; + +const ROLE_ATTRIBUTE_NAMES: [&str; 4] = ["only_role", "has_role", "only_any_role", "has_any_role"]; + +/// `.rs` files under `src`, skipping test modules: `test.rs`, `test_*.rs`, +/// and anything inside a `test/` directory. +fn production_sources(dir: &Path, sources: &mut Vec) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().unwrap().to_str().unwrap().to_string(); + if path.is_dir() { + if name != "test" { + production_sources(&path, sources); + } + continue; + } + if name == "test.rs" || name.starts_with("test_") { + continue; + } + if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + sources.push(path); + } + } +} + +/// The first `"..."` substring in `text`, if any. +fn quoted(text: &str) -> Option { + let (_, rest) = text.split_once('"')?; + let (value, _) = rest.split_once('"')?; + Some(value.to_string()) +} + +fn is_path_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == ':' +} + +/// The byte offset of the `#` that opens the attribute whose name starts at +/// `name_start`, if `name_start` is written as an attribute: `#[name(...)]` +/// or, with a path prefix, `#[some::path::name(...)]`. +fn attribute_start(source: &str, name_start: usize) -> Option { + let prefix = &source[..name_start]; + let path_start = prefix + .rfind(|c: char| !is_path_char(c)) + .map(|i| i + 1) + .unwrap_or(0); + prefix[..path_start] + .ends_with("#[") + .then_some(path_start - 2) +} + +/// The text between the parens that open at `open` (which must point at +/// `(`), and the byte offset just past the matching close. Parens inside a +/// string literal do not count. +fn matching_parens(source: &str, open: usize) -> Option<(&str, usize)> { + let mut depth = 0i32; + let mut in_string = false; + for (offset, ch) in source[open..].char_indices() { + match ch { + '"' => in_string = !in_string, + '(' if !in_string => depth += 1, + ')' if !in_string => { + depth -= 1; + if depth == 0 { + return Some((&source[open + 1..open + offset], open + offset + 1)); + } + } + _ => {} + } + } + None +} + +fn line_number(source: &str, byte_offset: usize) -> usize { + source[..byte_offset].matches('\n').count() + 1 +} + +/// Every role-attribute call in `source` — `#[only_role(...)]` and its +/// siblings, spanning one line or several, with or without a path prefix +/// such as `stellar_macros::only_role(...)` — as (line, name, argument +/// text). A plain function or method call of the same name (not written as +/// an attribute) is not a match. +fn role_attribute_calls(source: &str) -> Vec<(usize, &'static str, String)> { + let mut calls = Vec::new(); + for name in ROLE_ATTRIBUTE_NAMES { + let mut from = 0; + while let Some(rel) = source[from..].find(name) { + let start = from + rel; + let after = start + name.len(); + from = after; + if !source[after..].starts_with('(') { + continue; + } + let boundary_ok = match source[..start].chars().next_back() { + Some(c) => !(c.is_ascii_alphanumeric() || c == '_'), + None => true, + }; + if !boundary_ok { + continue; + } + let Some(attr_start) = attribute_start(source, start) else { + continue; + }; + let Some((args, _end)) = matching_parens(source, after) else { + continue; + }; + calls.push((line_number(source, attr_start), name, args.to_string())); + } + } + calls +} + +/// The string value of every `const ..._ROLE: ... = ...;` declared in +/// `source`, `pub` or not. +fn role_constants(source: &str) -> Vec { + source + .lines() + .map(str::trim) + .map(|line| line.strip_prefix("pub ").unwrap_or(line)) + .filter(|line| line.starts_with("const") && line.contains("ROLE")) + .filter_map(quoted) + .collect() +} + +#[test] +fn every_only_role_literal_matches_its_role_constant() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut mismatches = Vec::new(); + for contract in fs::read_dir(root.join("contracts")).unwrap() { + let src = contract.unwrap().path().join("src"); + if !src.is_dir() { + continue; + } + let mut sources = Vec::new(); + production_sources(&src, &mut sources); + let roles: Vec = sources + .iter() + .flat_map(|path| role_constants(&fs::read_to_string(path).unwrap())) + .collect(); + for path in &sources { + let source = fs::read_to_string(path).unwrap(); + for (line, name, args) in role_attribute_calls(&source) { + match quoted(&args) { + Some(literal) if roles.contains(&literal) => {} + Some(literal) => mismatches.push(format!( + "{}:{line}: \"{literal}\" matches none of this contract's role constants", + path.display() + )), + None => mismatches.push(format!( + "{}:{line}: {name}(...) carries no role literal", + path.display() + )), + } + } + } + } + assert!( + mismatches.is_empty(), + "role checks must match the role constants:\n{}", + mismatches.join("\n") + ); +} + +/// A refactor that drops a role check silently should fail here instead. +#[test] +fn only_role_sites_do_not_quietly_shrink() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut sites = 0; + for contract in fs::read_dir(root.join("contracts")).unwrap() { + let src = contract.unwrap().path().join("src"); + if !src.is_dir() { + continue; + } + let mut sources = Vec::new(); + production_sources(&src, &mut sources); + for path in &sources { + let source = fs::read_to_string(path).unwrap(); + sites += role_attribute_calls(&source) + .iter() + .filter(|(_, name, _)| *name == "only_role") + .count(); + } + } + assert!(sites >= 12, "found only {sites} #[only_role] sites"); +} + +#[test] +fn role_attribute_calls_spans_lines_and_paths() { + let fixture = concat!( + "#[only_role(\n", + " caller,\n", + " \"treasury\"\n", + ")]\n", + "fn deploy() {}\n", + "\n", + "#[stellar_macros::only_role(caller, \"guardian\")]\n", + "fn pause() {}\n", + "\n", + "fn not_an_attribute() {\n", + " access_control::has_role(e, caller, &MANAGER_ROLE)\n", + "}\n", + ); + let literals: Vec = role_attribute_calls(fixture) + .iter() + .map(|(_, _, args)| quoted(args).unwrap()) + .collect(); + assert_eq!( + literals, + vec!["treasury".to_string(), "guardian".to_string()] + ); +} diff --git a/crates/strata-bindings/tests/spec_docs.rs b/crates/strata-bindings/tests/spec_docs.rs new file mode 100644 index 00000000..8436edb6 --- /dev/null +++ b/crates/strata-bindings/tests/spec_docs.rs @@ -0,0 +1,77 @@ +//! The contract spec keeps the first 1,024 bytes of each doc and drops the rest +//! without a warning, so an over-long doc loses its end, where `# Authorization` +//! sits. + +use std::fs; +use std::path::Path; + +const SPEC_DOC_LIMIT: usize = 1024; + +/// Every entrypoint and error enum of the five contracts lives in these files. +const CONTRACT_SOURCES: [&str; 7] = [ + "contracts/async-vault/src/lib.rs", + "contracts/async-vault/src/error.rs", + "contracts/share-token/src/contract.rs", + "contracts/share-token/src/error.rs", + "contracts/nav-oracle/src/lib.rs", + "contracts/compliance/src/lib.rs", + "contracts/identity-verifier/src/lib.rs", +]; + +/// The doc of every function, enum and enum variant in `source`, joined the +/// way `soroban-sdk-macros` stores it: each line trimmed, then joined by `\n`. +fn item_docs(source: &str) -> Vec<(String, String)> { + let mut docs = Vec::new(); + let mut block: Vec<&str> = Vec::new(); + for line in source.lines().map(str::trim) { + if let Some(text) = line.strip_prefix("///") { + block.push(text.trim()); + continue; + } + if block.is_empty() || line.is_empty() || line.starts_with("#[") { + continue; + } + if let Some(item) = item_name(line) { + docs.push((item, block.join("\n"))); + } + block.clear(); + } + docs +} + +fn item_name(line: &str) -> Option { + if let Some((_, rest)) = line.split_once("fn ") { + return rest.split('(').next().map(|name| format!("fn {name}")); + } + if let Some(rest) = line.strip_prefix("pub enum ") { + return rest + .split_whitespace() + .next() + .map(|name| format!("enum {name}")); + } + let (name, code) = line.split_once(" = ")?; + let is_variant = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && code.trim_end_matches(',').parse::().is_ok(); + is_variant.then(|| format!("variant {name}")) +} + +#[test] +fn every_contract_doc_fits_the_spec() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut too_long = Vec::new(); + for path in CONTRACT_SOURCES { + let source = fs::read_to_string(root.join(path)).unwrap(); + let docs = item_docs(&source); + assert!(!docs.is_empty(), "{path}: no documented item found"); + for (item, doc) in docs { + if doc.len() > SPEC_DOC_LIMIT { + too_long.push(format!("{path}: {item} has {} bytes", doc.len())); + } + } + } + assert!( + too_long.is_empty(), + "the contract spec keeps {SPEC_DOC_LIMIT} bytes of a doc:\n{}", + too_long.join("\n") + ); +} diff --git a/crates/storage/Cargo.toml b/crates/strata-storage/Cargo.toml similarity index 89% rename from crates/storage/Cargo.toml rename to crates/strata-storage/Cargo.toml index 853bfd7e..3e577a0b 100644 --- a/crates/storage/Cargo.toml +++ b/crates/strata-storage/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "storage" +name = "strata-storage" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/storage/src/lib.rs b/crates/strata-storage/src/lib.rs similarity index 61% rename from crates/storage/src/lib.rs rename to crates/strata-storage/src/lib.rs index 6ad5f337..9c79aec1 100644 --- a/crates/storage/src/lib.rs +++ b/crates/strata-storage/src/lib.rs @@ -1,3 +1,21 @@ +//! # Design +//! +//! Storage helpers that keep the kit's own entries alive. Every read and every +//! write through them asks the network to renew the entry it touches. +//! +//! - **Thirty days, renewed a day short.** Each entry, the instance included, +//! goes back to 30 days once it falls a day short. The cost: a busy entry +//! costs about a day of rent per day; an idle one archives. +//! - **Extended in the call that creates it.** A write renews too, so a new +//! entry starts with at least its target. The cost: that call pays for it, or +//! for the network's minimum when that is longer. +//! - **The same lengths on every network,** fixed in code, so the Wasm tested +//! on testnet is the one that ships; each network's minimum lifetime is a +//! floor for new entries. The cost: no lengths tuned per network. +//! - **No cap per call.** A cap would let rarely read entries archive sooner, +//! and a mainnet restore pays at least 120 days of rent. The cost: the first +//! call after a quiet spell renews all it touches. + #![no_std] use core::fmt::Debug; diff --git a/crates/storage/src/test.rs b/crates/strata-storage/src/test.rs similarity index 100% rename from crates/storage/src/test.rs rename to crates/strata-storage/src/test.rs diff --git a/crates/strata-timelock/Cargo.toml b/crates/strata-timelock/Cargo.toml new file mode 100644 index 00000000..68733c88 --- /dev/null +++ b/crates/strata-timelock/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "strata-timelock" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } +publish = false + +[lib] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +strata-storage = { path = "../strata-storage" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "=1.11.0" diff --git a/crates/strata-timelock/src/clock.rs b/crates/strata-timelock/src/clock.rs new file mode 100644 index 00000000..020ff893 --- /dev/null +++ b/crates/strata-timelock/src/clock.rs @@ -0,0 +1,40 @@ +use soroban_sdk::{contracttrait, contracttype, Env}; + +use crate::refusal::Refusal; +use crate::setup; + +/// The state of the contract that owns the clock, as every queue on that clock +/// reads it. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Clock { + /// Seconds a change proposed now waits. + pub delay: u64, + /// The source's notice: how long a closed epoch waits before it can be + /// priced. A change never waits less. + pub exit_window: u64, + /// Seconds the source has spent paused, a running pause included. + pub paused_secs: u64, + pub paused: bool, + pub winding_down: bool, +} + +/// Implemented by the contract that owns the clock, so the contracts bound to +/// it can read it. +#[contracttrait] +pub trait ClockSource { + fn timelock_clock(e: &Env) -> Clock; +} + +/// Every contract that keeps a change queue implements this. The queue and the +/// setup window read the clock only through it. +pub trait TimelockHost { + fn clock(e: &Env) -> Result; +} + +/// The clock of the source this contract is bound to. `Err(Refusal::Unbound)` +/// when none is bound. +pub fn from_source(e: &Env) -> Result { + let source = setup::source(e).ok_or(Refusal::Unbound)?; + Ok(ClockSourceClient::new(e, &source).timelock_clock()) +} diff --git a/crates/strata-timelock/src/delay.rs b/crates/strata-timelock/src/delay.rs new file mode 100644 index 00000000..12772b11 --- /dev/null +++ b/crates/strata-timelock/src/delay.rs @@ -0,0 +1,10 @@ +pub const DAY_DURATION: u64 = 24 * 60 * 60; +/// Short enough to fix a defect in a week, long enough to warn holders before +/// a change lands. +pub const MIN_DELAY: u64 = 7 * DAY_DURATION; +/// No delay can wall a contract off from future changes. +pub const MAX_DELAY: u64 = 90 * DAY_DURATION; + +pub fn in_bounds(delay: u64) -> bool { + (MIN_DELAY..=MAX_DELAY).contains(&delay) +} diff --git a/crates/strata-timelock/src/keys.rs b/crates/strata-timelock/src/keys.rs new file mode 100644 index 00000000..d5ae78f5 --- /dev/null +++ b/crates/strata-timelock/src/keys.rs @@ -0,0 +1,17 @@ +// The lint fires on the code `contracttype` generates, which an allow on the enum does not reach. +#![allow(clippy::enum_variant_names)] + +use soroban_sdk::contracttype; + +/// Each name becomes a storage key in the contract that keeps the queue, so +/// the prefix keeps these apart from the contract's own keys. +#[contracttype] +pub(crate) enum Key { + TimelockNextId, + TimelockOpen, + TimelockPending(u32), + TimelockSource, + TimelockSetupDone, + TimelockPausedSecs, + TimelockPausedSince, +} diff --git a/crates/strata-timelock/src/lib.rs b/crates/strata-timelock/src/lib.rs new file mode 100644 index 00000000..585d765c --- /dev/null +++ b/crates/strata-timelock/src/lib.rs @@ -0,0 +1,49 @@ +//! # Design +//! +//! A timelock for privileged changes. One contract owns the clock: its delay, +//! its notice (the clock's `exit_window`), its pause time and whether it winds +//! down. That contract and every contract bound to it queue their changes +//! against the same clock. +//! +//! - **One clock, the vault's.** The token, the oracle and the compliance +//! contract bind to it once, during setup, and read it on every proposal and +//! apply. The cost: a vault pause holds every change in the kit. +//! - **A setup window first.** Changes are instant until setup ends, nothing +//! queues before it, and it never reopens. The cost: a mistake found after +//! setup takes a full delay to fix. +//! - **One queue per contract.** Every kind of change shares it, at most +//! `MAX_PENDING` at once, and no id is reused. The cost: a full queue refuses +//! proposals until a change lands or is cancelled. +//! - **The delay is an announcement period.** It promises notice, not an exit, +//! which also needs a sealed epoch, a price and cash. Paused time does not +//! count. The cost: a long pause delays every fix. +//! - **Never shorter than the notice.** Each attempt to apply compares the +//! change's delay with the notice then. The cost: a raised notice holds back +//! waiting changes until it comes down or they are proposed again. +//! - **Nothing lands in a wind-down.** Nothing is proposed or applied once it +//! starts. The cost: nothing can be fixed during a wind-down, a failing +//! compliance module included. A host may ignore the flag for a change it +//! must let through; the share token does for a `compliance` grant, which +//! still waits the delay. +//! - **Seven to ninety days.** `MIN_DELAY` and `MAX_DELAY` bound the delay the +//! vault sets. The cost: the fastest fix after setup takes a week. + +#![no_std] + +pub mod clock; +pub mod delay; +mod keys; +pub mod pause; +pub mod queue; +pub mod refusal; +pub mod schedule; +pub mod setup; + +pub use clock::{Clock, ClockSource, ClockSourceClient, TimelockHost}; +pub use delay::{DAY_DURATION, MAX_DELAY, MIN_DELAY}; +pub use queue::MAX_PENDING; +pub use refusal::Refusal; +pub use schedule::Schedule; + +#[cfg(test)] +mod test; diff --git a/crates/strata-timelock/src/pause.rs b/crates/strata-timelock/src/pause.rs new file mode 100644 index 00000000..d9cdc856 --- /dev/null +++ b/crates/strata-timelock/src/pause.rs @@ -0,0 +1,35 @@ +//! Pause accounting for the contract that owns the clock. The contracts bound +//! to it read the total through `ClockSource`. + +use soroban_sdk::Env; + +use crate::keys::Key; + +/// A second start while paused changes nothing. +pub fn start(e: &Env) { + let since: Option = strata_storage::get_instance(e, &Key::TimelockPausedSince); + if since.is_none() { + strata_storage::set_instance(e, &Key::TimelockPausedSince, &e.ledger().timestamp()); + } +} + +/// Banks the time since `start`. An end with no pause running changes nothing. +pub fn end(e: &Env) { + let since: Option = strata_storage::get_instance(e, &Key::TimelockPausedSince); + if let Some(since) = since { + let total = closed_secs(e).saturating_add(e.ledger().timestamp().saturating_sub(since)); + strata_storage::set_instance(e, &Key::TimelockPausedSecs, &total); + e.storage().instance().remove(&Key::TimelockPausedSince); + } +} + +/// Seconds spent paused so far, a running pause included. +pub fn paused_secs(e: &Env) -> u64 { + let since: Option = strata_storage::get_instance(e, &Key::TimelockPausedSince); + let running = since.map_or(0, |s| e.ledger().timestamp().saturating_sub(s)); + closed_secs(e).saturating_add(running) +} + +fn closed_secs(e: &Env) -> u64 { + strata_storage::get_instance(e, &Key::TimelockPausedSecs).unwrap_or(0) +} diff --git a/crates/strata-timelock/src/queue.rs b/crates/strata-timelock/src/queue.rs new file mode 100644 index 00000000..31f39d84 --- /dev/null +++ b/crates/strata-timelock/src/queue.rs @@ -0,0 +1,80 @@ +use core::fmt::Debug; + +use soroban_sdk::{Env, TryFromVal, Val, Vec}; + +use crate::clock::TimelockHost; +use crate::keys::Key; +use crate::refusal::Refusal; +use crate::schedule::Schedule; +use crate::setup; + +/// Changes that may wait at once. Bounds every walk over them. +pub const MAX_PENDING: u32 = 16; + +/// The ids of the changes waiting, oldest first. +pub fn open_ids(e: &Env) -> Vec { + strata_storage::get_instance(e, &Key::TimelockOpen).unwrap_or_else(|| Vec::new(e)) +} + +pub fn pending(e: &Env, id: u32) -> Option<(C, Schedule)> +where + C: TryFromVal, + >::Error: Debug, +{ + strata_storage::get_persistent(e, &Key::TimelockPending(id)) +} + +/// Queues a change behind the host's current delay and returns its id. +pub fn propose(e: &Env, change: &C) -> Result +where + C: Clone, + Val: TryFromVal, +{ + if setup::is_open(e) { + return Err(Refusal::SetupOpen); + } + let clock = H::clock(e)?; + if clock.winding_down { + return Err(Refusal::WindingDown); + } + let mut open = open_ids(e); + if open.len() >= MAX_PENDING { + return Err(Refusal::TooMany); + } + let id: u32 = strata_storage::get_instance(e, &Key::TimelockNextId).unwrap_or(1); + let schedule = Schedule { + proposed_at: e.ledger().timestamp(), + delay: clock.delay, + paused_secs: clock.paused_secs, + }; + strata_storage::set_persistent(e, &Key::TimelockPending(id), &(change.clone(), schedule)); + open.push_back(id); + strata_storage::set_instance(e, &Key::TimelockOpen, &open); + strata_storage::set_instance(e, &Key::TimelockNextId, &(id + 1)); + Ok(id) +} + +pub fn cancel(e: &Env, id: u32) -> Result<(), Refusal> { + remove(e, id) +} + +/// Removes and returns the change if it may land now. +pub fn take_ready(e: &Env, id: u32) -> Result +where + C: TryFromVal, + >::Error: Debug, +{ + let (change, schedule): (C, Schedule) = pending(e, id).ok_or(Refusal::NotFound)?; + schedule.check_ready(e.ledger().timestamp(), &H::clock(e)?)?; + remove(e, id)?; + Ok(change) +} + +fn remove(e: &Env, id: u32) -> Result<(), Refusal> { + let mut open = open_ids(e); + let index = open.first_index_of(id).ok_or(Refusal::NotFound)?; + open.remove(index); + strata_storage::set_instance(e, &Key::TimelockOpen, &open); + e.storage().persistent().remove(&Key::TimelockPending(id)); + Ok(()) +} diff --git a/crates/strata-timelock/src/refusal.rs b/crates/strata-timelock/src/refusal.rs new file mode 100644 index 00000000..fa718be7 --- /dev/null +++ b/crates/strata-timelock/src/refusal.rs @@ -0,0 +1,23 @@ +/// Why the timelock refused. Each contract maps these onto its own error +/// codes, so its codes stay stable. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Refusal { + /// The contract that owns the clock is winding down: nothing is proposed + /// or applied. + WindingDown, + Paused, + /// The change's delay, plus the time paused since, has not run yet. + NotElapsed, + /// The change's delay is shorter than the source's notice now. + BelowExitWindow, + /// No pending change has this id. + NotFound, + /// `MAX_PENDING` changes already wait. + TooMany, + /// Changes are instant until setup finishes, so none is queued before. + SetupOpen, + SetupClosed, + /// No clock source is bound. + Unbound, + AlreadyBound, +} diff --git a/crates/strata-timelock/src/schedule.rs b/crates/strata-timelock/src/schedule.rs new file mode 100644 index 00000000..ef12d694 --- /dev/null +++ b/crates/strata-timelock/src/schedule.rs @@ -0,0 +1,39 @@ +use soroban_sdk::contracttype; + +use crate::clock::Clock; +use crate::refusal::Refusal; + +/// When a change was proposed, and the clock it started from. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Schedule { + pub proposed_at: u64, + pub delay: u64, + pub paused_secs: u64, +} + +impl Schedule { + /// The moment the change may land, pushed back by every second the source + /// has spent paused since it was proposed. + pub fn eta(&self, clock: &Clock) -> u64 { + self.proposed_at + .saturating_add(self.delay) + .saturating_add(clock.paused_secs.saturating_sub(self.paused_secs)) + } + + pub fn check_ready(&self, now: u64, clock: &Clock) -> Result<(), Refusal> { + if clock.winding_down { + return Err(Refusal::WindingDown); + } + if clock.paused { + return Err(Refusal::Paused); + } + if self.delay < clock.exit_window { + return Err(Refusal::BelowExitWindow); + } + if now < self.eta(clock) { + return Err(Refusal::NotElapsed); + } + Ok(()) + } +} diff --git a/crates/strata-timelock/src/setup.rs b/crates/strata-timelock/src/setup.rs new file mode 100644 index 00000000..6d165eb2 --- /dev/null +++ b/crates/strata-timelock/src/setup.rs @@ -0,0 +1,41 @@ +//! The window after deployment in which wiring is instant and no change is +//! queued. It closes once and never reopens. + +use soroban_sdk::{Address, Env}; + +use crate::clock::{ClockSourceClient, TimelockHost}; +use crate::keys::Key; +use crate::refusal::Refusal; + +pub fn is_open(e: &Env) -> bool { + !strata_storage::get_instance::<_, bool>(e, &Key::TimelockSetupDone).unwrap_or(false) +} + +/// The clock source this contract is bound to. +pub fn source(e: &Env) -> Option
      { + strata_storage::get_instance(e, &Key::TimelockSource) +} + +/// Binds this contract to a clock source, once and only during setup. +pub fn bind_source(e: &Env, source: &Address) -> Result<(), Refusal> { + if !is_open(e) { + return Err(Refusal::SetupClosed); + } + if self::source(e).is_some() { + return Err(Refusal::AlreadyBound); + } + // An address that does not answer as a clock source fails the call here. + ClockSourceClient::new(e, source).timelock_clock(); + strata_storage::set_instance(e, &Key::TimelockSource, source); + Ok(()) +} + +/// Closes the window for good, once the host can read its clock. +pub fn finish(e: &Env) -> Result<(), Refusal> { + if !is_open(e) { + return Err(Refusal::SetupClosed); + } + H::clock(e)?; + strata_storage::set_instance(e, &Key::TimelockSetupDone, &true); + Ok(()) +} diff --git a/crates/strata-timelock/src/test/clock.rs b/crates/strata-timelock/src/test/clock.rs new file mode 100644 index 00000000..cec86539 --- /dev/null +++ b/crates/strata-timelock/src/test/clock.rs @@ -0,0 +1,27 @@ +use super::*; +use crate::clock::from_source; +use crate::setup::bind_source; + +#[test] +fn an_unbound_contract_has_no_clock() { + let e = Env::default(); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || { + assert_eq!(from_source(&e), Err(Refusal::Unbound)); + }); +} + +#[test] +fn a_bound_contract_reads_its_source_clock_as_it_changes() { + let (e, source) = owner(); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || bind_source(&e, &source).unwrap()); + + let mut reported = clock(MIN_DELAY); + reported.exit_window = 3_600; + reported.paused_secs = 42; + reported.paused = true; + e.as_contract(&source, || set_clock(&e, &reported)); + + assert_eq!(e.as_contract(&follower, || from_source(&e)), Ok(reported)); +} diff --git a/crates/strata-timelock/src/test/delay.rs b/crates/strata-timelock/src/test/delay.rs new file mode 100644 index 00000000..c16a7eb7 --- /dev/null +++ b/crates/strata-timelock/src/test/delay.rs @@ -0,0 +1,10 @@ +use crate::delay::in_bounds; +use crate::DAY_DURATION; + +#[test] +fn a_delay_from_seven_to_ninety_days_is_in_bounds() { + assert!(!in_bounds(7 * DAY_DURATION - 1)); + assert!(in_bounds(7 * DAY_DURATION)); + assert!(in_bounds(90 * DAY_DURATION)); + assert!(!in_bounds(90 * DAY_DURATION + 1)); +} diff --git a/crates/strata-timelock/src/test/mod.rs b/crates/strata-timelock/src/test/mod.rs new file mode 100644 index 00000000..a5c90db5 --- /dev/null +++ b/crates/strata-timelock/src/test/mod.rs @@ -0,0 +1,85 @@ +extern crate std; + +mod clock; +mod delay; +mod pause; +mod queue; +mod schedule; +mod setup; +mod storage_keys; + +use soroban_sdk::{contract, contractimpl, contracttype, testutils::Ledger as _, Address, Env}; + +use crate::{Clock, ClockSource, Refusal, TimelockHost, MIN_DELAY}; + +#[contracttype] +enum TestKey { + Clock, +} + +/// Owns its clock. A test sets the clock it reports. +#[contract] +struct Owner; + +impl TimelockHost for Owner { + fn clock(e: &Env) -> Result { + Ok(e.storage() + .instance() + .get(&TestKey::Clock) + .unwrap_or_else(|| clock(MIN_DELAY))) + } +} + +#[contractimpl(contracttrait)] +impl ClockSource for Owner { + fn timelock_clock(e: &Env) -> Clock { + ::clock(e).unwrap() + } +} + +/// Reads its clock from the source it is bound to. +#[contract] +struct Follower; + +impl TimelockHost for Follower { + fn clock(e: &Env) -> Result { + crate::clock::from_source(e) + } +} + +#[contractimpl] +impl Follower { + pub fn bind(e: Env, source: Address) { + crate::setup::bind_source(&e, &source).unwrap(); + } +} + +fn clock(delay: u64) -> Clock { + Clock { + delay, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } +} + +/// Makes the owner report this clock. Runs inside the owner's frame. +fn set_clock(e: &Env, clock: &Clock) { + e.storage().instance().set(&TestKey::Clock, clock); +} + +/// An owner at ledger time 1_000, its setup window still open. +fn owner() -> (Env, Address) { + let e = Env::default(); + e.ledger().set_timestamp(1_000); + let id = e.register(Owner, ()); + (e, id) +} + +/// An owner past setup, so its queue takes changes. +fn ready_owner() -> (Env, Address) { + let (e, id) = owner(); + e.as_contract(&id, || crate::setup::finish::(&e).unwrap()); + (e, id) +} diff --git a/crates/strata-timelock/src/test/pause.rs b/crates/strata-timelock/src/test/pause.rs new file mode 100644 index 00000000..bb68ab88 --- /dev/null +++ b/crates/strata-timelock/src/test/pause.rs @@ -0,0 +1,21 @@ +use super::*; +use crate::pause; + +#[test] +fn pause_time_accumulates_across_pauses() { + let (e, id) = owner(); + e.as_contract(&id, || { + pause::start(&e); + e.ledger().set_timestamp(1_100); + assert_eq!(pause::paused_secs(&e), 100); + pause::start(&e); + pause::end(&e); + e.ledger().set_timestamp(2_000); + pause::start(&e); + e.ledger().set_timestamp(2_050); + pause::end(&e); + pause::end(&e); + + assert_eq!(pause::paused_secs(&e), 150); + }); +} diff --git a/crates/strata-timelock/src/test/queue.rs b/crates/strata-timelock/src/test/queue.rs new file mode 100644 index 00000000..179bdd0f --- /dev/null +++ b/crates/strata-timelock/src/test/queue.rs @@ -0,0 +1,99 @@ +use soroban_sdk::{testutils::Address as _, vec}; + +use super::*; +use crate::queue::{cancel, open_ids, pending, propose, take_ready, MAX_PENDING}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +enum Change { + Upgrade(u32), + Rotate(Address), +} + +#[test] +fn a_change_lands_one_delay_after_it_was_proposed() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + let change_id = propose::(&e, &Change::Upgrade(2)).unwrap(); + + assert_eq!( + take_ready::(&e, change_id), + Err(Refusal::NotElapsed) + ); + e.ledger().set_timestamp(1_000 + MIN_DELAY); + assert_eq!( + take_ready::(&e, change_id), + Ok(Change::Upgrade(2)) + ); + assert_eq!(pending::(&e, change_id), None); + }); +} + +#[test] +fn nothing_is_proposed_while_setup_is_open() { + let (e, id) = owner(); + e.as_contract(&id, || { + assert_eq!( + propose::(&e, &Change::Upgrade(2)), + Err(Refusal::SetupOpen) + ); + assert_eq!(open_ids(&e).len(), 0); + }); +} + +#[test] +fn nothing_is_proposed_during_a_wind_down() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + let mut c = clock(MIN_DELAY); + c.winding_down = true; + set_clock(&e, &c); + + assert_eq!( + propose::(&e, &Change::Upgrade(2)), + Err(Refusal::WindingDown) + ); + assert_eq!(open_ids(&e).len(), 0); + }); +} + +#[test] +fn several_changes_wait_at_once_and_cancel_independently() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + let first = propose::(&e, &Change::Upgrade(2)).unwrap(); + let second = propose::(&e, &Change::Rotate(Address::generate(&e))).unwrap(); + assert_ne!(first, second); + + cancel(&e, first).unwrap(); + + assert_eq!(open_ids(&e), vec![&e, second]); + assert_eq!(cancel(&e, first), Err(Refusal::NotFound)); + assert!(pending::(&e, second).is_some()); + }); +} + +#[test] +fn the_queue_holds_at_most_max_pending() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + for n in 0..MAX_PENDING { + propose::(&e, &Change::Upgrade(n)).unwrap(); + } + assert_eq!( + propose::(&e, &Change::Upgrade(99)), + Err(Refusal::TooMany) + ); + }); +} + +#[test] +fn a_cancelled_id_is_never_handed_out_again() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + let first = propose::(&e, &Change::Upgrade(1)).unwrap(); + cancel(&e, first).unwrap(); + let second = propose::(&e, &Change::Upgrade(1)).unwrap(); + assert!(second > first); + }); +} diff --git a/crates/strata-timelock/src/test/schedule.rs b/crates/strata-timelock/src/test/schedule.rs new file mode 100644 index 00000000..1540842d --- /dev/null +++ b/crates/strata-timelock/src/test/schedule.rs @@ -0,0 +1,88 @@ +use proptest::prelude::*; + +use super::*; +use crate::{Schedule, DAY_DURATION, MAX_DELAY}; + +#[test] +fn time_spent_paused_does_not_count_toward_the_delay() { + let schedule = Schedule { + proposed_at: 1_000, + delay: MIN_DELAY, + paused_secs: 50, + }; + let mut c = clock(MIN_DELAY); + c.paused_secs = 50 + 3 * DAY_DURATION; + + assert_eq!(schedule.eta(&c), 1_000 + MIN_DELAY + 3 * DAY_DURATION); + assert_eq!( + schedule.check_ready(1_000 + MIN_DELAY, &c), + Err(Refusal::NotElapsed) + ); + assert_eq!( + schedule.check_ready(1_000 + MIN_DELAY + 3 * DAY_DURATION, &c), + Ok(()) + ); +} + +#[test] +fn nothing_lands_while_paused_or_winding_down() { + let schedule = Schedule { + proposed_at: 0, + delay: MIN_DELAY, + paused_secs: 0, + }; + let mut paused = clock(MIN_DELAY); + paused.paused = true; + let mut winding = clock(MIN_DELAY); + winding.winding_down = true; + let mut both = paused.clone(); + both.winding_down = true; + + assert_eq!( + schedule.check_ready(MAX_DELAY, &paused), + Err(Refusal::Paused) + ); + assert_eq!( + schedule.check_ready(MAX_DELAY, &winding), + Err(Refusal::WindingDown) + ); + assert_eq!( + schedule.check_ready(MAX_DELAY, &both), + Err(Refusal::WindingDown) + ); +} + +#[test] +fn a_change_shorter_than_the_current_exit_window_never_lands() { + let schedule = Schedule { + proposed_at: 0, + delay: MIN_DELAY, + paused_secs: 0, + }; + let mut c = clock(MIN_DELAY); + c.exit_window = MIN_DELAY + 1; + + assert_eq!(schedule.check_ready(0, &c), Err(Refusal::BelowExitWindow)); + assert_eq!( + schedule.check_ready(MAX_DELAY, &c), + Err(Refusal::BelowExitWindow) + ); +} + +proptest! { + #[test] + fn the_eta_never_comes_before_proposal_plus_delay( + proposed_at in 0u64..1_000_000_000, + delay in MIN_DELAY..=MAX_DELAY, + paused_before in 0u64..1_000_000, + paused_since in 0u64..1_000_000, + ) { + let schedule = Schedule { proposed_at, delay, paused_secs: paused_before }; + let mut c = clock(delay); + c.paused_secs = paused_before + paused_since; + let at = schedule.eta(&c); + prop_assert_eq!(at, proposed_at + delay + paused_since); + prop_assert!(schedule.check_ready(at - 1, &c).is_err()); + prop_assert!(schedule.check_ready(at, &c).is_ok()); + } +} diff --git a/crates/strata-timelock/src/test/setup.rs b/crates/strata-timelock/src/test/setup.rs new file mode 100644 index 00000000..9be8f5a0 --- /dev/null +++ b/crates/strata-timelock/src/test/setup.rs @@ -0,0 +1,71 @@ +use soroban_sdk::testutils::Address as _; + +use super::*; +use crate::setup::{bind_source, finish, is_open, source}; + +#[test] +fn the_setup_window_closes_once() { + let (e, id) = owner(); + e.as_contract(&id, || { + assert!(is_open(&e)); + + assert_eq!(finish::(&e), Ok(())); + assert!(!is_open(&e)); + assert_eq!(finish::(&e), Err(Refusal::SetupClosed)); + }); +} + +#[test] +fn a_bound_contract_cannot_finish_setup_unbound() { + let e = Env::default(); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || { + assert_eq!(finish::(&e), Err(Refusal::Unbound)); + assert!(is_open(&e)); + }); +} + +#[test] +fn a_contract_binds_one_source() { + let (e, first) = owner(); + let second = e.register(Owner, ()); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || { + assert_eq!(source(&e), None); + assert_eq!(bind_source(&e, &first), Ok(())); + assert_eq!(bind_source(&e, &second), Err(Refusal::AlreadyBound)); + assert_eq!(source(&e), Some(first.clone())); + }); +} + +#[test] +fn nothing_binds_once_setup_is_finished() { + let (e, first) = owner(); + let second = e.register(Owner, ()); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || { + bind_source(&e, &first).unwrap(); + finish::(&e).unwrap(); + + assert_eq!(bind_source(&e, &second), Err(Refusal::SetupClosed)); + assert_eq!(source(&e), Some(first.clone())); + }); +} + +#[test] +fn binding_refuses_an_address_that_is_not_a_clock_source() { + let (e, clock_source) = owner(); + let follower = FollowerClient::new(&e, &e.register(Follower, ())); + let no_clock = e.register(Follower, ()); + let no_contract = Address::generate(&e); + + assert!(follower.try_bind(&no_clock).is_err()); + assert!(follower.try_bind(&no_contract).is_err()); + assert_eq!(e.as_contract(&follower.address, || source(&e)), None); + + follower.bind(&clock_source); + assert_eq!( + e.as_contract(&follower.address, || source(&e)), + Some(clock_source) + ); +} diff --git a/crates/strata-timelock/src/test/storage_keys.rs b/crates/strata-timelock/src/test/storage_keys.rs new file mode 100644 index 00000000..4642631f --- /dev/null +++ b/crates/strata-timelock/src/test/storage_keys.rs @@ -0,0 +1,43 @@ +use soroban_sdk::xdr::ToXdr; + +use super::*; +use crate::keys::Key; + +const FIXTURE: &str = include_str!("../../../../fixtures/timelock-storage-keys.txt"); + +fn hex(env: &Env, key: Key) -> std::string::String { + key.to_xdr(env) + .iter() + .map(|byte| std::format!("{byte:02x}")) + .collect() +} + +/// "TimelockOpen" or "TimelockPending()"; panics on anything else. +fn key_named(name: &str) -> Key { + if name == "TimelockOpen" { + return Key::TimelockOpen; + } + let id = name + .strip_prefix("TimelockPending(") + .and_then(|rest| rest.strip_suffix(')')) + .and_then(|digits| digits.parse::().ok()); + match id { + Some(id) => Key::TimelockPending(id), + None => panic!("unknown fixture key name: {name}"), + } +} + +#[test] +fn storage_keys_match_the_shared_fixture() { + let env = Env::default(); + let lines: std::vec::Vec<(&str, &str)> = FIXTURE + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .map(|l| l.split_once(' ').expect(" ")) + .collect(); + assert!(lines.iter().any(|(n, _)| *n == "TimelockOpen")); + assert!(lines.iter().any(|(n, _)| n.starts_with("TimelockPending("))); + for (name, expected) in lines { + assert_eq!(hex(&env, key_named(name)), expected, "{name}"); + } +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..c1b86271 --- /dev/null +++ b/deny.toml @@ -0,0 +1,31 @@ +# Checked in CI by `cargo deny --locked check`. Exact pins mean a flagged +# dependency waits for the next milestone's upgrade; each ignore says why. + +[advisories] +version = 2 +yanked = "deny" +ignore = [ + { id = "RUSTSEC-2024-0436", reason = "paste is unmaintained, not vulnerable; it is a build-time macro that soroban-env-host and soroban-env-common bring in through the pinned soroban-sdk" }, + { id = "RUSTSEC-2026-0387", reason = "crypto-bigint 0.5 comes in through soroban-env-host's elliptic-curve crates, which run only in host-side tests and are not in the contract Wasm; no soroban-env-host release uses the fixed 0.7.6 yet (checked 2026-10-26)" }, +] + +[licenses] +version = 2 +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-3-Clause", + "MIT", + "Unicode-3.0", +] +confidence-threshold = 0.9 + +[bans] +multiple-versions = "warn" +wildcards = "deny" +allow-wildcard-paths = true + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 69b13459..90d7b6dc 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -1,21 +1,25 @@ # Authorization & Access Control Matrix -This document provides the complete authorization and role model specification for the Strata Vault Kit across all four contracts (`AsyncVault`, `ShareToken`, `NavOracle`, and `IdentityVerifier`). +This document provides the complete authorization and role model specification for the Strata Vault Kit across all five contracts (`AsyncVault`, `ShareToken`, `NavOracle`, `IdentityVerifier` and `Compliance`). --- ## 1. Architectural Principles 1. **Direct Role Membership Checks**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members. -2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. -3. **Exit-Only Guarantee**: A covered redemption claim always pays out cash, regardless of pause, staleness, or allowlist status. Delisted or frozen investors exit through the cash path and are prevented from re-entering share circulation. -4. **Five Separate Authorities**: - - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency ripcord reset. - - **Manager (Vault)**: Closes epochs. +2. **One signature per account**: A privileged call asks each account it needs for exactly one signature, on that call. No production entrypoint takes a caller argument it does not check. + - An admin entrypoint the kit defines takes no caller argument. `#[only_admin]` reads the stored admin and asks it to sign. + - Where an OpenZeppelin trait fixes a `caller` or `operator` argument (`pause`, `unpause`, `set_compliance`, `set_identity_verifier`), the entrypoint refuses any account other than the stored admin with `AdminRequired`, then asks that account to sign. + - A role entrypoint checks the role of the `caller` or `operator` it names, then asks that account to sign. `legal_transfer` and `recover_balance` also ask the admin, a different account, to sign the same call. + - The testnet stubs are the exception. The compliance contract's `bind_token`, `unbind_token`, `add_module_to` and `remove_module_from`, and the identity verifier's `set_claim_topics_and_issuers`, keep the `operator` argument their trait fixes but do not read it. `#[only_admin]` asks the stored admin to sign, so each still asks for one signature. Production replaces both stubs with external contracts (§5.4, §5.5). +3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts. +4. **Five Separate Authorities, plus the Vault's `manager` Role**: + - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. As the share token's admin, it co-signs compliance's `legal_transfer` and `recover_balance`. + - **Manager (vault role, not an authority)**: Closes epochs; anyone may also close one once the open epoch has run the minimum epoch duration. - **Treasury**: Deploys free reserve assets to custodian. - - **Guardian**: Emergency responder; pauses vault entries, raises oracle ripcord. + - **Guardian**: Emergency responder; pauses vault entries, pauses the oracle's NAV. - **Attester (Oracle)**: Submits off-chain valuation reports within strict deviation and rate bounds. - - **Compliance**: Writes the allowlist on the identity verifier and holds the share token's `compliance` role for freezes, forced transfers and recovery. Cannot mint, burn or swap the token's compliance or identity-verifier contract. + - **Compliance**: Writes the allowlist on the identity verifier and holds the share token's `compliance` role for freezes and partial freezes. It moves shares only with `legal_transfer` or `recover_balance`, each needing governance's co-signature in the same call, and never into or out of the vault's address. Cannot mint, burn, call `forced_transfer`, or swap the token's compliance or identity-verifier contract. --- @@ -25,27 +29,32 @@ The symbol `"manager"` names two completely distinct roles living on separate co | Contract | Role Symbol | Held By | Scope & Privileges | |---|---|---|---| -| **`AsyncVault`** | `symbol_short!("manager")` | Operator Multisig / Automation Bot | Strictly calls `close_epoch` to freeze orders for pricing. Holds **no** token minting or burning power. | -| **`ShareToken`** | `symbol_short!("manager")` | `AsyncVault` Contract Instance | Authorized to call `mint` (claim deposits), `burn` (claim redemptions) and `forced_transfer` (escrow redemptions). Holds no freeze or recovery power. | +| **`AsyncVault`** | `symbol_short!("manager")` | Operator Multisig / Automation Bot | Calls `close_epoch` to freeze orders for pricing, at any time; anyone else may also call it once the open epoch has run the minimum epoch duration. Holds **no** token minting or burning power. | +| **`ShareToken`** | `symbol_short!("manager")` | `AsyncVault` Contract Instance | Authorized to call `mint` (claim deposits), `burn` (claim redemptions) and `forced_transfer` (escrow redemptions and return escrowed shares). Holds no freeze or recovery power. | -The share token has a third role, `"compliance"`, held by the compliance authority: freezes, partial freezes, recovery and `forced_transfer`. `forced_transfer` is the one entrypoint both roles reach, because the vault escrows redemptions with it. +The share token has a third role, `"compliance"`, held by the compliance authority: freezes and partial freezes, plus `legal_transfer` and `recover_balance` with governance's co-signature. Only `manager` reaches `forced_transfer`, which the vault uses to escrow redemptions and to return escrowed shares after its own eligibility check. ### Deployment Script Safety In `scripts/harness/deploy.ts` and automated deployment scripts, the separation is enforced structurally: - The vault's constructor receives `roles.manager = accounts.manager.publicKey()`. - The share token's constructor grants `compliance` to the compliance key and grants `manager` to no one; the identity verifier is constructed with the compliance key as admin. - The share token role is granted to the deployed vault contract: `token.grant_role({ account: asyncVault.address, role: "manager", caller: governance })`. -- After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. It does not check that no other account holds it. +- After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. That no other account holds it is checked by the vault's `finish_setup` below. +- It grants `compliance` to a second account, `compliance-backup`, kept cold, and stops unless the token reports two holders. +- It then calls `token.bind_vault({ vault })` and `token.finish_setup()`, each signed by governance, and stops if `token.setup_open()` still reads true. From then on the token's admin changes wait for the vault's timelock (§5.2). +- It calls `compliance.bind_token({ token, operator: governance })`, then `compliance.bind_vault({ vault })` and `compliance.finish_setup()`, and stops if `compliance.setup_open()` still reads true. From then on the compliance contract's module, token-binding and admin changes wait for the vault's timelock (§5.5). +- It calls `oracle.bind_vault({ vault })` and `oracle.finish_setup()`, and stops if `oracle.setup_open()` still reads true. From then on the oracle's admin changes wait for the vault's timelock (§5.3). +- Last, it calls `vault.set_custodian({ custodian })` and `vault.finish_setup()`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. It also needs the asset to have the token's decimals, four different addresses for the asset, the token, the oracle and the vault, an oracle that answers as a feed, a custodian other than the vault, and the vault's own books empty: the first epoch, with no deposit or redemption booked in it, nothing committed, and no shares owed or to burn. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its setup closed; one that does not answer them is an external module and is accepted. The script then reads `vault.health()` and stops if any wiring field is false. From then on the vault takes deposits while the oracle holds a valid report (`FeedNotValid` otherwise), and its admin changes wait for its timelock (§5.1). --- ## 3. Vault Constructor Authority Handling `AsyncVault::__constructor` accepts `VaultRoles`, which includes `governance`, `manager`, `treasury`, `guardian`, `compliance`, and `attester`. -- **Why `compliance` and `attester` are kept in the constructor**: - The vault kit enforces an end-to-end separation of duties. At construction, the vault validates pairwise distinctness: +- **What the constructor does with `compliance` and `attester`**: + It checks four pairs and refuses an address that appears in both seats (`RolesNotDistinct`): `treasury != guardian`, `treasury != governance`, `compliance != governance`, and `compliance != treasury`. - Although compliance is enforced on `IdentityVerifier` / `Compliance` and attestation on `NavOracle`, the vault records and validates the full 5-authority topology at instantiation time to prevent cross-contamination of duties. Neither `compliance` nor `attester` is granted a direct role on the vault itself. + No other pair is compared: governance and guardian may be one account, and the manager is unconstrained. The vault stores neither `compliance` nor `attester` and grants neither a role. `compliance` is read only by that check; `attester` is neither stored nor compared, so nothing ties it to the oracle's attester. Compliance acts on the identity verifier and the share token, and attestation on the oracle. --- @@ -55,84 +64,149 @@ The following entrypoints are open to any caller on purpose: | Contract | Entrypoint | Authorization | Rationale | |---|---|---|---| -| **`AsyncVault`** | `fund` | `from.require_auth()` | Anyone (treasury, LP, custodian, donor) may fund the vault with settlement assets. `from.require_auth()` ensures that the sender explicitly approves transferring their own assets into the vault. Receiving capital is non-privileged and always safe. | -| **`AsyncVault`** | `fulfill_epoch` | Open (Any caller) | Pricing is deterministic once an epoch is closed and the oracle publishes a valid attestation. Permissionless fulfillment prevents a malicious operator from stalling pricing or censoring settlements. Guarded by `#[when_not_paused]` and oracle validity. | +| **`AsyncVault`** | `fund` | `caller.require_auth()` | Anyone (treasury, LP, custodian, donor) may fund the vault with settlement assets. `caller.require_auth()` ensures that the sender explicitly approves transferring their own assets into the vault. Receiving capital is non-privileged and always safe. | +| **`AsyncVault`** | `fulfill_epoch` | Open (Any caller) | Pricing is deterministic once an epoch is closed and the oracle publishes a valid attestation. Permissionless fulfillment prevents a malicious operator from stalling pricing or censoring settlements. Guarded by `#[when_not_paused]` and oracle validity, and refused with `SetupOpen` until the vault's setup finishes. | | **`AsyncVault`** | `activate_wind_down` | Open (Any caller) | Once governance announces wind-down and the timelock expires, anyone (including investors) can trigger activation. This ensures an operator cannot propose wind-down to block deposits and then stall activation indefinitely. | +| **`AsyncVault`** | `close_epoch` | `caller.require_auth()`, once the minimum has run | A manager who stops would otherwise hold every request back from a price. The minimum bounds how often anyone but the manager picks a batch boundary. Unset, only the manager seals. | | **`AsyncVault`** | `finalize_wind_down_round` | Open (Any caller) | Distributing returned capital pro-rata across the supply snapshot is deterministic. Making finalisation open ensures investors or keepers can trigger payout rounds immediately when funds arrive. | -| **`AsyncVault`** | `claim_wind_down` | `holder.require_auth()` | The holder signs their own claim; a keeper cannot claim for them. The payout goes to `holder`. | -| **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused` and not winding down. | -| **`AsyncVault`** | `claim_deposit` | `caller.require_auth()` | Investor entrypoint. Claims shares once epoch fulfilled; gated by SEP-57 receiver check on `mint`. | -| **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit before epoch pricing. Single-step refund. | -| **`AsyncVault`** | `request_redeem` | `from.require_auth()` | Investor exit. Always open, even during pause. | -| **`AsyncVault`** | `claim_redeem` | `caller.require_auth()` | Investor exit. Pays settlement asset once covered. | -| **`AsyncVault`** | `cancel_redeem` | `from.require_auth()` | Investor exit before epoch pricing. Returns escrowed shares. | +| **`AsyncVault`** | `claim_wind_down` | `holder.require_auth()` | The holder signs their own claim; a keeper cannot claim for them. The payout goes to `holder`. The vault's own address is refused (`NoEntitlement`) (signature: `test::signatures::claim_wind_down_asks_the_holder_for_one_signature`). | +| **`AsyncVault`** | `request_deposit` | `holder.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused`, not winding down, no wind-down proposed (`WindDownAlreadyProposed`), and refused with `SetupOpen` until the vault's setup finishes, so every deposit is covered by the timelock. Refused with `FeedNotValid` while the feed is stale or the NAV is paused, and with `EscrowNotCovered` while the vault holds less than its pending escrow. | +| **`AsyncVault`** | `claim_deposit` | `holder.require_auth()` | Investor entrypoint. Mints the shares once the epoch is priced; the token checks the receiver on `mint`. A delisted holder's shares become an exit instead (`DepositConvertedToExit`): they join the holder's redemption in the open epoch, or the holder's wind-down entitlement during a wind-down. A frozen holder's claim is held (`Frozen`). | +| **`AsyncVault`** | `cancel_deposit` | `holder.require_auth()` | Investor exit while the epoch cannot be priced. Refunds the whole escrowed deposit in one step, delisted or not. A frozen holder is held (`Frozen`). Refused with `EscrowNotCovered` while the vault's balance cannot cover the refund (signature: `test::signatures::cancel_deposit_asks_the_holder_for_one_signature`). | +| **`AsyncVault`** | `request_redeem` | `holder.require_auth()` | Investor exit. Open during a pause and to a delisted holder; refused during a wind-down (`WindDownActive`) and until the vault's setup finishes (`SetupOpen`). | +| **`AsyncVault`** | `claim_redeem` | `holder.require_auth()` | Investor exit. Pays settlement asset once covered (signature: `test::signatures::claim_redeem_asks_the_holder_for_one_signature`). | +| **`AsyncVault`** | `cancel_redeem` | `holder.require_auth()` | Investor exit while the epoch cannot be priced. Outside a wind-down the vault checks the holder itself and returns the escrowed shares with the manager `forced_transfer`; it refuses a delisted holder (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`). In an active wind-down the escrow is surrendered into the holder's wind-down entitlement instead (`RedemptionSurrendered`). A frozen holder is held (`Frozen`) either way (signature: `test::signatures::cancel_redeem_asks_the_holder_for_one_signature`). | --- -## 5. Complete Access Control Matrix Across All 4 Contracts +## 5. Complete Access Control Matrix Across All 5 Contracts ### 5.1 AsyncVault (`contracts/async-vault`) -Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`notice.rs`](../contracts/async-vault/src/test/notice.rs), [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) and [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) in [`contracts/async-vault/src/test/`](../contracts/async-vault/src/test/). +The vault's `admin` is governance. Until `finish_setup`, admin changes are instant and the vault takes no request and moves no epoch (`SetupOpen`). Setup finishes once the wiring is complete, the vault's own books are empty, and no admin hand-over offered during setup is pending, and it never reopens. After setup, the following wait for the timelock through `propose_change`/`apply_change`: the custodian, grants, revoking `manager`, admin hand-over, lowering the notice or the wind-down delay, the minimum epoch duration in either direction, the oracle, and upgrades. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking `treasury` or `guardian` stay instant. During an active wind-down nothing timelocked is proposed or applied, a `manager` revoke included. + +Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`governance.rs`](../contracts/async-vault/src/test/governance.rs), [`notice.rs`](../contracts/async-vault/src/test/notice.rs), [`sealing.rs`](../contracts/async-vault/src/test/sealing.rs), [`setup.rs`](../contracts/async-vault/src/test/setup.rs), [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) and [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) in [`contracts/async-vault/src/test/`](../contracts/async-vault/src/test/). [`signatures.rs`](../contracts/async-vault/src/test/signatures.rs) checks that each privileged call below asks one account for one signature, on that call only. | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| -| `close_epoch` | `#[only_role(caller, "manager")]` | Epoch must be open | `test::epochs::a_non_manager_cannot_close` | +| `close_epoch` | `caller.require_auth()`; `manager` at any time, anyone else once the open epoch has run `min_epoch_duration` (`MinEpochDurationNotElapsed`; `AccessControlError::Unauthorized` when none is set) | Epoch must be open; refused with `SetupOpen` until the vault's setup finishes | `test::epochs::a_non_manager_cannot_close`, `test::sealing::an_outsider_cannot_seal_before_the_minimum`, `test::sealing::an_outsider_seals_once_the_minimum_has_run` | | `deploy_to_custodian` | `#[only_role(caller, "treasury")]` | `assets <= free_reserve`, not winding down | `test::treasury::deploying_needs_the_treasury_role` | | `pause` | `#[only_role(caller, "guardian")]` | Contract not already paused | `test::controls::only_the_guardian_pauses` | -| `unpause` | `#[only_admin]`, `caller.require_auth()` | Contract must be paused | `test::controls::the_guardian_pauses_but_only_governance_unpauses` | -| `set_custodian` | `#[only_admin]`, `caller.require_auth()` | None | `test::treasury::setting_the_custodian_needs_the_admin_signature` | -| `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay` | `test::notice::setting_the_notice_needs_governance_authorisation` | -| `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_WIND_DOWN_DELAY`, not active | `test::wind_down::only_governance_sets_the_delay` | -| `propose_wind_down` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | -| `cancel_wind_down_proposal` | `#[only_admin]`, `caller.require_auth()` | Must be proposed, not active | `test::wind_down::governance_cancels_a_proposal_before_activation` | -| `propose_upgrade` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, delay >= notice | `test::upgrade::only_governance_proposes_and_cancels` | -| `propose_upgrade_delay` | `#[only_admin]`, `caller.require_auth()` | `secs in [MIN, MAX]`, `secs >= notice` | `test::upgrade::only_governance_proposes_and_cancels` | -| `cancel_upgrade` | `#[only_admin]`, `caller.require_auth()` | Proposal must stand | `test::upgrade::only_governance_proposes_and_cancels` | -| `apply_upgrade` | `#[only_admin]`, `caller.require_auth()` | Timelock expired, `when_not_paused` | `test::upgrade::only_governance_applies` | -| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault` | +| `unpause` | `caller` must be the stored admin (`AdminRequired`), then `caller.require_auth()` | Contract must be paused | `test::controls::the_guardian_pauses_but_only_governance_unpauses`, `test::controls::a_caller_other_than_the_admin_cannot_unpause_even_when_it_signs` | +| `set_custodian` | `#[only_admin]` | Never the vault itself (`InvalidCustodian`, checked first). Instant during setup; `UseTimelock` after | `test::governance::the_custodian_changes_through_the_timelock`, `test::treasury::setting_the_custodian_needs_the_admin_signature`, `test::treasury::setting_the_vault_as_its_own_custodian_is_refused` | +| `set_notice` | `#[only_admin]` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | +| `set_wind_down_delay` | `#[only_admin]` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | +| `set_min_epoch_duration` | `#[only_admin]` | `MIN_EPOCH_DURATION_FLOOR <= secs <= MIN_EPOCH_DURATION_CEILING` (`InvalidAmount`). Instant during setup; `UseTimelock` after | `test::sealing::the_minimum_stays_between_one_and_thirty_days`, `test::sealing::after_setup_the_minimum_changes_only_through_the_timelock` | +| `set_deposit_cap` | `#[only_admin]` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | +| `finish_setup` | `#[only_admin]` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; a compliance contract that answers `vault()` and `setup_open()` reports this vault with its setup closed (one that does not answer them is an external module and is accepted); the asset's `decimals` equal the token's; the asset, the token, the oracle and the vault are four different addresses; the oracle answers `state()` and `freshness_duration()`; a custodian other than the vault is set; the vault's own books are empty: the first epoch, with no deposit or redemption booked in it, nothing committed, and no shares owed or to burn; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::a_compliance_contract_from_outside_the_kit_is_accepted`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers; refused while the vault winds down (`WindDownActive`) | `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test::governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::governance::an_admin_hand_over_cannot_be_accepted_during_a_wind_down` | +| `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | +| `renounce_role` | The role holder, `caller.require_auth()` (OpenZeppelin's default) | The caller holds the role (`RoleNotHeld`); instant for every role, `manager` included | `test::signatures::each_role_call_asks_its_holder_for_one_signature` | +| `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | +| `propose_wind_down` | `#[only_admin]` | No proposal standing, not active; stops new deposits at once | `test::wind_down::only_governance_proposes`, `test::wind_down::a_proposed_wind_down_takes_no_new_deposit` | +| `cancel_wind_down_proposal` | `#[only_admin]` | Must be proposed, not active; reopens deposits | `test::wind_down::governance_cancels_a_proposal_before_activation`, `test::wind_down::cancelling_the_proposal_reopens_deposits` | +| `propose_change` | `#[only_admin]` | Not winding down, setup finished, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice`, a `Notice` within its bounds, a `WindDownDelay` in `[MIN_WIND_DOWN_DELAY, MAX_WIND_DOWN_DELAY]`, a `MinEpochDuration` within its bounds, a `Custodian` that is not the vault (`InvalidCustodian`) | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down`, `test::setup::nothing_is_proposed_during_setup`, `test::governance::a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied` | +| `cancel_change` | `#[only_admin]` | The change must be pending | `test::upgrade::only_governance_proposes_and_cancels` | +| `apply_change` | `#[only_admin]` | Eta passed, not paused, not winding down, delay `>= notice`; an `UpgradeDelay`, `Notice`, `WindDownDelay`, `MinEpochDuration` or `Custodian` change is checked against its bounds again | `test::upgrade::only_governance_applies`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included`, `test::governance::a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied` | +| `propose_change` (`Oracle(address)`) | `#[only_admin]` | The feed names this vault, its setup is closed, and it answers `state()` and `freshness_duration()` (`FeedNotBound`) | `test::oracle_swap::a_feed_outside_this_vault_is_never_proposed`, `test::oracle_swap::a_contract_that_is_not_a_feed_is_refused_when_proposed_and_when_applied` | +| `apply_change` (`Oracle(address)`) | `#[only_admin]` | Delay run; the feed still names this vault with its setup closed, and still answers `state()` and `freshness_duration()` (`FeedNotBound`) | `test::oracle_swap::the_oracle_changes_only_through_the_timelock`, `test::oracle_swap::a_feed_that_stops_naming_this_vault_is_never_installed`, `test::oracle_swap::a_contract_that_is_not_a_feed_is_refused_when_proposed_and_when_applied` | +| `pending_changes` | Open view | None | None (view) | +| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault`; `test::controls::governance_hands_over_in_two_steps_instead`; `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. Hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | ### 5.2 ShareToken (`contracts/share-token`) -The token's `admin` is governance. `pause`, `unpause`, `set_compliance` and `set_identity_verifier` need the admin; the guardian holds no role on the token. The `manager` role is held by the vault and the `compliance` role by the compliance authority. +The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. Setup cannot finish while an admin hand-over offered during setup is pending. After setup, the following wait for the vault's timelock through `propose_change`/`apply_change`: grants, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. A compliance or verifier swap is checked when proposed and again when applied: new rules must list the token, and rules that answer `vault()` and `setup_open()` must run on the vault's clock with their setup closed. As at `finish_setup`, rules that do not answer them count as an external module and are accepted. A new verifier must accept the vault. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. `transfer` and `transfer_from` refuse the bound vault as receiver (`VaultAddress`). + +The token pause is the "disabled" transfer mode; see [Transfer Modes](./kit-spec.md#transfer-modes). -Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs). +Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs). What each privileged call asks to sign: `test_governance::each_admin_call_asks_the_admin_for_one_signature`, `test::each_role_call_asks_its_holder_for_one_signature` and `test::legal_transfer::a_legal_transfer_or_a_recovery_asks_each_signer_once`. | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| -| `pause` | `#[only_admin]`, `caller.require_auth()` | Not paused | `test::unauthorized_caller_cannot_pause_or_unpause` | -| `unpause` | `#[only_admin]`, `caller.require_auth()` | Paused | `test::unauthorized_caller_cannot_pause_or_unpause` | +| `pause` | `caller` must be the stored admin (`AdminRequired`), then `caller.require_auth()` | Not paused | `test::unauthorized_caller_cannot_pause_or_unpause`, `test_governance::a_caller_other_than_the_admin_is_refused_even_when_it_signs` | +| `unpause` | `caller` must be the stored admin (`AdminRequired`), then `caller.require_auth()` | Paused | `test::unauthorized_caller_cannot_pause_or_unpause`, `test_governance::a_caller_other_than_the_admin_is_refused_even_when_it_signs` | | `mint` | `#[only_role(operator, "manager")]` | Receiver allowlisted | `test::unauthorized_caller_cannot_mint`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | | `burn` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_burn` | -| `forced_transfer` | `#[only_any_role(operator, ["manager", "compliance"])]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer` | -| `set_address_frozen` | `#[only_role(operator, "compliance")]` | None | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | -| `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | +| `forced_transfer` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer`, `test::legal_transfer::compliance_no_longer_forces_transfers` | +| `legal_transfer` | `#[only_role(caller, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Reference of 1–128 bytes (`InvalidReference`); never from or to the vault's address (`VaultAddress`); destination passes `verify_identity`; balance sufficient | `test::legal_transfer::*` | +| `set_address_frozen` | `#[only_role(operator, "compliance")]` | Never freezes the bound vault's address (`VaultAddress`); unfreezing it stays possible | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | +| `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient; never the bound vault's address (`VaultAddress`) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | | `unfreeze_partial_tokens`| `#[only_role(operator, "compliance")]` | Frozen amount sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | -| `recover_balance` | `#[only_role(operator, "compliance")]` | Target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery` | -| `set_compliance` | `#[only_admin]`, `operator.require_auth()` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | -| `set_identity_verifier` | `#[only_admin]`, `operator.require_auth()` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | -| `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`. A token without an admin could never be unpaused or rotate its manager; hand over through `transfer_admin_role` and `accept_admin_transfer`. | +| `recover_balance` | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Never from or to the vault's address (`VaultAddress`); target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery`, `test::legal_transfer::recovery_needs_the_admin_too`, `test::legal_transfer::recovery_never_takes_the_vault_balance`, `test::legal_transfer::recovery_never_lands_on_the_vault_address` | +| `set_compliance` | `operator` must be the stored admin (`AdminRequired`), then `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup`, `test_governance::a_caller_other_than_the_admin_is_refused_even_when_it_signs` | +| `set_identity_verifier` | `operator` must be the stored admin (`AdminRequired`), then `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup`, `test_governance::a_caller_other_than_the_admin_is_refused_even_when_it_signs` | +| `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`; `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. A token without an admin could never be unpaused or rotate its manager; hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | +| `bind_vault` | `#[only_admin]` | During setup, once; the vault must answer `timelock_clock` | `test_governance::a_second_vault_cannot_be_bound`, `test_governance::an_address_that_reports_no_clock_cannot_be_bound` | +| `finish_setup` | `#[only_admin]` | Needs a bound vault and no pending admin hand-over; once | `test_governance::setup_is_open_until_finished_and_never_reopens`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | +| `propose_change` | `#[only_admin]` | After setup; vault not winding down, except a grant of `compliance`; one upgrade at a time | `test_governance::nothing_is_proposed_during_setup`, `test_governance::only_the_admin_proposes_cancels_and_applies`, `test_governance::nothing_is_proposed_or_applied_while_the_vault_winds_down`, `test_governance::a_compliance_grant_lands_through_the_timelock_during_a_wind_down` | +| `cancel_change` | `#[only_admin]` | The change must be pending | `test_governance::only_the_admin_proposes_cancels_and_applies` | +| `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down (except a grant of `compliance`), delay `>=` the vault's notice | `test_governance::a_vault_pause_moves_the_eta_and_holds_the_change`, `test_governance::a_change_shorter_than_a_raised_notice_never_lands`, `test_governance::a_compliance_grant_proposed_before_the_wind_down_lands_during_it`, `test_governance::a_paused_vault_still_holds_a_compliance_grant_in_a_wind_down` | +| `propose_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | New rules list the token in `linked_tokens`, and rules that answer `vault()` and `setup_open()` name the token's vault with their setup closed (`ComplianceNotBound`); rules that do not answer them count as an external module and are accepted. A new verifier's `verify_identity` accepts the vault's address (`VerifierRefusesVault`). Rules that do not answer `linked_tokens` and a verifier that does not answer fail | `test_governance::rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `test_governance::a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `test::compliance::an_unbound_compliance_swap_is_refused_and_every_move_goes_on` in `contracts/async-vault` | +| `apply_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | Delay run; the new rules or verifier still pass the same check | `test_governance::applied_rule_and_verifier_changes_take_effect`, `test_governance::rules_that_stop_qualifying_are_never_installed`, `test_governance::rules_from_outside_the_kit_are_installed_while_they_list_the_token`, `test_governance::rules_that_stop_answering_a_binding_view_are_installed_as_external`, `test_governance::a_verifier_that_stops_accepting_the_vault_is_never_installed` | +| `pending_changes` | Open view | None | None (view) | +| `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` | +| `revoke_role` | Admin, `caller.require_auth()` | `compliance`: instant, the last holder included; a successor's grant waits one delay, during a wind-down too. `manager`: instant during setup, `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` (the instant case: `test_governance::revoking_compliance_stays_instant_after_setup`, `test_governance::governance_still_revokes_the_last_compliance_holder_at_once`) | +| `renounce_role` | The role holder, `caller.require_auth()` | The last `compliance` holder is refused (`LastComplianceHolder`); the caller holds the role (`RoleNotHeld`); instant | `test::the_last_compliance_holder_cannot_renounce`, `test::each_role_call_asks_its_holder_for_one_signature` | +| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers; refused while the vault winds down (`WindDownActive`) | `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test_governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending`, `test_governance::an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down` | +| `set_role_admin` | Refused always (`ShareTokenError::UseTimelock`) | None | `test_governance::no_role_ever_gets_a_role_admin`. A role admin could grant its role with no delay. | +| `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test::upgrade::with_wasm::the_token_exposes_no_immediate_upgrade` in `contracts/async-vault` (needs `upgrade_wasm`): the built token refuses `upgrade` with a real uploaded hash because it exports no such function | ### 5.3 NavOracle (`contracts/nav-oracle`) -Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs). +The oracle's `admin` is governance; its attester and guardian are constructor parameters. Until `finish_setup`, admin changes are instant. Setup cannot finish while an admin hand-over offered during setup is pending. After setup, the following wait for the vault's timelock through `propose_change`/`apply_change`: grants, admin hand-over, every `set_config` change except a shorter `freshness_duration`, and upgrades. Revoking `attester` or `guardian`, the NAV pause (`pause_nav`, `set_nav_paused`) and `clear_latest` stay instant. + +Tests: [`contracts/nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs) and [`contracts/nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs). What each privileged call asks to sign: `test::governance::each_admin_call_asks_the_admin_for_one_signature` and `test::governance::each_role_call_asks_its_holder_for_one_signature`. | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| -| `attest` | `#[only_role(caller, "attester")]` | Cooldown, deviation cap, bounds `[min, max]` | `test::attest_is_role_gated` | -| `raise_ripcord` | `#[only_role(caller, "guardian")]` | None | `test::raising_the_ripcord_is_limited_to_the_guardian` | -| `set_ripcord` | `#[only_admin]`, `caller.require_auth()` | None | `test::the_guardian_raises_the_ripcord_but_lowering_needs_governance` | -| `clear_latest` | `#[only_admin]`, `caller.require_auth()` | Ripcord must be raised | `test::only_admin_can_clear_latest`, `test::the_record_clears_only_while_the_ripcord_is_raised` | -| `set_config` | `#[only_admin]` (no caller argument; the stored admin signs) | Valid bounds | `test::only_admin_can_set_config` | -| `renounce_admin` | Refused always (`OracleError::AdminRequired`) | None | `test::the_oracle_admin_cannot_renounce_itself_away` | +| `attest` | `#[only_role(caller, "attester")]` | Refused while the NAV is paused; cooldown, deviation cap, bounds `[min, max]`; expiry capped at the freshness window; non-empty proof hash, proof URI ≤ 256 bytes | `test::oracle::attest_is_role_gated`, `test::oracle::attesting_is_refused_while_the_nav_is_paused`, `test::oracle::proof_reference::a_report_without_a_proof_hash_is_refused`, `test::oracle::proof_reference::a_proof_uri_past_the_limit_is_refused` | +| `pause_nav` | `#[only_role(caller, "guardian")]` | None; instant after setup too | `test::oracle::pausing_the_nav_is_limited_to_the_guardian` (the instant case: `test::governance::the_nav_pause_and_clearing_stay_instant_after_setup`) | +| `set_nav_paused` | `#[only_admin]` | None; instant after setup too | `test::oracle::the_guardian_pauses_the_nav_but_unpausing_needs_governance` | +| `clear_latest` | `#[only_admin]` | NAV must be paused; instant after setup too | `test::oracle::only_admin_can_clear_latest`, `test::oracle::the_record_clears_only_while_the_nav_is_paused` | +| `set_config` | `#[only_admin]` | Valid bounds. Instant while setup is open, or when the change is only a shorter `freshness_duration`; `UseTimelock` otherwise. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again | `test::oracle::only_admin_can_set_config`, `test::governance::a_tighter_cap_or_band_waits_after_setup`, `test::governance::a_config_that_tightens_one_field_and_loosens_another_waits`, `test::governance::a_shorter_freshness_window_with_a_looser_field_waits`, `test::governance::a_freshness_cut_below_the_cooldown_is_refused` | +| `renounce_admin` | Refused always (`OracleError::AdminRequired`) | None | `test::oracle::the_oracle_admin_cannot_renounce_itself_away`; `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. An oracle without an admin could never be reconfigured, clear a stuck record or unpause the NAV; hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | +| `bind_vault` | `#[only_admin]` | During setup, once; the vault must answer `timelock_clock` | `test::governance::setup_is_open_until_finished_and_never_reopens`, `test::governance::a_second_vault_cannot_be_bound`, `test::governance::an_address_that_reports_no_clock_cannot_be_bound` | +| `finish_setup` | `#[only_admin]` | Needs a bound vault and no pending admin hand-over; once | `test::governance::setup_is_open_until_finished_and_never_reopens`, `test::governance::setup_cannot_finish_while_a_hand_over_is_pending` | +| `propose_change` | `#[only_admin]` | After setup; vault not winding down; one upgrade at a time; a `Config` change must pass the constructor's bounds | `test::governance::nothing_is_proposed_during_setup`, `test::governance::an_invalid_config_cannot_be_proposed`, `test::governance::only_the_admin_proposes_cancels_and_applies` | +| `cancel_change` | `#[only_admin]` | The change must be pending | `test::governance::only_the_admin_proposes_cancels_and_applies` | +| `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice; a `Config` change must still pass the bounds | `test::governance::a_vault_pause_moves_the_eta_and_holds_the_change`, `test::governance::nothing_is_proposed_or_applied_while_the_vault_winds_down`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::a_config_outside_the_bounds_is_refused_when_it_applies` | +| `pending_changes` | Open view | None | None (view) | +| `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test::governance::grants_wait_after_setup_and_land_through_the_timelock` | +| `revoke_role` | Admin, `caller.require_auth()` | Instant: taking an attester or a guardian away moves no value | `test::governance::only_the_admin_revokes_a_role` (the instant case: `test::governance::revoking_an_attester_or_a_guardian_stays_instant_after_setup`) | +| `renounce_role` | The role holder, `caller.require_auth()` (OpenZeppelin's default) | The caller holds the role (`RoleNotHeld`); instant | `test::governance::each_role_call_asks_its_holder_for_one_signature` | +| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers; refused while the vault winds down (`WindDownActive`) | `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test::governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test::governance::setup_cannot_finish_while_a_hand_over_is_pending`, `test::governance::an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down` | +| `set_role_admin` | Refused always (`OracleError::UseTimelock`) | None | `test::governance::no_role_ever_gets_a_role_admin`. A role admin could grant its role with no delay. | +| `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test::governance::with_wasm::the_oracle_exposes_no_immediate_upgrade` (needs `upgrade_wasm`): the built oracle refuses `upgrade` with a real uploaded hash because it exports no such function | ### 5.4 IdentityVerifier (`contracts/identity-verifier`) -A testnet-only stub. Its admin is the compliance key and cannot be transferred: the stub exposes no access-control interface, so replacing the compliance key on it means redeploying. +A testnet-only stub. Its admin is the compliance key and cannot be transferred: the stub exposes no access-control interface, so replacing the compliance key on it means redeploying. So the compliance key loses its power at once only on the share token, where its `compliance` role can be revoked instantly. On this stub it can still allowlist and delist addresses until the token's verifier is swapped for a new deployment; after setup that swap waits for the vault's timelock, at least seven days. -Tests: [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs). +Tests: [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs). What each admin call asks to sign: `test::each_admin_call_asks_the_admin_for_one_signature`. | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| -| `allow` | `#[only_admin]` (the compliance key), `caller.require_auth()` | None | `test::only_admin_can_allow` | +| `allow` | `#[only_admin]` (the compliance key) | None | `test::only_admin_can_allow` | +| `set_claim_topics_and_issuers` | `#[only_admin]` (the compliance key); keeps the `operator` the trait fixes and does not read it | None; it changes nothing | None (signature: `test::each_admin_call_asks_the_admin_for_one_signature`) | | `verify_identity` | Open check (Internal hook) | `is_allowed(account) == true` | `test::verify_identity_checks_allowlist` | + +### 5.5 Compliance (`contracts/compliance`) + +A testnet-only stub. Its admin is governance. Module changes, token bindings and admin hand-overs are instant during setup and wait for the vault's timelock after `finish_setup`. Each admin call asks the stored admin to sign. Its own entrypoints take no caller argument; its OpenZeppelin trait methods keep the `operator` argument their trait fixes and do not read it (principle 2). + +Tests: [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs). What each privileged call asks to sign: `test::changes::each_admin_call_asks_the_admin_for_one_signature`, `test::changes::a_hand_over_asks_each_side_for_one_signature` and `test::changes::each_hook_asks_the_bound_token_for_one_signature`. + +| Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | +|---|---|---|---| +| `bind_vault` | `#[only_admin]` | During setup, once; the vault must answer `timelock_clock` | `test::changes::a_second_vault_or_a_late_vault_is_refused`, `test::changes::an_address_that_reports_no_clock_cannot_be_bound`, `test::changes::only_the_admin_binds_and_finishes` | +| `finish_setup` | `#[only_admin]` | Needs a bound vault and no pending admin hand-over; once | `test::changes::setup_cannot_finish_without_a_vault`, `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::a_second_vault_or_a_late_vault_is_refused` | +| `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | `#[only_admin]`; `operator` is not read | Instant during setup; `UseTimelock` after | `test::changes::after_setup_rule_changes_wait_for_the_timelock` | +| `propose_change` | `#[only_admin]` | After setup; vault not winding down | `test::changes::proposals_wait_until_setup_is_finished`, `test::changes::only_the_admin_proposes_cancels_and_applies` | +| `cancel_change` | `#[only_admin]` | The change must be pending | `test::changes::a_cancelled_change_never_lands` | +| `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test::changes::a_proposed_binding_lands_after_the_delay`, `test::changes::nothing_lands_while_the_vault_is_paused_or_winding_down`, `test::changes::a_vault_pause_pushes_the_change_back`, `test::changes::a_change_shorter_than_a_raised_notice_never_lands` | +| `pending_changes` | Open view | None | None (view) | +| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test::changes::only_the_admin_offers_or_cancels_a_hand_over`, `test::changes::after_setup_the_admin_hands_over_through_the_timelock`, `test::changes::a_cancelled_hand_over_no_longer_holds_setup_open`, `test::changes::a_hand_over_after_setup_is_cancelled_at_once` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers; refused while the vault winds down (`WindDownActive`) | `test::changes::only_the_nominee_accepts_a_hand_over`, `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test::changes::an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down` | diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 101e9cbb..7d3bbb6c 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -21,7 +21,9 @@ Every investor account in the vault ecosystem exists in one of three primary com 3. **Frozen (`Frozen`)**: - The account is subject to sanctions, legal freezing, or security hold (`is_frozen(account) == true` via `ShareToken::set_address_frozen`). - The share token refuses a `transfer` from or to a frozen address. Minting does not check freezes, and `forced_transfer` ignores them. - - A frozen investor is outside the payable-claims guarantee (design document §8.1). The vault does not check freezes today, so a frozen holder's exit still goes through; the kit does not promise it. + - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it; only an unfreeze releases it. Lifting a freeze needs a holder of the share token's `compliance` role, so a frozen investor's wind-down slice waits for one to be live: the last holder cannot renounce, and a grant of the role passes the token's timelock even during a wind-down. A refund for a deposit priced at zero shares is the exception: it is not tracked as owed, so it waits in the free reserve, where treasury can deploy it or a wind-down round can credit it to other holders (see the limits in the design doc's [§8.7](./strata-product-and-architecture.md#87-treasury-and-custodian)). A legal-order transfer (`legal_transfer`), which compliance and governance sign together, can move shares in the investor's own wallet, but not value the vault holds for them: deposit escrow, priced but unminted shares, shares escrowed in a redemption, priced cash, or a wind-down slice. A partial freeze limits exits to the free balance. + + This follows how asset-freeze and blocking rules generally treat frozen property: held, not released, not returned to the owner and not confiscated, with the entitlement kept and recorded. It is not legal advice; counsel must confirm it for each jurisdiction. --- @@ -29,48 +31,55 @@ Every investor account in the vault ecosystem exists in one of three primary com | Operation | Allowlisted | De-listed (Non-Frozen) | Frozen (Address or Partial) | Contract Mechanics & Regulatory Rationale | |---|---|---|---|---| -| **`request_deposit`** | ✅ Allowed | ⚠️ Allowed by SAC | ⚠️ Allowed by SAC | SAC USDC transfer moves funds into cancellable escrow. The vault does not check `IdentityVerifier` at subscription time to avoid coupling to external registry gas or third-party downtime. | -| **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ✅ **Allowed** | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. Cash refunds bypass share token compliance checks. | -| **`claim_deposit`** | ✅ Allowed | ❌ **Refused** | ⚠️ Minted if allowlisted | `ShareClient::mint` calls `verify_identity` and `can_create`, and does not check freezes. A de-listed receiver is refused with `IdentityVerificationFailed`. A frozen receiver still on the allowlist is minted, and cannot transfer those shares while frozen. | -| **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Allowed, not guaranteed | **Exit Right**: the vault escrows the shares with `ShareClient::forced_transfer`, which checks neither the allowlist nor freezes, so a de-listed holder can start an exit. It also releases as much of a partial freeze as the escrow needs. | -| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | `return_shares` uses standard `ShareClient::transfer(vault, controller, shares)`, which executes `can_transfer`. Reverts if `controller` is de-listed or frozen. Holder cannot re-acquire shares. Refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. | -| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Pays, not guaranteed | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks neither the allowlist nor freezes. | -| **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | +| **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`); a verifier that cannot answer counts as a refusal. A partial freeze is not checked. | +| **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `return_escrow`, then publishes `DepositCancelled`. Escrowed settlement asset (USDC) is refunded directly to `holder`. The vault checks `is_frozen` on the share token before it refunds anything; once past that, cash refunds bypass share token compliance checks. A partial freeze is not checked. | +| **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver off the allowlist gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A compliance module that refuses the mint (`can_create`) still blocks the claim. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | +| **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); a partial freeze caps the request to the free balance | **Exit Right**: the vault checks `is_frozen` and `get_frozen_tokens` on the share token before it escrows anything with `RWATokenClient::forced_transfer`. A de-listed, non-frozen holder can still start an exit; a frozen one cannot, and a partial freeze caps the request to the shares `get_frozen_tokens` leaves free. | +| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held**; a partial freeze does not stop it: the shares return and stay partly frozen | Outside a wind-down the vault refuses a de-listed holder (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`). For anyone else it checks the receiver itself (passes the identity check, not frozen, accepted by the compliance rules' `can_transfer`) and returns the shares with the manager `forced_transfer`; a move the rules refuse gets `HolderNotEligible`. Neither the vault's own allowlist place nor the share-token pause stops a return. A refused holder stays in the queue and exits in cash once the epoch is priced. In an active wind-down no shares come back: the escrow is burned into the holder's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not. A frozen holder's escrow stays until the freeze is lifted. | +| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. Outside a wind-down, a claim that pays nothing returns the escrowed shares instead, under the same checks as `cancel_redeem`: a de-listed holder's is refused with `HolderNotEligible` and the request stays. In a wind-down such a claim burns the escrowed shares. | +| **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares — the rest keeps earning every round and is paid once unfrozen. | +| **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen, never to the vault's address (`VaultAddress`). | +| **`legal_transfer`** (compliance + governance) | ✅ Allowed | ✅ Allowed | ✅ Allowed | Moves shares in the holder's wallet under a legal order; the destination must pass `verify_identity`; never into or out of the vault. It cannot reach value the vault holds for a frozen holder; only an unfreeze releases that. | --- ## 3. Deep Dive into Key Lifecycle Invariants ### 3.1 The Exit-Only Cash Path -A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen investor can always leave the fund in cash**. A frozen investor is outside this guarantee, as the design document states in §8.1: the vault does not check freezes today, so their exit goes through, but nothing promises it. +A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen investor can always leave the fund in cash**. A frozen investor is held instead of paid, as §1 describes. - **Mechanism**: 1. A de-listed holder queues an exit via `request_redeem`. The vault escrows the shares with `forced_transfer`. 2. The epoch closes and is priced via attestation. 3. Once priced and covered, the cash liability is committed. 4. The investor calls `claim_redeem`. The cash payout transfers the settlement asset (USDC) directly to the investor's Stellar account, while the shares are burned from the vault's own balance. - 5. The cash transfer never queries `IdentityVerifier` or `ShareToken::is_frozen`. Once priced and covered, cash exits cannot be blocked. + 5. The cash transfer asks the identity verifier through `standing`, but the claim does not depend on its answer: a de-listed holder is treated the same as a listed one. It does check `ShareToken::is_frozen` first: a frozen holder's claim is held, not paid, until the freeze lifts. Once priced, covered and unfrozen, cash exits cannot be blocked. ### 3.2 Deposit Cancellation for De-Listed Investors If an investor deposits into an open epoch but is de-listed (or KYC expires) before the epoch is priced: -- The investor is permitted to call `cancel_deposit(investor, epoch_id)`. -- `refund_deposit` transfers the escrowed USDC back to the investor. +- The investor is permitted to call `cancel_deposit(holder, epoch_id)`. +- `return_escrow` transfers the escrowed USDC back to the investor. - Because no shares were minted, returning cash restores the investor's funds without violating share token compliance rules. -### 3.3 De-Listing Between Deposit Request and Claim -If an investor deposits while allowlisted, but is de-listed after the epoch is closed and priced: -- **`claim_deposit` is refused**: The vault calls `ShareClient::mint`, which evaluates `can_create`. Because the receiver is no longer allowlisted, `verify_identity` fails with `IdentityVerificationFailed`. -- **`cancel_deposit` is blocked**: The epoch is already `Fulfilled`. As an anti-arbitrage safeguard, once an epoch has taken a valuation, cancellation is permanently closed (`VaultError::AlreadyPriced`). -- **Resolution**: The investor's settlement capital remains safely in vault escrow. The investor can either: - 1. Complete KYC refresh, upon which compliance re-allowlists the account and `claim_deposit` succeeds; or - 2. Compliance / governance resolves the claim out-of-band. +### 3.3 De-Listing Between Pricing and Claim +If an investor deposits while allowlisted but is de-listed after the epoch is priced: +- **The cash is no longer escrow.** Pricing moved it out of the cancellable bucket into the reserve, where it backs the shares the epoch promised. The investor's claim is to those shares, counted in `pending_mint_shares`. +- **`claim_deposit` moves the shares to the exit queue.** The share token would refuse to mint to her, so the vault mints the shares into its own escrow and adds them to her redemption in the open epoch. She is paid in cash at that epoch's price, like anyone leaving then, through `claim_redeem`. A fall in the price since her deposit was priced is hers, as it would be for any holder who exits. +- **`cancel_deposit` is refused** with `AlreadyPriced`: once an epoch has a price, cancellation is closed. +- **In a wind-down**, which prices nothing further, the shares join her wind-down entitlement and `claim_wind_down` pays them. +- **In a wind-down, cancelling any escrowed redemption**, whether from her converted deposit or one she requested before the delisting, burns the escrow into the holder's wind-down entitlement (`RedemptionSurrendered`). No shares come back, for any holder. +- **The conversion needs the vault's own allowlist place**, because the vault mints the shares into its own escrow. If compliance removes the vault's address, her conversion waits until the vault is relisted. Nothing is lost. This is kept as a stated limit: delisting the vault is a compliance operator acting against holders, which the kit does not guard against (THREAT_MODEL R9), and `health().allowlisted` reads false meanwhile. +- **Relisting first** still works: if compliance re-allowlists her before she claims, `claim_deposit` mints the shares to her. +- **A freeze holds her.** If she is also frozen, `claim_deposit` fails with `Frozen` (6071) and the priced deposit stays unclaimed until the freeze is lifted. + +An address that was never allowlisted cannot reach this state: `request_deposit` refuses it before any cash moves. ### 3.4 Redemption Cancellation Refusal When an investor requests redemption, their shares are transferred to the vault. If the investor becomes de-listed or frozen before pricing: -- Calling `cancel_redeem` requires transferring shares from the vault back to the investor's account (`return_shares`). -- `ShareToken::transfer` refuses a frozen receiver (`AddressFrozen`) and a receiver off the allowlist (`IdentityVerificationFailed`), so returning the shares is refused. -- The investor is not allowed to re-enter share possession. They remain in the queue and exit into cash upon epoch fulfillment. +- The vault checks the holder itself before it returns escrowed shares. A de-listed holder is refused (`NotAllowlisted`), and so is a move the compliance rules refuse (`HolderNotEligible`); either way the investor stays in the queue and exits in cash once the epoch is priced. A frozen holder is held (`Frozen`) until the freeze is lifted. +- The vault moves the shares as the token's `manager` (`forced_transfer`), so removing the vault's own address from the allowlist does not stop returns. A share-token pause does not stop them either: it stops trading between holders, not the vault's escrow. A partial freeze does not stop them: the shares return and stay partly frozen. +- During a wind-down, every cancel burns the escrowed shares into the holder's wind-down entitlement (`RedemptionSurrendered`), listed or not, and they collect pro rata with `claim_wind_down`. A frozen holder's escrow stays until the freeze is lifted. --- @@ -80,14 +89,19 @@ The share token is OpenZeppelin's RWA token. The allowlist and freeze checks sit | Token operation | Freeze check | Allowlist check (`verify_identity`) | Compliance hooks | |---|---|---|---| -| `transfer` | Refuses if sender or receiver is frozen; the sender needs enough unfrozen balance | Sender and receiver | `can_transfer` before, `transferred` after | +| `transfer` | Refuses if sender or receiver is frozen; the sender needs enough unfrozen balance; never to the vault's address (`VaultAddress`) | Sender and receiver | `can_transfer` before, `transferred` after | | `mint` | None | Receiver | `can_create` before, `created` after | | `forced_transfer` | None; releases as much of a partial freeze as the amount needs | None | `transferred` after | +| `legal_transfer` (the kit's own, built on `forced_transfer`) | None; releases as much of a partial freeze as the amount needs | Receiver | `transferred` after | | `burn` | None; releases as much of a partial freeze as the amount needs | None | `destroyed` after | 0.7.2 has five compliance hooks: `can_transfer` and `can_create` are asked before the state change, and `transferred`, `created` and `destroyed` are told after it. There is no `can_destroy`. -The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. +When the vault returns escrowed shares with `forced_transfer`, it makes the receiver checks itself first: the freeze, `verify_identity` and `can_transfer`. It does not check the token pause. + +The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. Adding or removing a module waits for the vault's timelock once its setup is finished, so holders get at least the delay as notice of a rule change. An exit still needs a sealed epoch, a price and cash, so the delay does not promise that one completes first. + +The `transfer` checks above and the token pause are the kit's two transfer modes; see [Transfer Modes](./kit-spec.md#transfer-modes). --- @@ -95,14 +109,27 @@ The kit's compliance contract registers no modules, so `can_transfer` and `can_c The behavior specified in this matrix is verified by on-chain automated test suites in [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs): -* `a_non_allowlisted_investor_cannot_claim_shares`: Verifies unallowlisted caller cannot mint shares on claim. -* `allowlisting_after_the_fact_lets_the_claim_through`: Verifies KYC restoration unblocks claim. +* `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`: Verifies a non-allowlisted depositor is refused and nothing moves. +* `a_delisted_investor_cannot_request_a_deposit`: Verifies a de-listed depositor is refused at request time. +* `relisting_after_pricing_lets_the_claim_through`: Verifies KYC restoration unblocks a priced claim. +* `a_delisted_holders_priced_deposit_joins_their_redemption_in_the_open_epoch`, `the_delisted_holder_is_paid_at_the_next_epochs_price`, `in_a_wind_down_the_shares_are_credited_before_the_first_round`, `a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds`, `a_frozen_and_delisted_holders_claim_is_held`: Verify the exit path for a holder delisted after pricing ([`test/delisted.rs`](../contracts/async-vault/src/test/delisted.rs)). +* `a_holder_delisted_after_pricing_is_paid_through_a_wind_down`: The combined case, with the cancel refused as `AlreadyPriced` ([`test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs)). +* `a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim`: Records that a compliance module can block a priced claim (a trust assumption). * `a_delisted_holder_can_still_queue_an_exit`: Verifies de-listed holders can queue redemption via forced transfer. * `a_delisted_holder_can_still_claim_their_exit`: Verifies de-listed holders always receive cash payout. * `a_delisted_investor_can_cancel_deposit_and_receive_asset`: Verifies de-listed depositors can cancel and receive cash back. * `a_delisted_holder_cannot_cancel_redemption_back_to_shares`: Verifies de-listed holders cannot abort redemption back into shares. -* `a_frozen_holder_cannot_cancel_redemption_back_to_shares`: Verifies frozen holders cannot abort redemption back into shares. +* `share_returns::*` ([`test/share_returns.rs`](../contracts/async-vault/src/test/share_returns.rs)): Verifies a cancel returns the shares while the vault's own address is delisted or the token is paused, keeps a partial freeze on the returned shares, and refuses a move the compliance rules refuse or a delisted holder's zero-asset claim with `HolderNotEligible`. +* `a_frozen_holder_is_paid_only_after_the_freeze_lifts`: Verifies a frozen holder's cancel and claim are both held, and paid only once the freeze lifts. * `a_frozen_or_delisted_holder_cannot_transfer_shares`: Verifies secondary market transfers are strictly refused for de-listed and frozen accounts. -* `a_frozen_investor_on_the_allowlist_is_still_minted`: Records that minting does not check freezes. -* `a_frozen_holder_can_still_queue_an_exit`: Records that escrowing an exit does not check freezes. -* `queueing_an_exit_releases_a_partial_freeze`: Records that the escrow releases as much of a partial freeze as it needs. +* `a_frozen_investor_cannot_request_a_deposit`: Verifies a frozen depositor is refused at request time. +* `a_frozen_investor_is_not_minted_until_unfrozen`: Verifies a frozen investor's priced deposit is held until the freeze is lifted. +* `a_frozen_holder_cannot_queue_an_exit`: Verifies a frozen holder cannot start a redemption. +* `a_partial_freeze_limits_an_exit_to_the_free_shares`: Verifies a partial freeze caps a redemption request to the free balance. +* `a_frozen_investors_escrow_stays_until_the_freeze_lifts`: Verifies a frozen depositor's cancel is held and the escrow is unaffected. +* `a_frozen_holder_does_not_get_a_worthless_exit_back`: Verifies a frozen holder's zero-asset claim is held rather than returning shares. +* `a_frozen_holder_is_not_moved_into_the_exit_queue`: Verifies a holder who is both de-listed and frozen is held, not converted to an exit. +* `a_frozen_holders_slice_stays_owed_until_the_freeze_lifts` ([`test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs)): Verifies a frozen holder's wind-down slice stays in `wind_down_owed` until the freeze lifts. +* `a_frozen_holders_escrowed_exit_waits_through_a_wind_down`: Verifies a frozen holder's escrowed redemption and wind-down claim are both held through a wind-down. +* `a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim`: Verifies a partial freeze excludes frozen shares from a wind-down surrender. +* `legal_transfer::*` ([`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs)): Verifies a legal-order transfer needs compliance and governance as two different accounts, never moves shares out of or into the vault's address, lands only on an allowlisted account and publishes its order reference; recovery needs the same co-signature, and the vault's address cannot be frozen. diff --git a/docs/GRANT_DIFFERENCES.md b/docs/GRANT_DIFFERENCES.md new file mode 100644 index 00000000..c71ced1a --- /dev/null +++ b/docs/GRANT_DIFFERENCES.md @@ -0,0 +1,188 @@ +# How the Built Kit Differs from the Grant Proposal + +This note is for the Stellar Community Fund and for teams that adopt the kit. +It lists where the built kit differs from the grant proposal on purpose, what is +not built yet, and the limits an adopter should know before deploying. Each item +says what the kit does and why. Where the kit departs from the proposal, the +item says what the proposal said. Each item links to the place in this +repository that documents it: "the design doc" is +[Product and architecture](./strata-product-and-architecture.md), and "the kit +spec" is the [Kit specification](./kit-spec.md). + +## Changed on purpose + +1. **Claims are priced once per epoch and paid in any order.** The proposal said + "FIFO claim priced at claim": claims paid first in, first out, each at the + price of the moment it is claimed. The kit batches requests into epochs. It + prices each epoch once, when the epoch is fulfilled (`fulfill_epoch`), from + a valid net asset value (NAV) report, and every request in the epoch gets + that one price. A priced claim pays as soon as the vault's cash, not + counting deposits that can still be cancelled, covers its own amount, in + any order. First in, first out would let one large claim the cash cannot + cover yet block every smaller claim behind it. One price per epoch also + treats every request in it the same. See design doc + [§6](./strata-product-and-architecture.md#6-how-it-works) and + [§8.4](./strata-product-and-architecture.md#84-request-lifecycle). + +2. **The attester posts the whole per-share NAV.** The proposal said the + "reporter attests off-chain value only, contract adds its own USDC + balance". In the kit, the attester computes the full NAV per share off + chain, from the value deployed off chain and the vault's public figures, + and posts it. The oracle checks each report against a band of allowed + prices, caps on how far the price may rise or fall in one report, and a + cooldown between reports. A report stays valid for a set freshness window, + and the guardian can pause the NAV in an emergency. The USDC the vault + holds is the reserve that pays claims, kept in its own public figures (the + design doc's split accounting): the cash available to pay exits, the cash + owed to priced redemptions, and any shortfall. The contract does not add it + to the price. This keeps the price fixed between reports: moving cash to the + custodian or back does not change it. See design doc + [§8.5](./strata-product-and-architecture.md#85-valuation-and-accounting). + +3. **The timelock is the kit's own `strata-timelock` crate.** The proposal does + not mention a timelock. An earlier version of the design planned to use + OpenZeppelin's timelock. OpenZeppelin's timelock lacks four things the kit + needs: + - one clock shared by the vault, the share token, the oracle and the + compliance contract; + - paused time does not count, so a vault pause pushes back the time a + queued change can apply; + - a setup window in which changes are instant; it closes once and never + reopens; + - no change lands during a wind-down, except a grant of the share token's + `compliance` role, which still waits the full delay. + + The delay is 7 to 90 days. See design doc + [§9](./strata-product-and-architecture.md#9-relationship-to-existing-stellar-tooling). + +4. **Two cases hold a priced claim: a compliance freeze, and a delisted + holder's redemption that rounds to zero.** The proposal said exits are + "never gated or pausable". In the kit, a vault pause, a delisting, or a + stale or paused price never blocks a priced claim once the vault's cash, + not counting deposits that can still be cancelled, covers it. A compliance + freeze does hold it. When the compliance authority freezes a holder, what + the vault owes them is held until the freeze is lifted. It is not paid, not + returned, and not given to anyone else. This follows how asset-freeze rules + generally treat frozen property. One edge case, a frozen holder's deposit + too small to mint any shares, is described in the + [compliance matrix](./COMPLIANCE_MATRIX.md#1-investor-states). There is one + more exception. A redemption so small that it rounds down to zero assets + pays no cash and gives the shares back, so the holder must be able to + receive shares. If the holder has been removed from the allowlist + (delisted), that claim waits until they are listed again. In a wind-down + the shares are burned instead. See kit spec + [Invariant 4](./kit-spec.md#invariant-4-covered-claims-always-pay-exit-guarantee). + +5. **New deposits stop while the price cannot be trusted or the cash is + short.** The proposal did not set this rule. Besides its usual checks, the + vault refuses a new deposit request when: + - the NAV feed is stale (its last report expired), cleared or paused + (`FeedNotValid`); + - the deposit asset's issuer has taken back part of the vault's cash (a + clawback), and the vault now holds less than the deposits still waiting + to be priced (`EscrowNotCovered`); + - governance has proposed a wind-down (`WindDownAlreadyProposed`) or it is + active (`WindDownActive`). + + A new deposit should not buy shares at a price nobody vouches for, fund an + earlier depositor's refund after a clawback, or join a vault that is about + to close. This rule stops only new deposits. It closes no exit for money + already in the vault. See design doc + [§8.4](./strata-product-and-architecture.md#84-request-lifecycle) and + [§8.7](./strata-product-and-architecture.md#87-treasury-and-custodian). + +6. **Anyone may seal an epoch once it has run a minimum time.** The proposal + did not set this rule. Sealing an epoch closes it to new requests so it can + be priced. The manager may seal the open epoch at any time. Governance can + also set a minimum epoch duration, from 1 to 30 days. Once the open epoch + has been open that long, anyone may seal it, so pricing does not stop if + the manager does. The 1-day floor stops outsiders from sealing a stream of + tiny epochs. Whoever seals picks where the batch ends, not the price. Left + unset, only the manager seals. See kit spec + [When the Manager or the Attester Stops](./kit-spec.md#when-the-manager-or-the-attester-stops). + +7. **Transfer modes are the token pause and the allowlist check on both + sides.** The grant's admin panel lists two transfer modes: disabled, and + allowed when both sides are allowlisted. The kit has both without a separate + setting. "Allowed when both sides are allowlisted" is the share token's own + check on every transfer between holders, and it is always on. "Disabled" is + the share token's pause. Only governance pauses and unpauses the token, and + both take effect at once. Exits stay open in either mode: the vault moves + shares by minting, burning and forced transfer, which the pause does not + stop. See kit spec [Transfer Modes](./kit-spec.md#transfer-modes). + +## Not built yet + +8. **Multisig authorities.** The proposal's first tranche included "Role + framework and multisig setup". Today the deploy script gives each authority + a single key, not a multisig. The scripted testnet deploy and CI sign every + setup step themselves, so each authority is one key there. The admin console + already collects the signatures for a privileged call and compares their + weight with the account's threshold; what is missing is the deploy creating + and checking the multisig accounts. The deploy checks that setup has + closed, that the vault's own wiring checks (its `health()` view) all pass, + that the vault holds the share token's `manager` role, and that the token's + `compliance` role has two holders. Still to come, before a mainnet + deployment: creating + the multisig accounts, checking that each authority is the intended + multisig, checking that no key used only to deploy keeps a role, and + reading the deposit asset's authorization flags (whether its issuer can + freeze or claw back balances). See design doc + [§8.10](./strata-product-and-architecture.md#810-deployment). + +## Delivered as its own document + +9. **On-chain monitoring plan.** The grant requires one. It is + [`docs/MONITORING_PLAN.md`](./MONITORING_PLAN.md): what an operator watches + on chain, where each signal comes from, and what to do when it fires. + +## Limits an adopter should know + +10. **Share decimals are 7, and the asset must match.** The share token is + created with 7 decimals, and the vault's setup cannot finish unless the + deposit asset has the same. Every classic Stellar asset, USDC included, + has 7. A Soroban token with other decimals needs a fork of the share token. + See kit spec + [Deploy-Time Parameters](./kit-spec.md#deploy-time-parameters-constructor_args). + +11. **An issuer clawback of the vault's cash is outside the kit's control.** + The deposit asset's issuer can take back the vault's cash, and the kit + cannot stop it. After a clawback the vault pays in a fixed order: first + deposits that can still be cancelled, then priced redemptions, and only + then anything else. A cancellation or a priced claim the cash cannot cover + is refused with a named error (`EscrowNotCovered` or `ClaimNotCovered`) + until the treasury authority sends cash to cover the gap. A wind-down claim + is the exception: it does not check coverage first. It can take cash held + for pending deposits, or fail with the asset's own error. The kit does not + insure the loss: it reaches holders through the NAV the attester reports. + If the issuer freezes the vault's balance instead, every cash exit waits + until the freeze is lifted. See threat model + [X10](./THREAT_MODEL.md#x10-the-deposit-assets-issuer-freezes-or-claws-back-the-vaults-cash) + and [X8](./THREAT_MODEL.md#x8-wind-down). + +12. **In a wind-down, a failing compliance module can block claims.** In a + wind-down, a holder collects their part of the cash by surrendering + shares, which the vault burns. The share token calls the compliance + contract on each burn. If a compliance module (a rule plugged into the + compliance contract) starts failing during a wind-down, those claims fail. + The module cannot be removed then: removing it waits for the timelock, and + no timelocked change lands in a wind-down except a `compliance` grant. A + frozen holder's wind-down payout also waits until the compliance authority + (a holder of the share token's `compliance` role) lifts the freeze. If that + key is lost, granting the role to a new account waits the full timelock + delay, at least seven days. So each deployment keeps a second `compliance` + holder whose key is stored offline. See design doc + [§8.7](./strata-product-and-architecture.md#87-treasury-and-custodian) and + kit spec [§4](./kit-spec.md#4-deployment-runbook-summary). + +13. **Figures counted in ledgers assume 5-second ledgers.** Most durations in + the kit are in seconds and read the ledger's close time: the timelock + delay, the notice, the freshness window, the cooldown, the wind-down delay + and the minimum epoch duration. Ledger speed does not change them. A few + figures are counted in ledgers and assume one ledger every 5 seconds, + 17,280 a day: how long contract data lives before it must be extended, the + 7 days a new admin has to accept a hand-over, and the 30 days the script + that extends storage asks for. If the network makes ledgers faster, these + last less time on the clock. At 2.5-second ledgers, a hand-over must be + accepted within 3.5 days. See kit spec + [Days and Ledgers](./kit-spec.md#days-and-ledgers). diff --git a/docs/MONITORING_PLAN.md b/docs/MONITORING_PLAN.md new file mode 100644 index 00000000..ff813702 --- /dev/null +++ b/docs/MONITORING_PLAN.md @@ -0,0 +1,631 @@ +# Monitoring plan + +What an operator watches on chain to run a Strata vault: each signal, where it +comes from, when it fires, how urgent it is, who is told and what they do. It +covers stale valuation, reserve coverage, privileged actions and storage that +expires. + +This is a plan, not running alerts. Nothing in the kit watches the chain or +sends a message today; push alerts are planned after v1 +([design doc §11](./strata-product-and-architecture.md#11-potential-future-features)). +Getter, event and entrypoint names are those of the contracts at commit +`641765d`. The testnet deployment in the [README](../README.md) may run older +code. + +Every threshold below is a default. It depends on the deployment's parameters, +so set each one from the values in +[Parameters the thresholds read](#parameters-the-thresholds-read) before the +vault opens, and again whenever they change. + +## How to read it + +Each signal has an id: V for valuation, C for coverage, P for privileged +actions, T for storage lifetime. Under it: + +- **Source:** a getter or an event, and the contract it comes from. A getter is + a read-only call, simulated over RPC with no fee and no signature. An event + is read with `getEvents`. +- **Threshold:** when the signal fires, by default. +- **Severity** and **told:** how fast someone acts, and who. +- **Response:** what they do, with a link to the runbook or the threat where one + exists. + +| Severity | Meaning | +| --- | --- | +| Critical | Act now, at any hour. Value can move, or pricing or exits have stopped. | +| Warning | Act within the working day. Something breaks later if nobody does. | +| Info | Record it, and review the record once a day. | + +| Told | Who | +| --- | --- | +| On call | Whoever runs the watcher for the operator. Told of every Critical and Warning signal. | +| Governance, guardian, treasury, attester, compliance | The signers of that authority ([kit spec §3](./kit-spec.md#3-role--authority-matrix)). | +| Manager | Whoever runs the account that holds the vault's `manager` role and seals epochs. | +| Holders | A public notice on the operator's own channel. | + +## Summary + +| Id | Signal | Severity | Told, besides on call | +| --- | --- | --- | --- | +| V1 | Report close to expiry | Warning | Attester | +| V2 | Feed not valid | Critical | Attester, governance | +| V3 | Reports expire before the notice ends | Warning | Governance | +| V4 | A large step | Warning | Attester, governance | +| V5 | A run of reports one way | Critical | Guardian, governance, attester | +| V6 | The stored report is cleared | Critical | Governance, guardian | +| V7 | The first report after a clear | Critical over the threshold, else Info | Governance, guardian, attester | +| C1 | Priced exits the vault cannot pay | Warning; Critical when it lasts | Treasury; governance on Critical | +| C2 | Not enough cash for the next pricing | Warning | Treasury | +| C3 | The vault holds less than its pending escrow | Critical | Treasury, governance | +| C4 | The asset's issuer acts on the vault | Critical | Treasury, governance | +| C5 | A sealed epoch is not priced | Warning; Critical after 24 hours | Attester, manager | +| C6 | The open epoch runs long | Warning | Manager, governance | +| C7 | Redemptions waiting for a price | Info; Warning above 25% of supply | Treasury, governance | +| P1 | Vault paused or unpaused | Critical; Warning for an unpause | Governance, guardian | +| P2 | NAV paused or lifted | Critical; Warning for a lift | Governance, guardian, attester | +| P3 | Share-token transfers paused or unpaused | Warning | Governance, compliance | +| P4 | Role granted or revoked | Critical for a grant; Warning for a revoke | Governance, the authority concerned | +| P5 | Admin hand-over | Critical | Governance | +| P6 | Change proposed to the timelock | Critical or Warning, by kind | Every governance signer, guardian, holders | +| P7 | Change applied, cancelled or due | Warning | Governance | +| P8 | Settings that act at once | Warning | Governance | +| P9 | Wind-down | Critical when proposed or activated, else Info | Governance, treasury, holders | +| P10 | Capital sent to the custodian | Warning when unplanned, else Info | Treasury, governance | +| P11 | Compliance acts on a holder | Warning for a freeze; Critical for a legal transfer or recovery | Compliance, governance | +| P12 | The token's rules or verifier change | Critical | Governance, compliance | +| P13 | Wiring drift | Critical | Governance | +| T1 | Instance or code close to archiving | Warning; Critical under two days | On call only | +| T2 | Pending change close to archiving | Warning | On call only | + +## Parameters the thresholds read + +| Parameter | Where to read it | Signals | +| --- | --- | --- | +| `freshness_duration`, `cooldown_secs` | `config()` on the oracle | V1, V3, V5 | +| `max_up_bps`, `max_down_bps` | `config()` on the oracle | V4, V5, V7 | +| `min_answer`, `max_answer` | `config()` on the oracle | V2, V7 | +| The notice | `notice()` on the vault | V3, C5 | +| The upgrade delay | `upgrade_delay()` on the vault | P6, P7 | +| The wind-down delay | `wind_down_delay()` on the vault | P9 | +| How often the attester reports | The operator's schedule, off chain | V1, V5 | +| How long an epoch stays open | The operator's schedule, off chain | C6 | +| How soon the fund's terms pay a priced exit | The fund's terms, off chain | C1 | + +Re-read the on-chain values whenever the oracle publishes `ConfigSet` or the +vault publishes `NoticeSet`, `WindDownDelaySet` or `ChangeApplied`. + +## How events are read and kept + +- **Retention.** An RPC server answers `getEvents` only for recent ledgers. The + default window is 120,960 ledgers, about seven days, and each provider can + set its own. `getHealth` returns `oldestLedger`, `latestLedger` and + `ledgerRetentionWindow`. The public testnet RPC reported 120,960 on + 2026-10-28. +- **Poll inside the window.** By default the watcher polls once a minute. It + keeps the `cursor` each response returns and starts the next request from it. +- **Keep history yourself.** Once a gap is longer than the window, its events + are gone from RPC. Store each event as it is read, or read from an indexer + that keeps full history. After any gap, run every getter check at once: + getters show where things stand, so a missed event still shows in the state + it left. +- **Names on chain.** Each event publishes its name in snake_case as its first + topic: `NavAttested` arrives as `nav_attested`. Filter by contract id and that + topic. [Events the plan reads](#events-the-plan-reads) lists each one. +- **A refused call publishes nothing.** A failed transaction rolls back its + events, so a refusal such as `FeedNotValid` or `ClaimNotCovered` leaves no + event. The plan watches the state that causes a refusal instead. +- **Getters.** Poll them every five minutes by default, and right after any + event from the vault's contracts. + +## Stale valuation + +The oracle holds one report. It stays valid until its `expires_at`, which is at +most `freshness_duration` after the oracle accepted it, while it sits in the +band `[min_answer, max_answer]` and the NAV is not paused. The vault prices an +epoch only from a valid report accepted at or after the epoch was sealed. +Prices are in WAD: `10^18` is one asset base unit per share base unit. + +### V1. Report close to expiry + +- **Source:** `latest()` on the oracle, whose `expires_at` is in seconds. + Compare it with the latest ledger's close time. +- **Threshold:** less than a quarter of `freshness_duration` left, with no newer + `NavAttested`. +- **Severity:** Warning. **Told:** attester. +- **Response:** the attester posts a report (`attest`). If it cannot, prepare + for V2. + +### V2. Feed not valid + +- **Source:** `state()` on the oracle answers `Stale`: no report, an expired + one, or one outside the band. `is_stale()` gives the same answer but ignores + the NAV pause. On the vault, `health().feed_valid` is false. A `Paused` + answer is P2. +- **Threshold:** any time after the first report. Right after a deploy, `Stale` + is normal until the first `attest`. +- **Severity:** Critical. **Told:** attester, governance. +- **Effect:** `fulfill_epoch` refuses with `FeedNotValid` (6044), so nothing is + priced. Requests in sealed epochs can be cancelled again. Priced claims still + pay. `request_deposit` also refuses with `FeedNotValid` while the feed is + stale or the NAV is paused, so no new deposit waits on a stalled feed. +- **Response:** the attester posts a report. If the attester is gone, follow + [When the Manager or the Attester Stops](./kit-spec.md#when-the-manager-or-the-attester-stops). + If the report fell out of a band governance changed, see + [O9](./THREAT_MODEL.md#o9-governance-changes-the-limits-or-the-feed). + Threat: [O4](./THREAT_MODEL.md#o4-the-feed-goes-stale). + +### V3. Reports expire before the notice ends + +- **Source:** `health().freshness_covers_notice` on the vault is false: + `freshness_duration` is shorter than `notice()`. +- **Threshold:** any time. +- **Severity:** Warning. **Told:** governance. +- **Response:** a sealed epoch's cancellation window can reopen on its own + ([O6](./THREAT_MODEL.md#o6-declining-a-price-after-seeing-it)). Governance + proposes a longer `freshness_duration` (a `Config` change) or a shorter + notice (a `Notice` change); both wait for the timelock. The instant changes, + a shorter freshness window or a longer notice, widen the gap. + +### V4. A large step + +- **Source:** `NavAttested` on the oracle, with `nav_per_share`. Compare it with + the report before it, which the watcher keeps; read `latest()` once when the + watcher starts. The step in basis points is `(new - old) * 10,000 / old`. +- **Threshold:** a step that uses 80% or more of its cap: `max_up_bps` for a + rise, `max_down_bps` for a fall. +- **Severity:** Warning. **Told:** attester, governance. +- **Response:** check the report's evidence (`proof_hash`, `proof_uri`) against + the valuation off chain. If the evidence does not back it, handle it as V5. + +### V5. A run of reports one way + +A compromised attester can walk the price in many steps, each inside its cap. + +- **Source:** `NavAttested` on the oracle, as in V4. +- **Threshold:** any of these: + - three reports in a row that move the same way, each using half its cap or + more; + - a total move over 24 hours larger than one full cap; + - more reports in 24 hours than the attester's schedule, plus one. +- **Severity:** Critical. **Told:** guardian, governance, attester. +- **Response:** check each report's evidence. If it backs the moves, this is a + real move larger than a cap + ([O3](./THREAT_MODEL.md#o3-a-real-move-larger-than-a-cap)), and governance + decides whether to pause and clear. If it does not, the guardian pauses the + NAV (`pause_nav`) at once, and governance follows + [Replacing a Compromised Attester](./kit-spec.md#replacing-a-compromised-attester), + the order in + [design doc §8.9](./strata-product-and-architecture.md#89-trust-boundaries--failure-modes). + Threat: [O2](./THREAT_MODEL.md#o2-a-walk-of-reports). + +### V6. The stored report is cleared + +- **Source:** `LatestCleared` on the oracle. Only the oracle's admin calls + `clear_latest`, and only while the NAV is paused. +- **Threshold:** every one. +- **Severity:** Critical. **Told:** governance, guardian. +- **Response:** confirm it is a step of a runbook governance started: + [Replacing a Compromised Attester](./kit-spec.md#replacing-a-compromised-attester) + or + [Upgrading the Oracle from an Earlier Release](./kit-spec.md#upgrading-the-oracle-from-an-earlier-release) + ([O11](./THREAT_MODEL.md#o11-an-oracle-upgraded-in-place-cannot-read-its-old-report)). + If nobody planned it, treat governance's keys as compromised + ([R2](./THREAT_MODEL.md#r2-governance-compromised-the-powers-that-act-at-once)): + the guardian keeps the NAV paused, calling `pause_nav` again if it is lifted, + while the governance signers replace their keys. Then watch V7. Threat: + [O8](./THREAT_MODEL.md#o8-clearing-the-stored-report). + +### V7. The first report after a clear + +- **Source:** the first `NavAttested` after `LatestCleared`. No cap applies to + it, only the band. Compare it with the last report before the clear, which + the watcher keeps. +- **Threshold:** it moves further from that report than `max_up_bps` or + `max_down_bps` would have allowed. +- **Severity:** Critical over the threshold; Info otherwise. **Told:** + governance, guardian, attester. +- **Response:** check its evidence before anyone prices with it. Once an + epoch's notice has run, anyone can call `fulfill_epoch`, and every epoch + sealed before this report is priced from it. If the evidence does not back + it, the guardian pauses the NAV and governance clears it again + ([O8](./THREAT_MODEL.md#o8-clearing-the-stored-report)). + +## Reserve coverage + +The vault's figures, all in asset base units: + +- `liquid_reserve()`: what the vault holds, less `cancellable_escrow()`, the + deposits that can still be cancelled. +- `committed()`: owed to priced redemptions not yet claimed. +- `uncovered()`: `committed()` less `liquid_reserve()`, when that is positive. +- `free_reserve()`: what is left after escrow, `committed()` and + `wind_down_owed()`. It is all treasury can send to the custodian. + +### C1. Priced exits the vault cannot pay + +- **Source:** `uncovered()` on the vault. +- **Threshold:** Warning above zero. Critical when it stays above zero longer + than the fund's terms allow for paying a priced exit (default 72 hours), or + keeps growing while no `Funded` arrives. +- **Told:** treasury; governance too on Critical. +- **Effect:** a claim the liquid reserve cannot cover fails with + `ClaimNotCovered` (6037) and stays owed. Nothing goes to the custodian while + anything is uncovered. +- **Response:** treasury brings capital back from the custodian and calls + `fund`. Threat: + [X1](./THREAT_MODEL.md#x1-the-reserve-cannot-pay-a-priced-exit). +- **In a wind-down:** `uncovered()` leaves out `wind_down_owed()`. Watch + `liquid_reserve() - committed() - wind_down_owed()` instead; below zero is + Critical ([X8](./THREAT_MODEL.md#x8-wind-down)). + +### C2. Not enough cash for the next pricing + +- **Source:** on the vault, the headroom `liquid_reserve() - committed()`, + against what the sealed epochs would commit: for each epoch whose + `get_epoch(id)` status is `Pending`, + `total_shares_redeeming * nav_per_share / 10^18`, with `nav_per_share` from + `latest()` on the oracle. +- **Threshold:** the headroom is smaller than that total. +- **Severity:** Warning. **Told:** treasury. +- **Response:** treasury brings capital back before the epochs are priced, so + C1 does not fire. + +### C3. The vault holds less than its pending escrow + +- **Source:** `liquid_reserve()` on the vault is below zero. This happens only + after cash leaves outside the vault's own calls, such as an issuer clawback + (C4). +- **Threshold:** below zero. +- **Severity:** Critical. **Told:** treasury, governance. +- **Effect:** the escrow depositors can cancel is not all there. `uncovered()` + counts the gap. `request_deposit` refuses with `EscrowNotCovered` (6076) while + `liquid_reserve()` is below zero, so a new deposit never pays an earlier + cancellation, and `cancel_deposit` refuses with `EscrowNotCovered` when the + vault's balance cannot cover that deposit. +- **Response:** treasury calls `fund` with at least `-liquid_reserve()`, plus + `committed()` to pay priced exits too. The refusals lift once the vault is + funded. Threat: + [X10](./THREAT_MODEL.md#x10-the-deposit-assets-issuer-freezes-or-claws-back-the-vaults-cash). + +### C4. The asset's issuer acts on the vault + +- **Source:** events of the deposit asset's contract that name the vault: + `clawback`, and `set_authorized` with `false`, which freezes the vault's + balance. If the event is missed, C3 or C1 still catch a clawback. +- **Threshold:** every one. +- **Severity:** Critical. **Told:** treasury, governance. +- **Response:** after a clawback, fund the gap as in C3. A freeze stops every + cash exit until the issuer lifts it: the vault's checks still pass, and each + transfer fails inside the asset. The kit has no control here + ([X10](./THREAT_MODEL.md#x10-the-deposit-assets-issuer-freezes-or-claws-back-the-vaults-cash)). + +### C5. A sealed epoch is not priced + +- **Source:** epochs with an `EpochClosed` and no `EpochFulfilled` on the vault. + For each, `get_epoch(id)` gives `priceable_at`; compare it with the ledger + time. +- **Threshold:** Warning when `priceable_at` passed more than an hour ago. + Critical after 24 hours. +- **Told:** attester, manager. +- **Response:** anyone can call `fulfill_epoch(id)`, and the operator runs a + keeper that does. If it refuses: `FeedNotValid` is V2 or P2, or, while + `health().feed_valid` is true, a feed that fails to answer (O9); + `InvalidSharePrice` is a feed that prices at zero or less (O9); + `AttestationBeforeClose` means no report was accepted since the seal, so the + attester posts one; `EnforcedPause` is P1. + +### C6. The open epoch runs long + +- **Source:** the ledger time against the last `EpochClosed`, or against + `closed_at` from `get_epoch` for the epoch before `current_epoch()`. +- **Threshold:** one and a half times the epoch length the operator runs. +- **Severity:** Warning. **Told:** manager, governance. +- **Response:** the manager calls `close_epoch`. Once the open epoch has run + the minimum epoch duration (`min_epoch_duration()`), anyone may call it + instead. If the manager is gone and no minimum is set, follow + [When the Manager or the Attester Stops](./kit-spec.md#when-the-manager-or-the-attester-stops). + Threat: + [R8](./THREAT_MODEL.md#r8-the-manager-stops-sealing-or-picks-the-moment). + +### C7. Redemptions waiting for a price + +- **Source:** on the vault, `total_shares_redeeming` from `get_epoch` for the + open epoch and each `Pending` one, against `total_economic_supply()`. +- **Threshold:** Info above 10% of the supply; Warning above 25%. +- **Told:** treasury, governance. +- **Response:** a large exit is coming. Treasury plans the cash (C2). + `committed()` and `pending_burn_shares()` show priced exits not yet claimed; + holders claim when they like, so those figures alone need no action unless + C1 fires. + +## Privileged actions + +Each privileged call on the vault, the share token, the oracle and the +compliance contract publishes an event. After each contract's setup, role +grants, upgrades, admin hand-overs and most settings first wait in the +timelock queue +([R1](./THREAT_MODEL.md#r1-governance-compromised-the-changes-that-wait)), so a +`ChangeProposed` comes at least the upgrade delay before the change lands. +[R2](./THREAT_MODEL.md#r2-governance-compromised-the-powers-that-act-at-once) +lists the powers that act at once. + +### P1. Vault paused or unpaused + +- **Source:** `Paused` and `Unpaused` on the vault; `paused()` on the vault. +- **Threshold:** every one. +- **Severity:** Critical for a pause; Warning for an unpause. **Told:** + governance, guardian. +- **Effect:** while paused, the vault takes no deposit request, prices nothing + and applies no queued change, and each queued change waits longer by the + time paused. Claims, cancellations and redemption requests go on. +- **Response:** the guardian confirms it paused on purpose, and governance lifts + the pause with `unpause` once the cause is fixed. A pause the guardian's + signers did not agree to means its keys are compromised: governance revokes + the guardian (`revoke_role`, instant). Threat: + [R6](./THREAT_MODEL.md#r6-guardian-compromised). + +### P2. NAV paused or lifted + +- **Source:** `NavPausedSet` on the oracle, with `paused` true or false; + `state()` answers `Paused`. +- **Threshold:** every one. +- **Severity:** Critical for a pause; Warning for a lift. **Told:** governance, + guardian, attester. +- **Effect:** while paused, no report lands and nothing is priced, and sealed + epochs reopen to cancellation. `request_deposit` refuses with `FeedNotValid`. +- **Response:** confirm a runbook is in progress (see V6). Only governance lifts + the pause (`set_nav_paused(false)`). For a pause nobody planned, governance + revokes the oracle's guardian (instant) and lifts the pause + ([O7](./THREAT_MODEL.md#o7-the-nav-pause-used-to-stall-pricing)). For a lift + nobody planned, treat governance's keys as compromised, and the guardian + pauses again. + +### P3. Share-token transfers paused or unpaused + +- **Source:** `Paused` and `Unpaused` on the share token; `paused()` on the + share token. +- **Threshold:** every one. +- **Severity:** Warning. **Told:** governance, compliance. +- **Effect:** stops `transfer` and `transfer_from` between holders only. + Deposits, exits, escrow and claims go on. +- **Response:** only governance, the token's admin, can do this. If governance + did not, treat its keys as compromised + ([R2](./THREAT_MODEL.md#r2-governance-compromised-the-powers-that-act-at-once)). + +### P4. Role granted or revoked + +- **Source:** `RoleGranted` and `RoleRevoked` on the vault, the share token and + the oracle, with the role and the account as topics. Role holders: + `manager()`, `treasury()` and `guardian()` on the vault, and `has_role`, + `get_role_member_count` and `get_role_member` on each contract. +- **Threshold:** every one. +- **Severity:** Critical for a grant; Warning for a revoke. **Told:** + governance, and the authority whose role changed. +- **Response:** after setup, a grant lands only through a `GrantRole` change + proposed at least the upgrade delay earlier (P6); check the account against + that proposal. A second `manager` on the share token also turns + `health().sole_token_manager` false (P13). Revoking the vault's treasury or + guardian, the oracle's attester or guardian, or the token's compliance role + is instant: confirm governance meant it. Threats: + [R1](./THREAT_MODEL.md#r1-governance-compromised-the-changes-that-wait), + [R5](./THREAT_MODEL.md#r5-one-account-in-several-seats). + +### P5. Admin hand-over + +- **Source:** `AdminTransferInitiated` and `AdminTransferCompleted` on the + vault, the share token, the oracle and the compliance contract; `get_admin()` + on each, and `governance()` on the vault. +- **Threshold:** every one. +- **Severity:** Critical. **Told:** governance. +- **Response:** after setup, the offer comes from an applied `Admin` change + (P7). Confirm the new admin is the account governance chose. + +### P6. Change proposed to the timelock + +- **Source:** `ChangeProposed`, with the change and its `eta`, on the vault, + the share token, the oracle and the compliance contract. The event's `eta` + is the one `pending_changes()` reports at that moment. Every second the + vault spends paused afterwards, a pause already running included, moves the + real one later, so P7 reads the `eta` from `pending_changes()`, which lists + what waits on each contract. +- **Threshold:** every one. +- **Severity:** Critical for `Upgrade`, `Admin`, `GrantRole`, `RevokeRole`, + the vault's `Oracle` and `Custodian`, the token's `Compliance` and + `IdentityVerifier`, and every change on the compliance contract. Warning for + the rest: `UpgradeDelay`, `Notice`, `WindDownDelay` and the oracle's + `Config`. +- **Told:** every governance signer, the guardian, and holders, since the delay + is their notice. +- **Response:** every governance signer checks it against what they agreed, and + governance cancels one it did not agree to (`cancel_change`). If governance's + quorum is compromised, a vault pause stops the timelock clock and blocks + applying, but governance can lift it, so it only buys time + ([R1](./THREAT_MODEL.md#r1-governance-compromised-the-changes-that-wait)). + +### P7. Change applied, cancelled or due + +- **Source:** `ChangeApplied` and `ChangeCancelled` on the four contracts; the + `eta` of each entry in `pending_changes()`. +- **Threshold:** every applied or cancelled change, and a pending change within + 24 hours of its `eta`. +- **Severity:** Warning. **Told:** governance. +- **Response:** before an `eta`, confirm the change is still wanted. After an + apply, re-read the [parameters](#parameters-the-thresholds-read); after a + `Custodian` change, treasury confirms the new address. + +### P8. Settings that act at once + +- **Source:** `DepositCapUpdated`, `NoticeSet`, `WindDownDelaySet` and + `CustodianSet` on the vault; `ConfigSet` on the oracle. After setup, the + instant ones are the deposit cap either way, a longer notice or wind-down + delay, and a shorter `freshness_duration`; the rest come from applied changes + (P7). +- **Threshold:** every one. +- **Severity:** Warning. **Told:** governance. +- **Response:** confirm governance meant it, and re-read the parameters. A + longer notice delays pricing of every epoch sealed after it, and can make V3 + fire. + +### P9. Wind-down + +- **Source:** `WindDownProposed`, with `active_at`, `WindDownProposalCancelled`, + `WindDownActivated` and `WindDownRoundFinalized` on the vault; `wind_down()` + on the vault. +- **Threshold:** every one. +- **Severity:** Critical when proposed or activated; Info otherwise. **Told:** + governance, treasury, holders. +- **Effect:** while the wind-down is proposed, `request_deposit` refuses with + `WindDownAlreadyProposed` (6048); once it is active, with `WindDownActive` + (6046). +- **Response:** once `active_at` passes, anyone can activate it; until then + governance can cancel it. Once active it cannot be undone, nothing is priced, + and holders exit through rounds paid from the free reserve. Treasury brings + capital back through `fund` so the rounds have cash. Threat: + [X8](./THREAT_MODEL.md#x8-wind-down). + +### P10. Capital sent to the custodian + +- **Source:** `Deployed` and `Funded` on the vault; `net_deployed()` on the + vault. +- **Threshold:** a `Deployed` that is not on treasury's plan. +- **Severity:** Warning when unplanned; Info otherwise. **Told:** treasury, + governance. +- **Response:** `deploy_to_custodian` moves only free reserve, and only to the + stored custodian. An unplanned one means treasury's keys may be compromised: + governance revokes the treasury (instant). Threat: + [R7](./THREAT_MODEL.md#r7-treasury-compromised). + +### P11. Compliance acts on a holder + +- **Source:** `AddressFrozen`, `TokensFrozen`, `TokensUnfrozen`, + `LegalTransfer` and `RecoverySuccess` on the share token. +- **Threshold:** every one. +- **Severity:** Warning for a freeze or an unfreeze; Critical for a legal + transfer or a recovery. **Told:** compliance, governance. +- **Response:** match each one to a case in compliance's records. A legal + transfer or a recovery carries both compliance's and governance's signatures, + so both confirm the case. Threats: + [R9](./THREAT_MODEL.md#r9-compliance-compromised), + [R10](./THREAT_MODEL.md#r10-compliance-and-governance-move-a-holders-shares-together). + +### P12. The token's rules or verifier change + +- **Source:** `ComplianceSet` and `IdentityVerifierSet` on the share token; + `ModuleAdded`, `ModuleRemoved`, `TokenBound` and `TokenUnbound` on the kit's + compliance contract. +- **Threshold:** every one. +- **Severity:** Critical. **Told:** governance, compliance. +- **Response:** after setup these come from applied changes (P7). A module that + refuses can block exits, and unbinding the token stops every share move. + Threats: [X6](./THREAT_MODEL.md#x6-a-compliance-module-blocks-exits), + [R11](./THREAT_MODEL.md#r11-swapping-the-tokens-compliance-contract-or-verifier). + +### P13. Wiring drift + +- **Source:** `health()` on the vault. On a live vault, `setup_open` is false + and every other flag is true. `feed_valid` and `freshness_covers_notice` are + V2 and V3. +- **Threshold:** any other flag false, or `setup_open` true on a vault meant to + be live. +- **Severity:** Critical. **Told:** governance. +- **Response:** [Health View](./kit-spec.md#health-view) says what each flag + means. `sole_token_manager` false means another account can mint shares + ([R1](./THREAT_MODEL.md#r1-governance-compromised-the-changes-that-wait)). + `allowlisted` false holds delisted holders' conversions until the vault is + listed again. Threat: + [R3](./THREAT_MODEL.md#r3-setup-left-open-or-wired-wrong). + +## Storage lifetime + +An archived contract instance refuses every call, so an archived vault instance +stops the vault. Each kit call renews what it touches, but an idle deployment +can still archive +([X11](./THREAT_MODEL.md#x11-storage-that-expires), +[kit spec §5](./kit-spec.md#5-storage-lifetime-ttl-policy)). + +### T1. Instance or code close to archiving + +- **Source:** RPC `getLedgerEntries` for each contract's instance and each Wasm + it runs, the deposit asset's contract included: compare `liveUntilLedgerSeq` + with the latest ledger. `stellar ledger entry fetch` reads the same value. +- **Threshold:** Warning when fewer than 7 days (120,960 ledgers) remain. + Critical under 2 days (34,560 ledgers), or once archived. +- **Told:** on call. +- **Response:** run + `STELLAR_ACCOUNT= npm run extend -w scripts/harness`. It + restores what has archived and extends each instance, its Wasm and each + pending change by 30 days. Any funded account can pay; it needs no authority. + +### T2. Pending change close to archiving + +- **Source:** the ids in `pending_changes()` on the vault, the share token, the + oracle and the compliance contract; each one's entry through + `getLedgerEntries`, with the key `npm run extend` builds. +- **Threshold:** as in T1. +- **Severity:** Warning. **Told:** on call. +- **Response:** `npm run extend`, as in T1. Nothing is lost if it archives: the + call that applies it restores it and pays for the restore. + +Balances, roles and other entries are restored by the call that needs them, so +the plan does not watch them. A production compliance module or identity +registry must extend its own entries. + +## Events the plan reads + +Event names are as in the code; the topic is what `getEvents` filters on. +`Paused`, `Unpaused`, the role and admin events, and the freeze, recovery, +rules and module events come from OpenZeppelin's Stellar libraries. + +| Event | Topic | Contract | Signals | +| --- | --- | --- | --- | +| `NavAttested` | `nav_attested` | Oracle | V4, V5, V7 | +| `LatestCleared` | `latest_cleared` | Oracle | V6, V7 | +| `NavPausedSet` | `nav_paused_set` | Oracle | P2 | +| `ConfigSet` | `config_set` | Oracle | P8 | +| `EpochClosed`, `EpochFulfilled` | `epoch_closed`, `epoch_fulfilled` | Vault | C5, C6 | +| `Deployed`, `Funded` | `deployed`, `funded` | Vault | C1, P10 | +| `Paused`, `Unpaused` | `paused`, `unpaused` | Vault, share token | P1, P3 | +| `RoleGranted`, `RoleRevoked` | `role_granted`, `role_revoked` | Vault, share token, oracle | P4 | +| `AdminTransferInitiated`, `AdminTransferCompleted` | `admin_transfer_initiated`, `admin_transfer_completed` | Vault, share token, oracle, compliance | P5 | +| `ChangeProposed`, `ChangeApplied`, `ChangeCancelled` | `change_proposed`, `change_applied`, `change_cancelled` | Vault, share token, oracle, compliance | P6, P7 | +| `DepositCapUpdated`, `NoticeSet`, `WindDownDelaySet`, `CustodianSet` | `deposit_cap_updated`, `notice_set`, `wind_down_delay_set`, `custodian_set` | Vault | P8 | +| `WindDownProposed`, `WindDownProposalCancelled`, `WindDownActivated`, `WindDownRoundFinalized` | `wind_down_proposed`, `wind_down_proposal_cancelled`, `wind_down_activated`, `wind_down_round_finalized` | Vault | P9 | +| `AddressFrozen`, `TokensFrozen`, `TokensUnfrozen`, `LegalTransfer`, `RecoverySuccess` | `address_frozen`, `tokens_frozen`, `tokens_unfrozen`, `legal_transfer`, `recovery_success` | Share token | P11 | +| `ComplianceSet`, `IdentityVerifierSet` | `compliance_set`, `identity_verifier_set` | Share token | P12 | +| `ModuleAdded`, `ModuleRemoved`, `TokenBound`, `TokenUnbound` | `module_added`, `module_removed`, `token_bound`, `token_unbound` | Compliance | P12 | +| `clawback`, `set_authorized` | `clawback`, `set_authorized` | Deposit asset | C4 | + +## What this plan does not cover + +- **Running it.** The kit has no watcher and sends no alert. +- **The evidence behind a report.** The plan says when to check a report's + `proof_hash` and `proof_uri`. Checking them against the valuation happens off + chain ([O10](./THREAT_MODEL.md#o10-the-proof-proves-nothing-on-chain)). +- **The authorities' accounts.** A change to an authority's signers or + thresholds is a Stellar account operation, not a contract event + ([R4](./THREAT_MODEL.md#r4-an-authoritys-keys)). Watch each authority's + account through an account-history service. +- **The allowlist.** The identity verifier in this repository is a testnet stub + and publishes no event when `allow` lists or delists an address. A production + identity registry has its own events. +- **External contracts.** An external compliance contract or identity registry + keeps its own governance and storage. +- **The custodian and the issuer.** What the custodian holds, and what the + deposit asset's issuer does beyond C4. +- **The RPC and the frontend.** Whether the RPC is up and serves its full + window, and who hosts the frontend. +- **Tuned values.** The defaults have not been tried on a live mainnet vault. + +## From plan to alerts + +1. Write each threshold for the deployment, from + [the parameters](#parameters-the-thresholds-read), into the watcher's + settings. +2. Run a watcher that polls `getEvents` with a cursor and the getters, inside + the RPC's retention window, and stores what it reads. +3. Route each severity: Critical pages on call and the named signers, Warning + goes to a shared channel, Info goes to a log. +4. On a testnet deployment, make each signal fire, for example by pausing the + NAV or posting a report near its cap, and check that the alert arrives. +5. Push alerts to operators are planned after v1 + ([design doc §11](./strata-product-and-architecture.md#11-potential-future-features)). diff --git a/docs/TESTING.md b/docs/TESTING.md index 219e4951..510a1bd4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -7,38 +7,87 @@ ledger and event rules are the real ones, not mocks. ## Running them ```sh -SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1 nix develop -c cargo test +nix develop -c cargo test ``` -The variable is needed whenever `cargo` runs directly (see -[AGENTS.md](../AGENTS.md)). The tests that upgrade the vault to a real WASM -build run only with `--features upgrade_wasm`, after the fixture in -[`fixtures/upgrade-target`](../fixtures/upgrade-target) is built. +`cargo test` and `cargo clippy` need no extra variable. +`SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1` matters only when `cargo` +itself builds a contract for the Wasm target (see [AGENTS.md](../AGENTS.md)). +The tests that upgrade the vault, the share token and the oracle to a real WASM +build run only with `--features upgrade_wasm`, after five builds: the share +token, the oracle, and a probe build of the token, of the oracle and of the +vault, each a stand-in for its next version. Inside `nix develop`: + +```sh +stellar contract build --package share-token +stellar contract build --package nav-oracle +CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package share-token --features upgrade_probe +CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package nav-oracle --features upgrade_probe +CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package async-vault --features upgrade_probe +cargo test -p async-vault -p nav-oracle --features upgrade_wasm with_wasm +``` + +CI runs the same command and then `scripts/ci/check-tests-ran.sh`, which fails +the step unless both `with_wasm` modules ran a passing test, because a filter +that matches nothing makes `cargo test` pass. + +The `short_wind_down` feature builds a vault whose wind-down minimum is zero, +for a local harness only; its WASM carries the meta entry `build = short +wind-down; local tests only`. Check that it compiles with +`cargo check -p async-vault --features short_wind_down`. Do not run the test +suite under it: tests that subtract from the minimum assume seven days. + +CI also runs three checks that fail on any finding. Run them before pushing, +inside `nix develop`: + +```sh +cargo fmt --all -- --check +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo deny --locked check +``` + +`deny.toml` lists the allowed licences and every ignored advisory with its +reason. ## Storage and TTL The contracts read and write through the shared helpers in -[`crates/storage`](../crates/storage/src/lib.rs). A write extends the entry to +[`crates/strata-storage`](../crates/strata-storage/src/lib.rs). A write extends the entry to 30 days; a read that finds the entry extends it again once it has fallen more -than a day short. The instance follows the same rule. +than a day short. The instance follows the same rule. The +[TTL policy](./kit-spec.md#5-storage-lifetime-ttl-policy) says what each +contract extends, who pays, and how to restore an archived one. | Behaviour | Tests | Key test names | | --- | --- | --- | -| Persistent entries round-trip and get the full TTL on write | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` | -| A read restores a decayed entry; a missing entry is not created | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` | -| Instance writes and reads extend the contract instance | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` | +| Persistent entries round-trip and get the full TTL on write | [`crates/strata-storage/src/test.rs`](../crates/strata-storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` | +| A read restores a decayed entry; a missing entry is not created | [`crates/strata-storage/src/test.rs`](../crates/strata-storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` | +| Instance writes and reads extend the contract instance | [`crates/strata-storage/src/test.rs`](../crates/strata-storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` | +| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs), [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `every_admin_call_restores_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | | Vault requests and epochs are written with the full TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_request_and_its_epoch_are_written_with_the_full_ttl`, `a_redemption_request_is_written_with_the_full_ttl` | | Reading a vault request, epoch or the instance restores its TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `reading_a_decayed_request_restores_its_full_ttl`, `reading_a_decayed_epoch_restores_its_full_ttl`, `any_vault_read_restores_the_instance_ttl` | | A request still claims, and a wind-down position still pays, after most of a month | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_pending_request_still_claims_after_most_of_a_month`, `a_position_survives_the_ledger_advancing` | -| The oracle's latest valuation keeps a full TTL while it is read | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `the_latest_valuation_keeps_a_full_ttl_while_it_is_read` | +| The oracle's latest valuation keeps a full TTL while it is read | [`contracts/nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs) | `the_latest_valuation_keeps_a_full_ttl_while_it_is_read` | +| The oracle's setup, grant, hand-over and change calls extend its instance | [`contracts/nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs) | `setup_grants_hand_overs_and_changes_restore_the_instance_ttl` | +| Every call that writes a share balance or a freeze gives a new one its full TTL, under testnet's minimum lifetime; so do a deposit claim and the first redemption, through the vault | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_mint_writes_the_new_balance_with_the_full_ttl`, `an_escrow_writes_the_receiving_balance_with_the_full_ttl`, `a_recovery_writes_the_new_balance_with_the_full_ttl`, `sending_nothing_from_an_empty_account_writes_its_balance_with_the_full_ttl`, `a_partial_freeze_writes_its_entry_with_the_full_ttl`, `the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl` | +| Every role grant in the vault, the token and the oracle gives the role's entries their full TTL: in the constructor, during setup and through the timelock | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs) | `the_constructor_writes_each_role_with_the_full_ttl`, `a_grant_during_setup_writes_the_role_with_the_full_ttl`, `a_grant_through_the_timelock_writes_the_role_with_the_full_ttl` | +| Binding a token or adding a module gives each new compliance entry its full TTL, under testnet's minimum lifetime: during setup and through the timelock | [`contracts/compliance/src/test/ttl.rs`](../contracts/compliance/src/test/ttl.rs) | `a_binding_during_setup_writes_the_bound_tokens_with_the_full_ttl`, `a_binding_through_the_timelock_writes_the_bound_tokens_with_the_full_ttl`, `the_binding_that_opens_a_second_bucket_writes_it_with_the_full_ttl`, `a_module_added_during_setup_writes_the_hooks_modules_with_the_full_ttl`, `a_module_added_through_the_timelock_writes_the_hooks_modules_with_the_full_ttl` | +| Under mainnet's longer minimum a new entry starts at that minimum; a balance is first renewed on day 91, a role on day 31 | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs) | `under_mainnets_minimum_a_new_balance_is_first_renewed_on_day_91`, `under_mainnets_minimum_a_new_role_is_first_renewed_on_day_31` | | An epoch that was never written reads as nothing | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs) | `unwritten_epoch_reads_as_none`, `epoch_zero_is_the_absent_id` | The test `Env` does not archive an entry whose TTL runs out; it still reads it. So these tests check the TTL numbers the contracts set, not what the network does once an entry is archived. -The share token's balances and roles are stored by the OpenZeppelin Stellar -library, which manages their TTL itself. +The share token's balances and freezes, the compliance stub's modules and bound +tokens, and the roles of the vault, the token and the oracle, are stored by the +OpenZeppelin Stellar library. It extends an entry when it reads it, not when it +creates it, so the contracts read back each one they create. Without that, a +new entry would start at the network's minimum lifetime. The tests for it set +the ledger's minimum to testnet's 120,960 ledgers; the test `Env` defaults to +4,096. OpenZeppelin never extends the instance, so the token, the identity +verifier and the compliance contract extend their own on every call that does +work. ## Authorization @@ -51,22 +100,32 @@ membership is the contract's real state, not a mock. Most of these tests run with `mock_all_auths`, so every signature is granted and a refusal can only come from the role check. -**Whether a signature is required.** A refusal test clears all signatures with -`set_auths(&[])` and checks the call fails and changes nothing, which proves the -entrypoint requires the right address to sign. +**Which signatures a call needs.** A refusal test signs the call as another +account only (`mock_auths`), or clears all signatures with `set_auths(&[])`, +and checks the call fails and changes nothing, which proves the entrypoint +requires the right address to sign. A signature test reads `e.auths()` right +after a call and expects one entry per account the call needs, on that call +only. The per-entrypoint role matrix, with the refusal test for each, is in [AUTHORIZATION_MATRIX.md](./AUTHORIZATION_MATRIX.md). | Behaviour | Tests | Key test names | | --- | --- | --- | +| Every privileged call asks each account it needs for one signature: an admin call asks the admin once, a role call asks the role holder once, and `legal_transfer` and `recover_balance` ask compliance and the admin once each. `cancel_deposit`, `cancel_redeem`, `claim_redeem` and `claim_wind_down` each refuse an unsigned call and record the holder's signature on a normal one | [`contracts/async-vault/src/test/signatures.rs`](../contracts/async-vault/src/test/signatures.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs), [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs) | `each_setup_call_asks_the_admin_for_one_signature`, `each_admin_call_after_setup_asks_the_admin_for_one_signature`, `each_admin_call_asks_the_admin_for_one_signature`, `each_role_call_asks_its_holder_for_one_signature`, `a_legal_transfer_or_a_recovery_asks_each_signer_once`, `a_hand_over_asks_each_side_for_one_signature`, `each_hook_asks_the_bound_token_for_one_signature`, `cancel_deposit_asks_the_holder_for_one_signature`, `cancel_redeem_asks_the_holder_for_one_signature`, `claim_redeem_asks_the_holder_for_one_signature`, `claim_wind_down_asks_the_holder_for_one_signature` | +| An account other than the admin cannot pause, unpause or swap the token's rules or verifier, or unpause the vault, even when it signs | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `a_caller_other_than_the_admin_cannot_unpause_even_when_it_signs`, `a_caller_other_than_the_admin_is_refused_even_when_it_signs` | | A deposit is one investor signature that covers the vault call and the asset transfer inside it | [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs) | `one_investor_signature_covers_the_request_and_the_transfer` | | Investor calls refuse without the investor's signature | [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `request_deposit_needs_the_investor_authorisation`, `claiming_needs_the_investor_authorisation`, `request_redeem_needs_the_holder_authorisation` | | Role calls refuse without the role holder's signature | [`contracts/async-vault/src/test/epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`contracts/async-vault/src/test/notice.rs`](../contracts/async-vault/src/test/notice.rs), [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`contracts/async-vault/src/test/deposit_cap.rs`](../contracts/async-vault/src/test/deposit_cap.rs) | `closing_needs_the_manager_authorisation`, `setting_the_notice_needs_governance_authorisation`, `setting_the_custodian_needs_the_admin_signature`, `unauthorized_caller_rejected` | | Guardian, governance and upgrade powers stay with their role | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs), [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `the_guardian_pauses_but_only_governance_unpauses`, `only_governance_proposes_and_cancels`, `only_governance_applies`, `only_governance_sets_the_delay` | | Roles that must stay separate are refused at deployment | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs) | `the_treasury_may_not_also_be_the_guardian`, `compliance_may_not_also_be_governance` | | Share token: only the right role mints, burns, pauses, freezes or moves shares | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `unauthorized_caller_cannot_mint`, `unauthorized_caller_cannot_burn`, `unauthorized_caller_cannot_forced_transfer`, `unauthorized_caller_cannot_freeze_or_recover` | -| Oracle: only the attester posts, only the guardian raises the ripcord | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attest_is_role_gated`, `raising_the_ripcord_is_limited_to_the_guardian`, `only_admin_can_set_config` | +| Share token: compliance moves a holder's shares only with the admin's signature in the same call, never out of or into the vault's address, by order or by transfer, and cannot freeze the vault | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `compliance_alone_cannot_move_shares`, `one_account_cannot_co_sign_with_itself`, `nothing_leaves_the_vault_address`, `a_transfer_into_the_vault_is_refused`, `recovery_needs_the_admin_too`, `the_vault_address_cannot_be_frozen` | +| Share token: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` | +| Oracle: only the attester posts, only the guardian pauses the NAV | [`contracts/nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs) | `attest_is_role_gated`, `pausing_the_nav_is_limited_to_the_guardian`, `only_admin_can_set_config` | +| Oracle: only the admin binds the vault, finishes setup, runs the timelock and revokes a role | [`contracts/nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies`, `only_the_admin_revokes_a_role` | +| Compliance contract: only the admin binds the vault, finishes setup, runs the timelock and offers or cancels a hand-over; only the nominee accepts it | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies`, `only_the_admin_offers_or_cancels_a_hand_over`, `only_the_nominee_accepts_a_hand_over`, `an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down` | +| Vault: only the admin finishes setup; after it, the custodian, grants, `manager` revokes, hand-overs and a lower notice or wind-down delay go through the timelock, while revoking the treasury or the guardian stays instant | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `only_governance_finishes_setup`, `the_custodian_changes_through_the_timelock`, `grants_and_manager_revokes_wait_for_the_timelock`, `revoking_treasury_or_guardian_stays_instant`, `only_the_admin_revokes_a_role`, `an_admin_hand_over_cannot_be_accepted_during_a_wind_down`, `cancelling_a_hand_over_stays_open_during_a_wind_down` | | Identity verifier: only the admin edits the allowlist | [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs) | `only_admin_can_allow` | ## Ledger time and sequence @@ -77,29 +136,91 @@ so time rules and TTL can be reached in one test. | Behaviour | Tests | Key test names | | --- | --- | --- | | The test clock moves time and sequence | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs) | `the_clock_helper_moves_time_and_sequence` | -| Closing records the ledger time; pricing waits for the notice period | [`contracts/async-vault/src/test/notice.rs`](../contracts/async-vault/src/test/notice.rs) | `closing_records_the_moment_and_the_wait`, `an_epoch_cannot_be_priced_inside_its_notice`, `it_is_priced_once_the_notice_elapses` | +| Closing records the ledger time; pricing waits for the notice period | [`contracts/async-vault/src/test/notice.rs`](../contracts/async-vault/src/test/notice.rs) | `closing_records_the_moment_and_the_wait`, `an_epoch_cannot_be_priced_inside_its_notice`, `it_is_priced_once_the_notice_elapses`, `an_epoch_whose_notice_would_end_past_the_clock_stays_open` | +| The manager seals at any time; anyone else once the open epoch has run the minimum epoch duration, counted from the end of setup or the last seal; the minimum stays within 1 to 30 days and, after setup, changes only through the timelock | [`contracts/async-vault/src/test/sealing.rs`](../contracts/async-vault/src/test/sealing.rs) | `an_outsider_seals_once_the_minimum_has_run`, `an_outsider_cannot_seal_before_the_minimum`, `the_manager_seals_before_the_minimum`, `a_raised_minimum_counts_from_the_epochs_start`, `an_upgraded_vault_lets_anyone_seal_a_minimum_after_it_lands` | +| A sealed epoch is never sealed again: pointed back at it, `close_epoch` refuses with `EpochNotOpen` and leaves its record as it was | [`contracts/async-vault/src/test/epochs.rs`](../contracts/async-vault/src/test/epochs.rs) | `a_sealed_epoch_cannot_be_closed_again` | | A valuation must be newer than the epoch close | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `an_epoch_is_not_priced_against_a_valuation_older_than_its_close`, `a_valuation_taken_in_the_closing_ledger_prices_the_epoch` | -| Oracle freshness, cooldown and its own timestamp | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `heartbeat_expiry_makes_it_stale`, `attest_enforces_cooldown_and_deviation`, `attest_stamps_its_own_timestamp` | -| Upgrade and wind-down timelocks follow the ledger clock | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `applying_before_the_eta_is_refused`, `a_pause_freezes_the_upgrade_timelock_clock`, `governance_proposes_and_anyone_activates_after_the_delay` | -| Timing rules hold for any clock values (property tests) | [`contracts/async-vault/src/test/timing.rs`](../contracts/async-vault/src/test/timing.rs) | `standard_priceable_at_never_precedes_the_close`, `a_notice_that_would_overflow_saturates` | +| Oracle freshness, cooldown and its own timestamp | [`contracts/nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs) | `heartbeat_expiry_makes_it_stale`, `attest_enforces_cooldown_and_deviation`, `attest_stamps_its_own_timestamp`, `a_reporter_expiry_past_the_window_is_cut_to_the_window`, `two_reports_in_one_ledger_are_refused_at_the_shortest_cooldown` | +| Upgrade and wind-down timelocks follow the ledger clock | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `applying_before_the_eta_is_refused`, `a_pause_freezes_the_upgrade_timelock_clock`, `governance_proposes_and_anyone_activates_after_the_delay`, `a_proposals_eta_matches_the_pending_change_until_a_pause_moves_it` | +| The share token's changes wait on the vault's clock: a vault pause moves the eta, a wind-down refuses them, except a `compliance` grant; the last `compliance` holder cannot renounce, and a frozen holder is paid once a new one lifts the freeze | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_grant_lands_one_vault_delay_after_it_was_proposed`, `a_vault_pause_moves_the_eta_and_holds_the_change`, `nothing_is_proposed_or_applied_while_the_vault_winds_down`, `an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down`, `a_compliance_grant_lands_through_the_timelock_during_a_wind_down`, `a_compliance_grant_proposed_before_the_wind_down_lands_during_it`, `a_paused_vault_still_holds_a_compliance_grant_in_a_wind_down`, `governance_still_revokes_the_last_compliance_holder_at_once`, `the_last_compliance_holder_cannot_renounce`, `a_frozen_holder_is_paid_once_a_compliance_holder_granted_in_the_wind_down_lifts_the_freeze`, `the_proposal_event_carries_the_eta_pending_changes_reports_then` | +| The oracle's changes wait on the vault's clock the same way | [`contracts/nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs) | `a_loosened_config_lands_through_the_timelock`, `a_vault_pause_moves_the_eta_and_holds_the_change`, `nothing_is_proposed_or_applied_while_the_vault_winds_down`, `an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down` | +| So do the compliance contract's module, token-binding and admin changes, once its setup is finished | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `after_setup_rule_changes_wait_for_the_timelock`, `a_proposed_binding_lands_after_the_delay`, `a_vault_pause_pushes_the_change_back`, `nothing_lands_while_the_vault_is_paused_or_winding_down`, `an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down` | +| The vault's own changes wait on its clock: a raised notice blocks a shorter change, a pause holds one, a wind-down refuses them, and a wind-down waits at least seven days | [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_change_shorter_than_a_raised_notice_never_lands`, `a_pending_role_change_lands_after_a_pause`, `nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included`, `governance_proposes_and_anyone_activates_after_the_delay`, `an_unset_delay_still_waits_the_minimum`, `an_admin_hand_over_cannot_be_accepted_during_a_wind_down` | +| Timing rules hold for any clock values (property tests) | [`contracts/async-vault/src/test/timing.rs`](../contracts/async-vault/src/test/timing.rs) | `standard_priceable_at_never_precedes_the_close`, `a_notice_that_would_overflow_has_no_end` | ## Events | Behaviour | Tests | Key test names | | --- | --- | --- | -| The vault publishes each step of a deposit with its amounts | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `requesting_a_deposit_publishes_the_request`, `closing_an_epoch_publishes_its_totals`, `pricing_an_epoch_publishes_the_share_price`, `claiming_a_deposit_publishes_the_shares_paid` | +| The vault publishes each step of a deposit with its amounts, a cancellation of either side, and a refund for a deposit priced at zero shares | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `requesting_a_deposit_publishes_the_request`, `closing_an_epoch_publishes_its_totals`, `pricing_an_epoch_publishes_the_share_price`, `claiming_the_last_deposit_publishes_the_shares_paid_and_the_settlement`, `cancelling_a_deposit_publishes_the_cancellation`, `a_deposit_priced_at_zero_shares_publishes_a_refund_not_a_cancellation`, `cancelling_a_redemption_publishes_the_cancellation` | | A refused call publishes nothing | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `a_refused_call_publishes_nothing` | -| The oracle publishes the valuation it stored | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | +| An epoch's rounding remainder is cleared once its last request settles, a settled epoch leaves nothing in the totals whatever the claim order (property test), and a request that can never settle (a refused redemption or a refused mint) keeps its epoch open | [`contracts/async-vault/src/test/settlement.rs`](../contracts/async-vault/src/test/settlement.rs) | `the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once`, `an_epoch_with_no_requests_settles_when_it_is_priced`, `a_settled_epoch_leaves_no_remainder_in_the_totals`, `a_request_that_cannot_settle_keeps_its_epoch_open`, `a_refused_mint_keeps_a_priced_deposit_epoch_open` | +| The oracle publishes the valuation it stored, with its proof reference | [`contracts/nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs) | `attesting_publishes_the_stored_valuation`, `a_proof_uri_at_the_limit_is_stored_and_published` | +| The vault publishes the end of its setup, and an applied change publishes its own event before `ChangeApplied` | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `finishing_setup_publishes_it`, `applying_a_custodian_change_publishes_both_events` | ## Calls between contracts The vault tests deploy the real share token, identity verifier, compliance -contract and oracle, plus a Stellar Asset Contract for the deposit asset. +contract and oracle, plus a Stellar Asset Contract for the deposit asset. The +fixture wires them as the deploy does, finishes the vault's setup, then posts +an opening report and moves the clock one second on, so deposits are taken and +the next sealed epoch is not priced by that report. `setup_unfinished()` and +`miswired(…)` leave the setup open. | Behaviour | Tests | Key test names | | --- | --- | --- | -| Deposit asset (Stellar Asset Contract) moves in and out | [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs), [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs) | `request_deposit_updates_epoch_and_user_state`, `capital_makes_the_round_trip_to_the_custodian` | +| Deposit asset (Stellar Asset Contract) moves in and out | [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs), [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`contracts/async-vault/src/test/clawback.rs`](../contracts/async-vault/src/test/clawback.rs) | `request_deposit_updates_epoch_and_user_state`, `capital_makes_the_round_trip_to_the_custodian`, `a_cancellation_the_balance_cannot_cover_is_refused_by_name` | | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | -| The share token checks the identity verifier on mint and transfer | [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `a_non_allowlisted_investor_cannot_claim_shares`, `a_frozen_or_delisted_holder_cannot_transfer_shares`, `manager_mints_to_allowlisted_account` | -| The vault reads the oracle and refuses a stale or paused feed | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `the_epoch_is_priced_at_the_attestation`, `fulfill_is_rejected_once_the_feed_goes_stale`, `a_paused_feed_is_refused_by_the_vault_not_the_oracle` | -| Upgrading replaces the vault's code and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_proposal_replaces_the_code`, `a_migration_refuses_a_second_run` | +| The vault checks the identity verifier at request; the share token checks it on mint and transfer | [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`, `a_frozen_or_delisted_holder_cannot_transfer_shares`, `manager_mints_to_allowlisted_account` | +| The vault returns escrowed shares as the token's `manager` after checking the receiver itself, including the compliance contract's `can_transfer`; neither its own allowlist place nor the token pause stops a return. A delisted holder's own zero-asset redemption claim waits until they are listed again, outside a wind-down; in one, it burns the shares instead | [`contracts/async-vault/src/test/share_returns.rs`](../contracts/async-vault/src/test/share_returns.rs), [`contracts/async-vault/src/test/cancel.rs`](../contracts/async-vault/src/test/cancel.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_delisted_vault_still_returns_escrowed_shares`, `a_token_pause_stops_trading_but_not_a_share_return`, `a_return_the_compliance_rules_refuse_stays_escrowed`, `a_delisted_holders_redemption_that_pays_nothing_waits_until_relisted`, `a_delisted_holders_worthless_redemption_burns_in_a_wind_down` | +| The vault reads the oracle and refuses a stale or paused feed, one that traps, or one that answers a share price of zero or less; each of these leaves sealed requests cancellable, and a feed whose `state()` still answers `Valid` still takes deposits | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs), [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs), [`contracts/async-vault/src/test/oracle_swap.rs`](../contracts/async-vault/src/test/oracle_swap.rs) | `the_epoch_is_priced_at_the_attestation`, `fulfill_is_rejected_once_the_feed_goes_stale`, `fulfill_is_rejected_while_the_nav_is_paused`, `a_deposit_is_refused_while_the_feed_is_stale`, `a_deposit_is_refused_while_the_nav_is_paused`, `a_deposit_is_refused_before_the_first_report`, `a_feed_that_traps_on_its_attestation_refuses_fulfilment`, `a_feed_that_traps_on_its_price_refuses_fulfilment`, `a_feed_that_traps_on_its_state_neither_prices_nor_blocks_a_cancel`, `a_sealed_deposit_cancels_when_the_feed_traps`, `a_sealed_redemption_cancels_when_the_feed_traps`, `a_deposit_still_lands_and_can_leave_when_the_feed_traps`, `both_sides_of_a_sealed_epoch_cancel_when_the_feed_traps_on_its_price`, `both_sides_of_a_sealed_epoch_cancel_when_the_feed_prices_at_zero_or_less`, `a_feed_that_prices_at_zero_or_less_refuses_fulfilment` | +| A priced, covered claim pays while the NAV is paused or the feed is stale: the claim never reads the oracle | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `a_priced_redemption_still_pays_while_the_nav_is_paused`, `a_priced_redemption_still_pays_once_the_feed_goes_stale`, `a_priced_deposit_still_claims_while_the_nav_is_paused` | +| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen, the token is bound to compliance (which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`), the asset has the token's decimals, the asset, token, oracle and vault have four different addresses, the oracle answers as a feed, a custodian other than the vault is set, and the vault's own books are empty (the first epoch, with no deposit or redemption booked in it, nothing committed, and no shares owed or to burn). `health()` reports each check, and before setup finishes the vault takes no request and moves no epoch | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/health.rs`](../contracts/async-vault/src/test/health.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `each_missing_wiring_step_shows_in_its_own_flag`, `a_token_that_cannot_answer_fails_its_checks_instead_of_trapping`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `a_redemption_before_setup_finishes_is_refused`, `no_epoch_is_sealed_or_priced_before_setup_finishes`, `a_redemption_planted_during_setup_never_reaches_a_depositor`, `setup_finishes_after_cash_is_funded_during_it`, `setup_cannot_finish_while_a_hand_over_is_pending` | +| The vault is never its own custodian: the direct setter, during setup and after, and a `Custodian` change, when proposed and when applied, refuse it with `InvalidCustodian` | [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/guards.rs`](../contracts/async-vault/src/test/guards.rs) | `setting_the_vault_as_its_own_custodian_is_refused`, `a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied`, `custodian_not_the_vault_refuses_the_vault_itself` | +| The share token swaps to new compliance rules only if they list the token and, when they answer `vault()` and `setup_open()`, run on the vault's clock with their setup closed (`ComplianceNotBound`); as at setup, rules that do not answer them count as an external module and are accepted. It swaps to a new verifier only if the verifier accepts the vault (`VerifierRefusesVault`). Both are checked when proposed and again when applied; rules that do not answer `linked_tokens` and a verifier that does not answer are refused | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/share-token/src/test_guards.rs`](../contracts/share-token/src/test_guards.rs), [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs) | `rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `rules_that_stop_qualifying_are_never_installed`, `rules_from_outside_the_kit_are_installed_while_they_list_the_token`, `rules_that_stop_answering_a_binding_view_are_installed_as_external`, `a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `a_verifier_that_stops_accepting_the_vault_is_never_installed`, `applied_rule_and_verifier_changes_take_effect`, `an_unbound_compliance_swap_is_refused_and_every_move_goes_on` | +| Upgrading replaces the vault's code and migrates once; an upgrade under an open epoch, a priced redemption, a queued change and a proposed wind-down keeps them, and the new code pays, prices and winds down from them | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_change_replaces_the_code`, `the_vault_upgrades_under_open_requests_and_a_proposed_wind_down` | +| The share token upgrades through the vault's timelock under live balances, an open deposit and an open redemption, and migrates once; called directly, `upgrade` does not exist | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `the_token_upgrades_under_live_balances_and_open_requests`, `the_token_exposes_no_immediate_upgrade` | +| The oracle upgrades through the vault's timelock under a live valuation, keeps its config, roles and vault, and migrates once; called directly, `upgrade` does not exist | [`contracts/nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs) (needs `upgrade_wasm`) | `the_oracle_upgrades_under_a_live_valuation`, `the_oracle_exposes_no_immediate_upgrade` | +| An oracle upgraded in place cannot read a report an earlier release stored; pausing the NAV, clearing the report and lifting the pause let the attester post a new one | [`contracts/nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs) | `a_report_of_the_earlier_shape_is_cleared_and_replaced` | + +The harness's own attestations, from its tests and `npm run seed`, carry the +SHA-256 of [`docs/evidence/sample-valuation.md`](./evidence/sample-valuation.md), +a sample marked as test data, and a URL to it. By default the URL is the file's +raw copy on `main`, which resolves once the file is on `main`; the hash then +matches only while that copy is unchanged. With `PROOF_URI` pointing at a +release tag's copy, the URL and the hash name the same bytes for good. + +## Contract spec docs and error codes + +The contract spec keeps the first 1,024 bytes of each doc and drops the rest +without a warning. Each contract's error codes stay inside the block +`crates/strata-bindings` gives it. + +| Behaviour | Tests | Key test names | +| --- | --- | --- | +| Every entrypoint and error variant doc of the five contracts fits in 1,024 bytes | [`crates/strata-bindings/tests/spec_docs.rs`](../crates/strata-bindings/tests/spec_docs.rs) | `every_contract_doc_fits_the_spec` | +| An error enum declared through `error_enum!` compiles only while every code stays in its contract's block | [`crates/strata-bindings/src/errors.rs`](../crates/strata-bindings/src/errors.rs) (doctests) | the in-range example compiles; the out-of-range one fails with `E0080` | +| Every `#[only_role]` string literal matches one of its contract's role constants | [`crates/strata-bindings/tests/role_checks.rs`](../crates/strata-bindings/tests/role_checks.rs) | `every_only_role_literal_matches_its_role_constant` | + +## Wind-down on the network + +`scripts/harness/wind-down.test.ts` deploys a throwaway vault set on the local +network from a vault built with `--features short_wind_down`. That build drops +the seven-day minimum and carries a `build` meta entry, and is never deployed +elsewhere. The test runs a deposit, a claim, a redeem request, a wind-down, a +round and a claim, then checks that every function in the vault's spec was +called through the generated client's typed methods, so a renamed method or a +changed argument fails the typecheck. The full wind-down logic is tested in +Rust (`contracts/async-vault/src/test/wind_down.rs`), down to a round of one +asset unit at the lowest price the oracle allows +(`a_round_of_one_unit_at_the_lowest_price_still_credits_it`, +`at_the_lowest_price_holders_collect_the_reserve_less_a_unit_each`) and a claim +view that shows what the claim pays and fails where it fails +(`the_claimable_view_shows_what_the_claim_then_pays`, +`the_claimable_view_and_the_claim_fail_alike_at_the_overflow_edge`), and a +claim by the vault against its own escrow, which is refused +(`the_vault_cannot_claim_against_its_own_escrowed_shares`). Every unit a round +credits is paid or still owed, and the vault holds what it owes +(`the_payouts_never_exceed_what_the_rounds_credited`, +`multiple_rounds_with_floor_dust_remain_solvent_for_last_claimant`). The +testnet run is spread over seven days in the Tranche 2 release. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 00000000..e43c5336 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,1313 @@ +# Threat model + +What can go wrong in a Strata vault, what stops it, what is left, and the test +that shows each control. It covers the oracle, the roles and the redemption +paths. Tranche 3's threat-to-test matrix builds on it. + +## Scope + +- **Contracts.** `async-vault`, `share-token` and `nav-oracle`, the testnet + stubs `identity-verifier` and `compliance`, and the shared crates + `strata-timelock`, `strata-bindings` and `strata-storage`, at commit + `f3c03f8`. +- **The console**, as far as signing goes: the `/sign` page that collects an + authority's signatures (R12). +- **Not the testnet deployment.** The deployment listed in the + [README](../README.md) runs an older release, and several controls below are + not on it yet. + +The design doc +([§8.9](./strata-product-and-architecture.md#89-trust-boundaries--failure-modes)), +the [kit spec](./kit-spec.md), the +[authorization matrix](./AUTHORIZATION_MATRIX.md), the +[compliance matrix](./COMPLIANCE_MATRIX.md) and the `//! # Design` notes at the +top of each contract's `lib.rs` describe many of these controls in more detail. +Where a document and the code disagree, this document follows the code. + +## How to read it + +Each threat has an id: O for the oracle, R for the roles, X for the redemption +paths. Under it: + +- **Control:** what stops it, and where. +- **Residual:** what is left when the control works. +- **Tests:** the tests that show the control, or "none" and why. + +Vault tests live in `contracts/async-vault/src/test/` and are cited by file name +alone, such as `cancel.rs`. Other Rust tests are cited by their path under +`contracts/` or `crates/`. `cargo test` from the repository root runs them all; +[TESTING.md](./TESTING.md) gives the environment variable `cargo` needs and the +builds the `upgrade_wasm` tests need. Harness tests, cited by title, run against +a local network ([scripts/harness/README.md](../scripts/harness/README.md)). +Console tests, also cited by title, run with `npm run test`. + +## Assets + +- **Investor cash.** Deposit escrow that can still be cancelled + (`cancellable_escrow`), the reserve, cash owed to priced exits (`committed`), + and what wind-down rounds have credited but holders have not collected + (`wind_down_owed`). +- **Shares.** In wallets, escrowed in the vault by a redemption, and priced for + a deposit but not yet minted (`pending_mint_shares`). +- **The share price.** The oracle's latest report. +- **Capital at the custodian** (`net_deployed`). Off chain once sent. +- **Privileged powers.** Upgrades, role grants, the custodian slot, contract + swaps, pauses, freezes, legal transfers and the wind-down. + +## Actors + +| Actor | What it can do on chain | +| --- | --- | +| Governance | Admin of the vault. By deploy, also admin of the share token, the oracle and the compliance contract. | +| Guardian | Pauses the vault (vault `guardian`). By deploy, also pauses the NAV (oracle `guardian`). | +| Manager | Seals the open epoch with `close_epoch` (vault `manager`) at any time; "sealed" below means closed by the manager or by anyone once the minimum epoch duration has run. A role, not one of the five authorities. | +| Treasury | Sends free reserve to the custodian (vault `treasury`). | +| Attester | Posts the share price (oracle `attester`). | +| Compliance | Freezes holders, and with governance makes legal transfers and recoveries (share token `compliance`). By deploy, also lists and delists addresses (identity verifier admin). | +| The vault contract | Mints, burns and moves escrowed shares (share token `manager`, which `finish_setup` requires it to hold alone). | +| Anyone | Prices a sealed epoch, funds the vault, activates a wind-down once it is due, finalizes a round, seals an epoch that has been open for the minimum epoch duration once governance has set one. Each holder signs their own requests, cancellations and claims. | +| Outside the kit | The custodian, the deposit asset's issuer, external compliance or identity contracts, wallets, and whoever hosts a frontend. | + +"By deploy" means the deploy script sets it and no contract checks it (R5). The +design calls for each of the five authorities to be a native Stellar multisig +account; no contract can check that (R4). + +## Assumptions + +- The Soroban host checks each `require_auth` against the account's signers and + thresholds. It refuses to re-enter a contract already in the call stack, so a + token or compliance hook cannot call back into the vault during a claim. +- Ledger time comes from network consensus. The cooldown, expiry, notice and + timelock windows all read it. +- The deposit asset is a Stellar Asset Contract, such as USDC. Its issuer's + controls are outside the kit (X10). +- OpenZeppelin's Stellar libraries, at the versions pinned in `Cargo.toml`, + behave as documented. + +## Oracle + +### O1. One false report within the limits + +The attester, compromised or careless, posts a price that passes every check. + +- **Control:** Only an `attester` can post. A report must sit in the band + `[min_answer, max_answer]`, move from the last report by no more than + `max_up_bps` up or `max_down_bps` down (both required, 1 to 10,000 basis + points), and come at least `cooldown_secs` after it. The oracle stamps the + acceptance time itself. The vault prices an epoch only after its notice, and + only from a report accepted at or after the epoch was sealed. The guardian or + the oracle admin can pause the NAV at once, and revoking the attester is + instant. +- **Residual:** A report inside the limits is taken as true. Every request in an + epoch priced from it gets that price, so value moves between the holders who + enter or leave then and the holders who stay. Nothing on chain compares the + price with its evidence (O10). +- **Tests:** "cannot attest a price" in + [`scripts/harness/guards.test.ts`](../scripts/harness/guards.test.ts); + `attest_rejects_out_of_band` (only the floor and zero, through `attest`), + `attest_enforces_cooldown_and_deviation` and + `a_configured_downside_still_bounds_a_fall` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs); + `nav_in_band_refuses_a_value_outside_the_band` (both ends of the band) in + [`nav-oracle/src/test/guards.rs`](../contracts/nav-oracle/src/test/guards.rs); + `an_epoch_is_not_priced_against_a_valuation_older_than_its_close` in + [`oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs); + `an_epoch_cannot_be_priced_inside_its_notice` in + [`notice.rs`](../contracts/async-vault/src/test/notice.rs). + +### O2. A walk of reports + +A compromised attester moves the price in many steps, each inside the caps. + +- **Control:** The cooldown limits how often a report lands, the caps limit + each step, and the band limits the price. A NAV pause stops the walk, and + revoking the attester is instant. +- **Residual:** The price can move by one cap per cooldown, and the steps + compound. With the shortest cooldown, one second, a report can land in every + ledger. The band is the only absolute limit. Nothing on chain raises an + alarm; alerts on large moves belong to the + [monitoring plan](./MONITORING_PLAN.md#v5-a-run-of-reports-one-way). +- **Tests:** `two_reports_in_one_ledger_are_refused_at_the_shortest_cooldown` + and `attest_enforces_cooldown_and_deviation` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs). + +### O3. A real move larger than a cap + +The asset really loses, or gains, more than one report may show. + +- **Control:** Governance chooses the caps. A fall cap of 10,000 basis points + lets a loss of any size land in one report. With a lower cap, the oracle admin + can pause the NAV, which stops pricing, and call `clear_latest`, so the next + report can land anywhere in the band (O8). After setup, changing a cap waits + for the timelock, tightening included. +- **Residual:** With a fall cap below 10,000, a large loss lands over several + reports. Holders who leave meanwhile are paid above value, at the cost of + those who stay, unless someone pauses the NAV. A capped rise does the same in + reverse for those who enter. With a fall cap of 10,000, one false report can + drop the price to `min_answer`. +- **Tests:** `a_full_downside_cap_lets_a_large_loss_land_at_once`, + `a_full_downside_cap_leaves_min_answer_as_the_only_floor` and + `a_configured_downside_still_bounds_a_fall` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs); + `a_tighter_cap_or_band_waits_after_setup` in + [`nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs). + +### O4. The feed goes stale + +The attester stops reporting, or the last report expires. + +- **Control:** A report expires at the earlier of its own `expires_at` and its + acceptance plus `freshness_duration`. A stale feed makes `fulfill_epoch` + fail, and requests in a sealed epoch can be cancelled again. A stale feed + also makes `request_deposit` refuse (`FeedNotValid`), so no new deposit waits + on it. Priced claims still pay: no claim reads the oracle. Governance revokes + the attester at once. `health()` reports `feed_valid`, and + `freshness_covers_notice` is false when the freshness window is shorter than + the notice. +- **Residual:** Holders with shares cannot exit until a report lands, or until + a wind-down pays them pro rata (X8). After setup, granting a new attester + waits for the timelock: at least seven days, plus any time the vault spends + paused. +- **Tests:** `heartbeat_expiry_makes_it_stale` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs); + `fulfill_is_rejected_once_the_feed_goes_stale` in + [`oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs); + `a_sealed_epoch_cancels_once_the_feed_goes_stale` in + [`cancel.rs`](../contracts/async-vault/src/test/cancel.rs); + `a_deposit_is_refused_while_the_feed_is_stale` and + `a_deposit_is_refused_before_the_first_report` in + [`deposit.rs`](../contracts/async-vault/src/test/deposit.rs); + `a_notice_longer_than_a_report_lives_is_reported` in + [`health.rs`](../contracts/async-vault/src/test/health.rs). + +### O5. Time games + +The attester gives a report a far expiry, or prices a sealed epoch with a +valuation made before the seal. + +- **Control:** The oracle stores the earlier of the given expiry and acceptance + plus `freshness_duration` (at most 35 days), and refuses an expiry that has + already passed. The attester gives no time of its own: the oracle records the + ledger time it accepted the report, and the vault compares that time with the + seal. +- **Residual:** The chain knows when a report was accepted, not when its + valuation was made. A report accepted after the seal can carry a valuation + made before it. +- **Tests:** `a_reporter_expiry_past_the_window_is_cut_to_the_window` and + `attested_at_is_when_the_contract_took_the_report` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs); + `expires_in_future_refuses_a_report_born_stale` in + [`nav-oracle/src/test/guards.rs`](../contracts/nav-oracle/src/test/guards.rs); + `an_attestation_before_the_close_does_not` in + [`timing.rs`](../contracts/async-vault/src/test/timing.rs). + +### O6. Declining a price after seeing it + +A holder learns the price of a sealed epoch and cancels if it does not suit +them. + +- **Control:** Once a sealed epoch's price is readable, cancellation closes, + even while the notice runs. A price is readable when the feed holds a valid + report accepted at or after the seal and the vault is not paused. + Cancellation opens again only when that stops being true: the report expires + or falls out of the band, the NAV or the vault is paused, the report is + cleared, or an oracle swap leaves no such report. A wind-down opens it too. + Apart from activating a wind-down that is already due, no holder can cause + any of these. Anyone can price the epoch once its notice ends. +- **Residual:** Whoever knows the price before the report lands, such as the + attester or anyone the valuation reaches early, can cancel first. If the + report that closed the window expires before anyone prices the epoch, + cancellation opens again; keep `freshness_duration` longer than the notice. A + guardian can reopen cancellation by pausing the vault, and only governance + lifts that pause. An epoch takes the latest valid report at the moment someone + prices it, so a later report can replace the one that closed the window. +- **Tests:** `a_sealed_epoch_cannot_be_cancelled_while_it_can_be_priced`, + `cancellation_reopens_when_the_price_that_closed_it_expires` and + `a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price` in + [`cancel.rs`](../contracts/async-vault/src/test/cancel.rs); + `the_notice_does_not_reopen_the_cancellation_window` in + [`notice.rs`](../contracts/async-vault/src/test/notice.rs); + `anyone_may_fulfill_a_closed_epoch` in + [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs). + +### O7. The NAV pause used to stall pricing + +A compromised guardian pauses the NAV, again and again. + +- **Control:** The NAV pause stops only new reports, pricing and new deposits. + Redemption requests, claims and cancellations go on, and sealed epochs can be + cancelled again. + Only the oracle admin lifts it (`set_nav_paused(false)`), and revoking the + oracle's guardian is instant. +- **Residual:** Pricing waits until governance lifts the pause. Revoking the + guardian stops further pauses but does not lift this one. The oracle's + guardian is the vault's guardian by deploy only. +- **Tests:** `pausing_the_nav_is_limited_to_the_guardian` and + `the_guardian_pauses_the_nav_but_unpausing_needs_governance` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs); + `revoking_an_attester_or_a_guardian_stays_instant_after_setup` in + [`nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs); + `fulfill_is_rejected_while_the_nav_is_paused` in + [`oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs); + `a_sealed_epoch_cancels_while_the_nav_is_paused` in + [`cancel.rs`](../contracts/async-vault/src/test/cancel.rs); + `a_deposit_is_refused_while_the_nav_is_paused` in + [`deposit.rs`](../contracts/async-vault/src/test/deposit.rs). + +### O8. Clearing the stored report + +`clear_latest` removes the last report, so the next one has no earlier price to +cap it. + +- **Control:** Only the oracle admin clears, and only while the NAV is paused, + so lifting the pause and posting the next report are separate acts. The + oracle publishes `LatestCleared`. The band still bounds the next report. +- **Residual:** The first report after a clear can land anywhere in the band, + and so can the first report after deployment. Pausing the NAV, clearing and + lifting the pause are all instant, also after setup, so the oracle admin and + an attester acting together can set any price in the band at once. Alerts on + `LatestCleared` and on the report after it belong to the + [monitoring plan](./MONITORING_PLAN.md#v6-the-stored-report-is-cleared). +- **Tests:** `the_record_clears_only_while_the_nav_is_paused` and + `only_admin_can_clear_latest` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs); + `the_nav_pause_and_clearing_stay_instant_after_setup` in + [`nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs). + +### O9. Governance changes the limits or the feed + +Governance loosens the caps or the band, or points the vault at a feed it +controls. + +- **Control:** After setup, every change to the oracle's config except a + shorter `freshness_duration` waits for the vault's timelock (R1), and its + bounds are checked again when it applies. A shorter freshness window stays + instant: later reports expire sooner, and no value moves. Swapping the vault's + oracle is a timelocked change too. The new feed must name this vault, have + finished its setup and answer `state()` and `freshness_duration()`, both when + the swap is proposed and when it applies. No config sets a floor below + `MIN_ANSWER`. +- **Residual:** The feed checks catch wiring mistakes, not intent: a contract + governance writes can answer them and report any price. The delay is notice, + not a guaranteed exit (R1). An epoch sealed before the swap is priced by + whichever feed stands when it is priced. + + The checks do not ask for `attested_at()` or `nav_per_share()`: a freshly + deployed oracle has no report yet and refuses both, so asking would refuse a + sound swap. A feed that traps on either view, or answers a share price of + zero or less, is still installed. The + vault reads it as having no price: cancellation on a sealed epoch stays + open, and `fulfill_epoch` refuses with `FeedNotValid`, or with + `InvalidSharePrice` for a price of zero or less. Deposits still land while + its `state()` answers `Valid`, and each can be cancelled. A feed that starts + trapping on `state()` after the swap is read the same way, but deposits fail + too. In every case, the way back to pricing is a second timelocked swap to a + working feed. +- **Tests:** `a_tighter_cap_or_band_waits_after_setup`, + `a_shorter_freshness_window_stays_instant_after_setup` and + `a_config_outside_the_bounds_is_refused_when_it_applies` in + [`nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs); + `a_floor_below_a_millionth_of_an_asset_unit_per_share_is_refused` and + `a_stored_floor_below_the_minimum_stays_until_a_config_change_raises_it` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs); + `the_oracle_changes_only_through_the_timelock`, + `a_feed_outside_this_vault_is_never_proposed`, + `a_feed_that_stops_naming_this_vault_is_never_installed`, + `a_contract_that_is_not_a_feed_is_refused_when_proposed_and_when_applied`, + `a_sealed_epoch_is_priced_by_the_feed_standing_at_fulfilment`, + `a_sealed_deposit_cancels_when_the_feed_traps`, + `a_sealed_redemption_cancels_when_the_feed_traps`, + `a_feed_that_traps_on_its_attestation_refuses_fulfilment`, + `a_feed_that_traps_on_its_price_refuses_fulfilment`, + `a_feed_that_traps_on_its_state_neither_prices_nor_blocks_a_cancel`, + `a_deposit_still_lands_and_can_leave_when_the_feed_traps`, + `both_sides_of_a_sealed_epoch_cancel_when_the_feed_traps_on_its_price`, + `both_sides_of_a_sealed_epoch_cancel_when_the_feed_prices_at_zero_or_less` + and `a_feed_that_prices_at_zero_or_less_refuses_fulfilment` in + [`oracle_swap.rs`](../contracts/async-vault/src/test/oracle_swap.rs); + "governance cannot loosen the feed config at once" in + [`scripts/harness/guards.test.ts`](../scripts/harness/guards.test.ts). + +### O10. The proof proves nothing on chain + +A report's evidence is missing, wrong, or has nothing to do with the price. + +- **Control:** Each report carries a proof hash, which must not be all zeros, + and an optional URI of at most 256 bytes. The oracle stores both with the + report, returns them from `latest()` and publishes them in `NavAttested`. +- **Residual:** The oracle never checks the hash against a document. Any + non-zero hash passes, and the URI may stop working. A reader fetches the + evidence and compares the hash off chain. +- **Tests:** `a_report_without_a_proof_hash_is_refused`, + `a_proof_uri_past_the_limit_is_refused`, + `the_latest_report_carries_its_proof`, + `attesting_publishes_the_stored_valuation` and + `a_proof_uri_at_the_limit_is_stored_and_published` (both check that + `NavAttested` carries the proof) in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs). + +### O11. An oracle upgraded in place cannot read its old report + +Releases before the proof reference stored the report in another shape. An +oracle upgraded in place from one of them cannot read it, so every read of the +report fails, and pricing stops. Cancelling a request in a sealed epoch fails +too while the vault is unpaused and not winding down, because the vault then +asks the feed's state first. A cancellation in the open epoch, in a paused +vault or during a wind-down still works: the vault does not ask the feed then. + +- **Control:** The recovery in the + [kit spec](./kit-spec.md#upgrading-the-oracle-from-an-earlier-release): pause + the NAV before the upgrade applies, apply it, call `clear_latest`, lift the + pause, and attest. While the NAV is paused, `state()` answers `Paused` without + reading the report, and neither `pause_nav` nor `clear_latest` reads it. After + setup the upgrade itself waits for the timelock. +- **Residual:** If the upgrade lands before the NAV pause, pricing and those + cancellations fail until the NAV is paused. Deposits are refused while the + NAV is paused (`FeedNotValid`) or the feed cannot answer, so the vault takes + none from the upgrade or the pause until the first report after the clear. + That report has no earlier price to cap it (O8). A fresh deployment has no + old report. +- **Tests:** `a_report_of_the_earlier_shape_is_cleared_and_replaced` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs). + +### O12. An integrator reads a price the vault would refuse + +A contract that reads the share price takes `nav_per_share()` or `latest()` +without checking the feed's state. + +- **Control:** `state()` answers `Valid`, `Stale` or `Paused`, and + `ensure_consumable()` fails unless the feed is valid. `latest()` returns the + report's `accepted_at` and `expires_at`. The vault checks `state()` before it + prices. +- **Residual:** `nav_per_share()` and `latest()` return the stored report even + when it is stale, out of band or paused. An integrator who skips `state()` + reads a price the vault would refuse. The SEP-40 adapter is not built yet. +- **Tests:** `the_nav_pause_holds_regardless_of_freshness` and + `heartbeat_expiry_makes_it_stale` in + [`nav-oracle/src/test/oracle.rs`](../contracts/nav-oracle/src/test/oracle.rs); + `fulfill_is_rejected_while_the_nav_is_paused` in + [`oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs). + +## Roles + +### R1. Governance compromised: the changes that wait + +Someone takes governance's keys and changes code, roles or wiring. + +- **Control:** After each contract's setup, these changes wait in a public queue + (`propose_change`, then `apply_change`) on the vault's clock: + - vault: an upgrade, the upgrade delay, a role grant, revoking `manager`, an + admin hand-over, the custodian, a lower notice or wind-down delay, the + oracle; + - share token: an upgrade, a role grant (a second minter included), revoking + `manager`, an admin hand-over, a compliance or verifier swap; + - oracle: an upgrade, a role grant, an admin hand-over, a config change other + than a shorter freshness window; + - the kit's compliance contract: adding or removing a module, binding or + unbinding a token, an admin hand-over. + + The delay is 7 to 90 days, and never shorter than the notice when the change + applies. Time the vault spends paused does not count, nothing applies while it + is paused, nothing but a grant of the token's `compliance` role is proposed or + applied during a wind-down, and no admin hand-over is accepted then. One code + change waits at a time. No contract offers an immediate upgrade. Renouncing + the admin and setting a role admin are always refused. Every proposal is + published (`ChangeProposed`) and listed by `pending_changes()`. +- **Residual:** The delay is notice, not a guaranteed exit: leaving needs a + sealed epoch, a valid price accepted after the seal, and cash. Once the delay + runs, an upgrade can do anything, and a vault upgrade can also change the + clock the other three contracts read. A second `manager` on the token could + mint shares and redeem them against the reserve; + `health().sole_token_manager` turns false only when the grant lands. Revoking + the vault's `manager` role on the token, or unbinding the token from the + compliance contract, stops every request, claim and cancellation that mints, + burns or moves shares. During a wind-down, governance can still grant + `compliance` to an account it controls after one published delay, then sign + `legal_transfer` or `recover_balance` with it alone. Holders who are not + frozen can surrender their shares to `claim_wind_down` meanwhile, once a + round is finalised. +- **Tests:** `grants_and_manager_revokes_wait_for_the_timelock`, + `a_change_shorter_than_a_raised_notice_never_lands`, + `nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included` and + `an_admin_hand_over_cannot_be_accepted_during_a_wind_down` in + [`governance.rs`](../contracts/async-vault/src/test/governance.rs); + `applying_before_the_eta_is_refused`, `applying_while_paused_is_refused`, + `a_pause_freezes_the_upgrade_timelock_clock`, + `a_second_code_change_while_one_stands_is_refused`, + `the_vault_exposes_no_immediate_upgrade`, + `the_vault_reports_its_clock_to_the_token_and_the_oracle` and, with + `--features upgrade_wasm`, `the_token_exposes_no_immediate_upgrade` in + [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs); + `governance_cannot_renounce_itself_out_of_the_vault` in + [`controls.rs`](../contracts/async-vault/src/test/controls.rs); + `grants_and_manager_revokes_wait_after_setup`, + `rule_and_verifier_swaps_wait_after_setup` and + `an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down` in + [`share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs); + `grants_wait_after_setup_and_land_through_the_timelock`, + `an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down` and, with + `upgrade_wasm`, `the_oracle_exposes_no_immediate_upgrade` in + [`nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs); + `after_setup_rule_changes_wait_for_the_timelock` and + `an_admin_hand_over_cannot_be_accepted_while_the_vault_winds_down` in + [`compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs); + `time_spent_paused_does_not_count_toward_the_delay` in + [`strata-timelock/src/test/schedule.rs`](../crates/strata-timelock/src/test/schedule.rs); + `a_delay_from_seven_to_ninety_days_is_in_bounds` in + [`strata-timelock/src/test/delay.rs`](../crates/strata-timelock/src/test/delay.rs). + +### R2. Governance compromised: the powers that act at once + +Someone takes governance's keys and uses the powers that need no delay. + +- **Control:** The instant powers either move no value by themselves or are + emergency tools: + - unpause the vault, lift a NAV pause, call `clear_latest` while the NAV is + paused, pause or unpause the share token's transfers; + - set the deposit cap, up or down; raise the notice (up to 30 days, and never + above the upgrade delay) or the wind-down delay; shorten the oracle's + freshness window; + - revoke the vault's treasury or guardian, the oracle's attester or guardian, + or the token's compliance role; + - propose or cancel a queued change; propose a wind-down, which waits its own + delay of 7 to 90 days and which governance may cancel until then; + - co-sign a legal transfer or a recovery (`recover_balance`) with compliance + (R10). +- **Residual:** With an attester, governance can set any price in the band at + once (O8). With compliance, it can move any holder's wallet shares at once + (R10). A raised notice delays pricing for every epoch sealed afterwards, and + holds back queued changes with a shorter delay. A token pause stops trading + between holders, though not entries or exits. +- **Tests:** `the_deposit_cap_stays_instant_both_ways`, + `raising_the_notice_is_instant_and_lowering_waits` and + `revoking_treasury_or_guardian_stays_instant` in + [`governance.rs`](../contracts/async-vault/src/test/governance.rs); + `the_nav_pause_and_clearing_stay_instant_after_setup` and + `revoking_an_attester_or_a_guardian_stays_instant_after_setup` in + [`nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs); + `revoking_compliance_stays_instant_after_setup` in + [`share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs); + `governance_cancels_a_proposal_before_activation` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs). + +### R3. Setup left open, or wired wrong + +A deployment takes deposits while admin changes are still instant, or with a +binding missing. + +- **Control:** The vault, the share token, the oracle and the compliance + contract each start in a setup window in which admin changes are instant. + `finish_setup` closes it once, and it never reopens. The vault refuses + deposits, redemptions, and sealing or pricing an epoch until its own setup + is finished (`SetupOpen`). Its `finish_setup` refuses (`SetupIncomplete`) + unless: + - the token and the oracle name this vault and have finished their own setup; + - the vault is the token's only `manager`, and no shares exist yet; + - the vault passes the identity check and is not frozen on the token; + - the token is in its compliance contract's `linked_tokens`, and a compliance + contract that answers both `VaultBinding` views names this vault with its + setup finished (one that leaves either view unanswered is accepted as + external); + - the asset and the token have the same decimals, and the asset, the token, + the oracle and the vault are four different addresses; + - the oracle answers `state()` and `freshness_duration()`, and a custodian + other than the vault is set; + - the vault's own books are empty: the first epoch, with no deposit or + redemption booked in it, nothing committed, and no shares owed or to burn. + + It also refuses while an admin hand-over offered during setup is still open. + `health()` reports each of these checks except three: that no shares exist + yet, that the vault's own books are empty, and that no hand-over is pending. +- **Residual:** Before `finish_setup`, the admin changes anything at once. The + checks read only the token's `manager` list: a second attester, treasury or + guardian is not checked, though each contract shows its role holders. An + external compliance contract keeps its own governance. A vault built with the + `short_wind_down` feature, meant for local tests and marked so in its contract + meta, activates a wind-down with no delay, and nothing on chain refuses that + build. +- **Tests:** `a_deposit_before_setup_finishes_is_refused`, + `a_redemption_before_setup_finishes_is_refused`, + `no_epoch_is_sealed_or_priced_before_setup_finishes`, + `a_redemption_planted_during_setup_never_reaches_a_depositor`, + `setup_finishes_after_cash_is_funded_during_it`, + `every_wiring_step_is_checked_before_setup_finishes`, + `a_compliance_contract_from_outside_the_kit_is_accepted`, + `setup_cannot_finish_while_a_hand_over_is_pending` and + `setup_never_finishes_twice` in + [`setup.rs`](../contracts/async-vault/src/test/setup.rs); + `each_missing_wiring_step_shows_in_its_own_flag` in + [`health.rs`](../contracts/async-vault/src/test/health.rs); + `the_setup_window_closes_once` in + [`strata-timelock/src/test/setup.rs`](../crates/strata-timelock/src/test/setup.rs). + +### R4. An authority's keys + +An authority is a single key, its keys collude, or a call takes more signatures +than it shows. + +- **Control:** Each privileged call asks each account it needs for exactly one + signature, on that call only; `legal_transfer` and `recover_balance` ask + compliance and the token admin once each. The network checks each account's + signers and thresholds. The design calls for each authority to be a native + Stellar multisig account. +- **Residual:** No contract can check that an authority is a multisig, or how + many keys it needs: that is set at deploy. Keys that reach an authority's + threshold act for it. No test reads each authority's signers and thresholds + on the network yet; that comes with the multisig authorities, which are + planned. No test submits a privileged call through a relayer that pays the fee + while the authority signs only its authorization. None is planned: the kit + runs no relayer or backend, and whoever sends a transaction pays its fee. +- **Tests:** `each_setup_call_asks_the_admin_for_one_signature` (the setters, + a grant, a revoke, a hand-over offer and its cancellation, `finish_setup`), + `each_admin_call_after_setup_asks_the_admin_for_one_signature` (the change + queue, `unpause`, a wind-down proposal and its cancellation, accepting a + hand-over) and `each_role_call_asks_its_holder_for_one_signature` + (`close_epoch`, `deploy_to_custodian`, `pause`, `renounce_role`) in + [`signatures.rs`](../contracts/async-vault/src/test/signatures.rs); + `each_role_call_asks_its_holder_for_one_signature` (mint, burn, forced + transfer, the freezes, `renounce_role`) and + `a_legal_transfer_or_a_recovery_asks_each_signer_once` in + [`share-token/src/test.rs`](../contracts/share-token/src/test.rs); + `each_admin_call_asks_the_admin_for_one_signature` (setup, the change queue, + roles, the transfer pause, both swaps) in + [`share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs); + `each_admin_call_asks_the_admin_for_one_signature` (setup, the change queue, + roles, `set_config`, the NAV pause, `clear_latest`) and + `each_role_call_asks_its_holder_for_one_signature` (`attest`, `pause_nav`, + `renounce_role`, accepting a hand-over) in + [`nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs); + `each_admin_call_asks_the_admin_for_one_signature` (setup, bindings, modules, + the change queue) in + [`compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs); + `each_admin_call_asks_the_admin_for_one_signature` (`allow` and + `set_claim_topics_and_issuers`) in + [`identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs). + Signers and thresholds on the network: none yet. + +### R5. One account in several seats + +One key holds several roles, within a contract or across contracts, so one +compromise reaches several powers. + +- **Control:** The vault's constructor refuses four pairs of equal addresses: + treasury and guardian, treasury and governance, compliance and governance, + compliance and treasury. It compares nothing else, and it is the only check + on who holds which role. Separately, the share token refuses a legal + transfer or a recovery signed by one account in both seats, compliance and + admin (R10); that check does not stop the grant itself. +- **Residual:** Nothing checks later grants or hand-overs: governance can grant + itself the vault's treasury role, at once during setup or through the + timelock after it. Separation is not enforced across contracts either: the + oracle's, the token's and the verifier's roles are constructor parameters, + equal to the vault's authorities by deploy only. Governance may also be the + guardian, and the manager may be anyone. Every check compares addresses, and + two Stellar accounts can share a signer, so distinct addresses do not prove + distinct keys. The checks are being redesigned so that a deployment may + choose to run every role from one key, which the four refusals forbid today + at construction. Until then, holders read each contract's role holders; the + verifier stub has no view of its admin. +- **Tests:** `the_treasury_may_not_also_be_the_guardian`, + `the_treasury_may_not_also_be_governance`, + `compliance_may_not_also_be_governance`, + `compliance_may_not_also_be_the_treasury` and + `the_guardian_may_be_governance` in + [`constructor.rs`](../contracts/async-vault/src/test/constructor.rs); + `one_account_cannot_co_sign_with_itself` (the grant of `compliance` to the + admin goes through; the call it then signs alone is refused) in + [`share-token/src/test.rs`](../contracts/share-token/src/test.rs). Later + grants and other contracts: none, because nothing checks them. + +### R6. Guardian compromised + +The guardian pauses the vault or the NAV to stall it. + +- **Control:** The guardian can pause the vault and pause the NAV, and nothing + else. A vault pause stops deposit requests, pricing and applying queued + changes, and stops the timelock's clock. Redemption requests, claims, + cancellations, deployments to the custodian, funding and the wind-down go on. + Only governance unpauses, and it revokes the guardian at once. +- **Residual:** A pause delays pricing and every queued change, fixes included, + until governance acts, and the paused time is added to every queued change's + wait. While the vault is paused, sealed epochs can be cancelled again. +- **Tests:** `only_the_guardian_pauses`, + `the_guardian_pauses_but_only_governance_unpauses`, + `a_paused_vault_still_takes_redeem_requests`, + `a_paused_vault_still_pays_redemptions` and + `a_paused_vault_does_not_fulfill_epochs` in + [`controls.rs`](../contracts/async-vault/src/test/controls.rs); + `the_pause_never_blocks_a_cancellation` in + [`cancel.rs`](../contracts/async-vault/src/test/cancel.rs); + `the_pause_does_not_block_a_wind_down_claim` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs); + `revoking_treasury_or_guardian_stays_instant` in + [`governance.rs`](../contracts/async-vault/src/test/governance.rs); + `a_pause_freezes_the_upgrade_timelock_clock` in + [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs). + +### R7. Treasury compromised + +The treasury drains the reserve. + +- **Control:** `deploy_to_custodian` moves at most the free reserve: what the + vault holds, less cancellable escrow, `committed` and `wind_down_owed`. It + sends only to the stored custodian, and never during a wind-down. While + anything is uncovered, the free reserve is zero. After setup the custodian + changes only through the timelock, and never to the vault itself. +- **Residual:** The treasury can send the whole free reserve to the custodian at + any time; exits priced later wait until the cash comes back through `fund`. + What the custodian does with the money is off chain. +- **Tests:** `assets_owed_to_a_priced_redemption_cannot_be_deployed` and + `deploying_needs_the_treasury_role` in + [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs); + `refundable_escrow_is_not_free_reserve` in + [`cancel.rs`](../contracts/async-vault/src/test/cancel.rs); + `the_treasury_cannot_deploy_while_anything_is_uncovered` in + [`multi_epoch.rs`](../contracts/async-vault/src/test/multi_epoch.rs); + `the_custodian_changes_through_the_timelock` and + `a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied` in + [`governance.rs`](../contracts/async-vault/src/test/governance.rs); + `an_active_wind_down_sends_nothing_to_the_custodian` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs). + +### R8. The manager stops sealing, or picks the moment + +The manager stops sealing, so nothing is priced, or seals when it suits someone. + +- **Control:** The manager can seal the open epoch at any time; once governance + sets a minimum epoch duration, anyone else may seal an epoch that has been + open that long. Neither can set the price: anyone prices a sealed epoch from + the oracle. Requests in the open epoch can be cancelled at any time, apart + from the holds in X3 and X4. +- **Residual:** The manager decides which requests share a price until the + minimum has run. A manager who stops sealing holds priced exits back for up + to the minimum epoch duration; once it has run, anyone seals. Without a + minimum set, exits at a price wait until governance replaces the manager + through the timelock, at least seven days. Holders can still cancel at any + time: a deposit comes back as cash, a redemption as shares. Once the minimum + has run, anyone can seal at a moment chosen to match a valuation they + already know, the ordering O5 names: a report accepted after the seal can + carry a valuation made before it. Before this branch only the manager could + pick that moment; now any outsider can too. Holders still get no price + older than the seal, so the risk falls on the operator's own cut-off + routine, which must now assume an outsider may seal first. +- **Tests:** `a_non_manager_cannot_close` and + `anyone_may_fulfill_a_closed_epoch` in + [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs); + `a_raised_minimum_counts_from_the_epochs_start`, + `an_outsider_seals_once_the_minimum_has_run` and + `with_no_manager_anyone_still_seals_after_the_minimum` in + [`sealing.rs`](../contracts/async-vault/src/test/sealing.rs); + `cancelling_a_deposit_returns_the_asset` and + `cancelling_a_redemption_returns_the_shares` in + [`cancel.rs`](../contracts/async-vault/src/test/cancel.rs); + `grants_and_manager_revokes_wait_for_the_timelock` in + [`governance.rs`](../contracts/async-vault/src/test/governance.rs). + +### R9. Compliance compromised + +Compliance freezes or delists holders wrongly, or lists an address it should +not. + +- **Control:** Freezes need the token's `compliance` role; they act at once and + are published. The vault's own address cannot be frozen once the token knows + its vault. Compliance cannot mint, burn, use `forced_transfer` or swap the + token's rules or verifier, and it moves a holder's shares only with + governance's signature in the same call (R10). Governance revokes the token's + `compliance` role at once. The token's last `compliance` holder cannot + renounce (`LastComplianceHolder`). A grant of `compliance` goes through the + token's timelock even during a wind-down, after the normal delay. The deploy + grants a cold backup holder during setup. A delisted holder still leaves in + cash (X4), and what a frozen holder is owed stays recorded (X3). +- **Residual:** A wrongful freeze holds all of a holder's exits, covered claims + included, until it is lifted; nothing on chain checks for a legal order. A + frozen holder's exits, a wind-down claim included, wait for a live + `compliance` holder. If governance revokes the last one or its key is lost, a + freeze stays until a grant lands one delay later; with no backup and no + governance action, it stays for good. The backup holder carries every + `compliance` power the primary does: it freezes at once and co-signs legal + transfers (R10), so it is one more key to guard, not a spare with less + reach. The testnet verifier stub's admin, the + compliance key by deploy, lists and delists at once, without an event, and + can be replaced only by swapping the verifier, which waits for the timelock + after setup. The verifier and compliance contracts in this repository are + testnet stubs; + [#238](https://github.com/BootNodeDev/strata-vault-kit/issues/238) chooses + what replaces them on mainnet. +- **Tests:** `the_vault_address_cannot_be_frozen`, + `compliance_alone_cannot_move_shares`, `compliance_no_longer_forces_transfers`, + `the_compliance_authority_cannot_mint_burn_or_swap_the_rules` and + `the_last_compliance_holder_cannot_renounce` in + [`share-token/src/test.rs`](../contracts/share-token/src/test.rs); + `revoking_compliance_stays_instant_after_setup`, + `governance_still_revokes_the_last_compliance_holder_at_once`, + `a_compliance_grant_lands_through_the_timelock_during_a_wind_down`, + `a_compliance_grant_proposed_before_the_wind_down_lands_during_it` and + `a_paused_vault_still_holds_a_compliance_grant_in_a_wind_down` in + [`share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs); + `a_frozen_holder_is_paid_once_a_compliance_holder_granted_in_the_wind_down_lifts_the_freeze` + in [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs); + `only_admin_can_allow` in + [`identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs); + "governance and the vault manager cannot freeze a holder" in + [`scripts/harness/guards.test.ts`](../scripts/harness/guards.test.ts). + +### R10. Compliance and governance move a holder's shares together + +The two authorities, colluding or both compromised, take a holder's shares. + +- **Control:** `legal_transfer` needs the `compliance` role and the token + admin's signature in the same call, from two different accounts. It never + moves shares out of or into the vault's address, so it cannot reach escrow, + priced cash, unminted shares or a wind-down credit. The destination must pass + the identity check. Each transfer publishes `LegalTransfer` with an order + reference of 1 to 128 bytes. +- **Residual:** Together the two can move any amount of any holder's wallet + shares at once, with no delay, frozen shares included. With the testnet + verifier stub, compliance itself lists the destination. Nothing on chain + checks that a legal order exists. `recover_balance` needs the same two + signatures and a recovery account named by the verifier; the testnet stub + names none, so recovery always fails there. Governance can grant + `compliance` to an account it controls after one published delay, a + wind-down included (R1), reaching both signatures alone. +- **Tests:** `compliance_and_the_admin_move_shares_together`, + `one_account_cannot_co_sign_with_itself`, `nothing_leaves_the_vault_address`, + `nothing_lands_on_the_vault_address`, + `the_destination_must_pass_the_identity_check`, + `the_order_reference_is_required_and_bounded` and + `recovery_needs_the_admin_too` in + [`share-token/src/test.rs`](../contracts/share-token/src/test.rs). + +### R11. Swapping the token's compliance contract or verifier + +Governance installs rules or a verifier that block exits, let anyone in, or +change without notice. + +- **Control:** After setup a swap waits for the vault's timelock. It is checked + when proposed and again when applied. New rules must list the token in + `linked_tokens`. If they answer both of the kit's `VaultBinding` views, + `vault()` and `setup_open()`, they must name this vault with their setup + finished. A compliance contract that leaves either view unanswered is + accepted as external, as `finish_setup` accepts one. A new verifier must + accept the vault's address. +- **Residual:** The checks catch wiring mistakes, not intent. An external + compliance contract keeps its own governance: its module changes do not wait + for the vault's timelock. The binding views prove nothing either: rules that + name another vault, or whose setup is open, pass as soon as they stop + answering one view, even between proposal and apply. A new verifier can + refuse every holder, which stops deposits but leaves exits in cash, or accept + anyone. +- **Tests:** `rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, + `rules_that_stop_qualifying_are_never_installed`, + `rules_from_outside_the_kit_are_installed_while_they_list_the_token`, + `rules_that_stop_answering_a_binding_view_are_installed_as_external`, + `a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed` and + `a_verifier_that_stops_accepting_the_vault_is_never_installed` in + [`share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs); + `an_unbound_compliance_swap_is_refused_and_every_move_goes_on` in + [`compliance.rs`](../contracts/async-vault/src/test/compliance.rs); "refuses + an instant verifier swap, even from governance" in + [`scripts/harness/guards.test.ts`](../scripts/harness/guards.test.ts). + +### R12. A crafted transaction on the console's signing page + +An authority opens a shared `/sign` link, sees one operation and signs another. + +- **Control:** The page decodes the transaction before anyone signs. It refuses + a fee-bump envelope, more than one operation, an operation that is not a + contract call, an operation whose source differs from the transaction's, an + authorization entry that is not the source account's, and a transaction not + yet valid. It also refuses a contract other than the configured vault, + oracle, share token and identity verifier, an operation the console does not + offer, and arguments of the wrong shape. It shows the operation in the + console's terms. +- **Residual:** A signer may sign in the wallet without reading it. A tampered + or impersonated console can show anything; each operator hosts its own. The + console builds no `legal_transfer`, so a co-signed order is built outside it. + At this commit the console is built against the contracts deployed on + testnet, an older release; some of its operations, such as a compliance + `forced_transfer`, no longer exist in these contracts. +- **Tests:** "refuses a fee bump envelope", "refuses a transaction with more + than one operation", "refuses a host function that is not a contract + invocation", "refuses an operation with a source other than the + transaction's", "refuses an auth entry with address credentials" and + "refuses a transaction that is not valid yet" in + [`app-lib/multisig.test.ts`](../app-lib/multisig.test.ts); "refuses a contract + that is not one of the configured four", "refuses an operation the console + does not propose" and "refuses arguments of the wrong shape" in + [`app/src/pages/sign.test.ts`](../app/src/pages/sign.test.ts). + +## Redemption paths + +### X1. The reserve cannot pay a priced exit + +Priced exits owe more cash than the vault holds. + +- **Control:** Pricing fixes the cash owed (`committed`) whether or not the cash + is there. A claim pays when the liquid reserve (what the vault holds, less + cancellable escrow) covers that claim's own amount; otherwise it fails with + `ClaimNotCovered` and stays owed. Claims pay in any order, so a covered claim + never waits behind a larger one. While anything is uncovered, nothing goes to + the custodian. `uncovered()` shows the gap, and anyone can `fund` the vault. +- **Residual:** An exit waits for the treasury or the custodian, with no + deadline. Being uncovered is not insolvency; whether the vault's assets cover + its liabilities is for the attested price and governance. +- **Tests:** `a_claim_waits_until_the_reserve_covers_it`, + `a_covered_claim_pays_while_a_larger_one_waits` and + `the_treasury_cannot_deploy_while_anything_is_uncovered` in + [`multi_epoch.rs`](../contracts/async-vault/src/test/multi_epoch.rs); + `the_epoch_prices_but_the_claim_waits_for_the_cash` in + [`redeem.rs`](../contracts/async-vault/src/test/redeem.rs); "the large claim + waits" and "pays once the capital returns" in + [`scripts/harness/guards.test.ts`](../scripts/harness/guards.test.ts). + +### X2. A pause, a stale feed or a delisting blocks a covered claim + +An administrative act stops a holder from collecting a priced, covered exit. + +- **Control:** `claim_redeem` has no pause check and does not read the oracle. + It asks the verifier about the holder but pays a delisted holder like a listed + one, and a verifier that fails to answer counts as delisted. Of the holder's + own standing, only a freeze holds the claim (X3). +- **Residual:** The claim still needs the liquid reserve to cover it, or it + fails with `ClaimNotCovered` (X1). It also calls the share token, to check + the freeze and burn the escrow, and the token calls the compliance contract. + A module that fails there blocks it (X6), and so would a timelocked revoke of + the vault's `manager` role on the token or an unbinding of the token from its + compliance contract (R1). +- **Tests:** `a_paused_vault_still_pays_redemptions` in + [`controls.rs`](../contracts/async-vault/src/test/controls.rs); + `a_delisted_holder_can_still_claim_their_exit` in + [`compliance.rs`](../contracts/async-vault/src/test/compliance.rs); + `a_priced_claim_still_pays_during_a_wind_down` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs), which pays + a week after the price, when the test feed's one-hour report has long + expired; `a_priced_redemption_still_pays_while_the_nav_is_paused` and + `a_priced_redemption_still_pays_once_the_feed_goes_stale` in + [`oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs). + +### X3. A frozen holder + +A freeze is used to take or redirect a holder's value. + +- **Control:** The vault refuses a frozen holder's requests, claims and + cancellations with `Frozen`. What it owes them stays recorded, and no other + holder can claim that record: escrowed cash, priced but unminted shares, + escrowed shares, priced cash in `committed`, and a wind-down credit in + `wind_down_owed`. A partial freeze limits a redemption or a wind-down claim to + the free shares. Lifting the freeze releases everything. A legal transfer + reaches only wallet shares (R10). +- **Residual:** A freeze holds even a covered claim, for as long as it lasts. + Lifting a freeze needs a live `compliance` holder (R9). The record is safe, + but the cash behind it is pooled: while the reserve is short, other covered + claims pay first (X1), and after a loss of reserve in a wind-down, claims can + spend each other's cash (X8). The vault cannot pay an authority directly. A + refund for a deposit worth less than one share at its price is not tracked as + owed: it waits in the free reserve, where the treasury can deploy it or a + wind-down round can credit it to others. Counsel must confirm the hold for + each jurisdiction. +- **Tests:** `a_frozen_holder_is_paid_only_after_the_freeze_lifts`, + `a_frozen_investors_escrow_stays_until_the_freeze_lifts`, + `a_frozen_investor_is_not_minted_until_unfrozen`, + `a_partial_freeze_limits_an_exit_to_the_free_shares` and + `a_frozen_investors_zero_share_refund_is_held` in + [`compliance.rs`](../contracts/async-vault/src/test/compliance.rs); + `a_frozen_holders_slice_stays_owed_until_the_freeze_lifts` and + `a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs). + +### X4. A delisted holder + +Delisting traps a holder in shares they may no longer hold, or an unlisted +address gets in. + +- **Control:** The vault checks the allowlist when a deposit is requested, + before any cash moves. A delisted holder who is not frozen can request a + redemption, claim cash and cancel a deposit. When they claim a priced deposit, + the vault mints the shares into its own escrow and adds them to their + redemption in the open epoch, paid in cash at that epoch's price; in a + wind-down they join the holder's entitlement. They cannot cancel a redemption + back into shares. +- **Residual:** They leave at a later epoch's price, and a fall since their + deposit was priced is theirs. The conversion mints into the vault's own + escrow, so it waits while compliance has delisted the vault; + `health().allowlisted` then reads false, and nothing is lost. A redemption + priced at zero assets cannot give its shares back to a delisted holder, so + its request stays open. Only the holder can start an exit: nothing lets + compliance or governance redeem a holder who is no longer eligible. If a + fund's terms require that, it needs a new power, and counsel must confirm the + process. +- **Tests:** `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`, + `a_delisted_holder_can_still_queue_an_exit` and + `a_delisted_holder_cannot_cancel_redemption_back_to_shares` in + [`compliance.rs`](../contracts/async-vault/src/test/compliance.rs); + `a_delisted_holders_priced_deposit_joins_their_redemption_in_the_open_epoch` + and `a_conversion_waits_while_the_vault_is_delisted` in + [`delisted.rs`](../contracts/async-vault/src/test/delisted.rs); + `a_delisted_holder_with_a_worthless_exit_keeps_the_request` in + [`share_returns.rs`](../contracts/async-vault/src/test/share_returns.rs). + +### X5. Escrowed shares that should come back + +A token pause, the vault's own delisting or the rules stop a cancelled +redemption from returning. + +- **Control:** Outside a wind-down, `cancel_redeem` checks the holder itself + (not frozen, passes the identity check, allowed by the rules' `can_transfer`) + and returns the shares with the manager's `forced_transfer`. Neither the token + pause nor the vault's own place on the allowlist applies to that move. +- **Residual:** A return the rules refuse fails with `HolderNotEligible`; the + request stays and is paid in cash once priced. In a wind-down no shares come + back: the escrow is burned into the holder's entitlement. +- **Tests:** `a_token_pause_stops_trading_but_not_a_share_return`, + `a_delisted_vault_still_returns_escrowed_shares`, + `a_return_the_compliance_rules_refuse_stays_escrowed` and + `a_partial_freeze_does_not_stop_a_return` in + [`share_returns.rs`](../contracts/async-vault/src/test/share_returns.rs); + `an_escrowed_redemption_cancelled_in_a_wind_down_joins_the_payout` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs). + +### X6. A compliance module blocks exits + +A module on the compliance contract refuses or fails, so the token's calls fail. + +- **Control:** On the kit's compliance contract, modules change only through the + vault's timelock after setup, never while the vault is paused, and never + during a wind-down. The kit registers no modules. +- **Residual:** The token calls the compliance contract on every mint, transfer + and burn, and exits use all three: a mint for a share claim, a forced transfer + to escrow or return a redemption, a burn to pay a redemption or a wind-down + claim. A module that refuses or fails there blocks even a covered exit. + Removing it waits for the timelock, and during a wind-down it cannot be + removed at all. An external compliance contract changes its modules under its + own governance. Which modules mainnet uses, and what they may do during an + exit, is decided with + [#238](https://github.com/BootNodeDev/strata-vault-kit/issues/238). +- **Tests:** `a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim` + in [`compliance.rs`](../contracts/async-vault/src/test/compliance.rs); + `a_module_failing_on_burn_blocks_wind_down_claims_and_cannot_be_removed` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs); + `a_refused_mint_keeps_a_priced_deposit_epoch_open` in + [`settlement.rs`](../contracts/async-vault/src/test/settlement.rs); + `after_setup_rule_changes_wait_for_the_timelock` and + `nothing_lands_while_the_vault_is_paused_or_winding_down` in + [`compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs). + +### X7. Escrow behind stalled pricing + +A stale feed, a stopped manager or a pause keeps escrowed cash or shares from +coming back. + +- **Control:** Cancelling is one atomic step that returns the whole escrow. It + is open while the epoch is open. For a sealed epoch it stays open until a + valid report accepted at or after the seal can be read with the vault + unpaused, even during the notice (O6). It opens again if that report expires + or falls out of the band, if the NAV or the vault is paused, or if the report + is cleared, an oracle swap leaves none, or the feed traps or answers a share + price of zero or less (O9). It stays open throughout a wind-down. +- **Residual:** Between a readable report and the end of the notice, a request + can be neither cancelled nor priced; the notice is at most 30 days. A frozen + holder cannot cancel (X3). A delisted holder's redemption does not come back + as shares; it is paid in cash (X4). Nobody exits at a price until pricing + resumes. +- **Tests:** `a_sealed_epoch_is_still_cancellable`, + `a_sealed_epoch_cancels_once_the_feed_goes_stale`, + `a_sealed_epoch_cancels_while_the_nav_is_paused` and + `the_pause_never_blocks_a_cancellation` in + [`cancel.rs`](../contracts/async-vault/src/test/cancel.rs); + `the_notice_does_not_reopen_the_cancellation_window` in + [`notice.rs`](../contracts/async-vault/src/test/notice.rs); + `a_sealed_deposit_cancels_when_the_feed_traps`, + `a_sealed_redemption_cancels_when_the_feed_traps`, + `a_feed_that_traps_on_its_state_neither_prices_nor_blocks_a_cancel`, + `both_sides_of_a_sealed_epoch_cancel_when_the_feed_traps_on_its_price` and + `both_sides_of_a_sealed_epoch_cancel_when_the_feed_prices_at_zero_or_less` + in [`oracle_swap.rs`](../contracts/async-vault/src/test/oracle_swap.rs); + `an_epoch_sealed_before_the_announcement_is_still_cancellable` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs); "the deposit + can now be cancelled and the asset returns" in + [`scripts/harness/guards.test.ts`](../scripts/harness/guards.test.ts). + +### X8. Wind-down + +A wind-down is used to trap holders, is stalled, or pays some holders at +others' cost. + +- **Control:** Governance proposes it. Once its delay of 7 to 90 days has run, + anyone can activate it; until then governance may cancel it. From the + proposal on, the vault takes no new deposit. Once active, it cannot be + undone. It takes no new requests, prices nothing and sends nothing to the + custodian, while priced claims, cancellations and funding go on. Anyone + finalizes a round. Each round credits only the free reserve, pro rata to a + supply snapshot taken at the first round, and every payment rounds in the + vault's favour. The snapshot counts holders' shares only: the share token + refuses a transfer into the vault. +- **Residual:** + - It pays out only what is on chain. The vault cannot make the custodian + return capital. + - A holder who never claims is never paid. Rounding leaves less than one unit + per holder in `wind_down_owed` at any price the oracle allows, plus the + value of any shares the snapshot counted that were never minted. + - No queued change applies during a wind-down except a grant of the token's + `compliance` role, so nothing else can be fixed then, a failing module + included (X6). + - `uncovered()` leaves out what the rounds owe, although `free_reserve()` + subtracts it. After a loss of reserve, such as an issuer clawback (X10), + `uncovered()` can read zero while the vault cannot pay both its priced exits + and its round credits. A claim of either kind can then take cash owed to the + other, and a wind-down claim, which checks no coverage at all, can also take + deposit escrow. This stays a stated limit (Known limits, item 5): the vault + does not decide who bears a loss of reserve (X10), and funding the gap + lifts it. + - Shares sent to a vault before its token was upgraded stay in the snapshot, + and no one can claim their slice. +- **Tests:** `only_governance_proposes`, + `governance_proposes_and_anyone_activates_after_the_delay`, + `governance_cancels_a_proposal_before_activation`, + `an_active_wind_down_cannot_be_cancelled`, `anyone_may_finalise_a_round`, + `a_round_leaves_priced_liabilities_alone`, + `the_payouts_never_exceed_what_the_rounds_credited`, + `multiple_rounds_with_floor_dust_remain_solvent_for_last_claimant`, + `a_round_of_one_unit_at_the_lowest_price_still_credits_it`, + `at_the_lowest_price_holders_collect_the_reserve_less_a_unit_each`, + `the_claimable_view_and_the_claim_fail_alike_at_the_overflow_edge`, + `the_claimable_view_shows_what_the_claim_then_pays` and + `the_vault_cannot_claim_against_its_own_escrowed_shares` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs); + `wind_down_delay_elapsed_refuses_before_the_activation_time` (the guard + `activate_wind_down` runs) and + `holder_not_the_vault_refuses_the_vault_itself` in + [`guards.rs`](../contracts/async-vault/src/test/guards.rs). For + `uncovered()` with round credits, none: the limit is stated, not built. + +### X9. Rounding and amounts below one share + +Rounding lets a claim take more than its share, or a tiny deposit is lost. + +- **Control:** Conversions round down, in the vault's favour. Claims round one + by one, so together they never pay more than pricing promised. When an + epoch's last request settles, the remainder leaves `committed` and + `pending_mint_shares` and stays in the reserve (`EpochReconciled`). A running + total that would go below zero fails (`AccountingMismatch`) instead of being + clamped. A deposit worth less than one share mints nothing and is refunded at + claim, from the free reserve. +- **Residual:** Rounding remainders stay with the fund. A request that never + settles keeps its epoch's remainder in the totals. The refund for a deposit + below one share is not set aside when the epoch is priced: the treasury or a + wind-down round can spend that cash first, and the claim then waits for the + free reserve. +- **Tests:** `deposit_and_redeem_round_trip_never_manufactures_assets` and + `conversion_rounds_down_in_the_vaults_favour` in + [`conversions.rs`](../contracts/async-vault/src/test/conversions.rs); + `the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once` and + `a_total_that_would_go_below_zero_fails_loudly` in + [`settlement.rs`](../contracts/async-vault/src/test/settlement.rs); + `a_deposit_below_one_share_is_refunded_at_claim` and + `a_refund_after_pricing_leaves_the_priced_total_unchanged` in + [`deposit.rs`](../contracts/async-vault/src/test/deposit.rs); + `a_dust_refund_never_takes_committed_money` in + [`cancel.rs`](../contracts/async-vault/src/test/cancel.rs); + `a_zero_share_refund_is_refused_once_a_round_has_taken_the_reserve` in + [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs). + +### X10. The deposit asset's issuer freezes or claws back the vault's cash + +The issuer of the deposit asset freezes the vault's balance or takes it back. + +- **Control:** The kit cannot stop the issuer; it is a declared risk of the + chosen asset. A payout goes only to the holder's own address. After a + clawback, the vault pays in this order and refuses the rest by name: + 1. Escrow a depositor can still cancel. `cancel_deposit` pays when the + vault's balance covers that deposit's own amount; otherwise + `EscrowNotCovered`. While the balance is below the total cancellable + escrow, `request_deposit` refuses with `EscrowNotCovered`, so a new + deposit never pays an earlier cancellation. + 2. Priced redemptions. `claim_redeem` pays when the liquid reserve (the + balance less cancellable escrow) covers its own amount; otherwise + `ClaimNotCovered`, and the claim stays owed in `committed`. + 3. What wind-down rounds owe sits beside priced redemptions in the free + reserve. + 4. Last, and only from the free reserve: a refund of a deposit below one + share (`claim_deposit`, `ClaimNotCovered`) and a transfer to the custodian + (`deploy_to_custodian`, `ReserveCommittedToExits`). + + `uncovered()` shows the gap, `committed + cancellable_escrow - balance` when + positive. Once the treasury funds it with `fund`, the refusals for + cancellations, priced claims and new deposits lift. A refund of a deposit + below one share and a transfer to the custodian also need free reserve, which + `uncovered()` does not show. +- **Residual:** + - Within each class, whoever acts first is paid first. The vault does not + decide who finally bears the loss. + - Pricing does not read the balance. An epoch priced during the gap mints its + deposits in full, and the loss reaches holders through the NAV. + - A freeze leaves the vault's balance readable, so the vault's checks pass + and the transfer fails inside the asset with the asset's own error. Every + cash exit waits until the freeze lifts. + - No deploy script reads the asset's authorization flags yet, although the + design doc's §8.10 says a deployment checks and reports them. + - In a wind-down, `uncovered()` understates the gap, and a wind-down claim + checks no coverage before it transfers (X8). It can take deposit escrow, or + fail inside the asset with the asset's own error when the balance is short. +- **Tests:** `a_cancellation_the_balance_cannot_cover_is_refused_by_name`, + `cancellations_are_paid_in_the_order_they_come_while_the_balance_lasts`, + `no_deposit_is_taken_while_the_balance_is_below_the_escrow`, + `a_priced_redemption_waits_while_the_issuer_has_taken_the_escrow`, + `a_refund_below_one_share_waits_while_the_issuer_has_taken_the_escrow`, + `nothing_goes_to_the_custodian_while_the_issuer_has_taken_the_escrow` and + `pricing_ignores_a_shortfall_and_mints_the_full_deposit` in + [`clawback.rs`](../contracts/async-vault/src/test/clawback.rs); + `a_claim_waits_until_the_reserve_covers_it` in + [`multi_epoch.rs`](../contracts/async-vault/src/test/multi_epoch.rs). None + freezes the vault's balance. + +### X11. Storage that expires + +An idle contract's instance, code or entries archive, so calls fail. + +- **Control:** Each contract extends its instance and the kit's own entries on + the calls kit spec §5 lists: on the vault, every call that reads or writes + them; on the token, every call that moves shares or changes a setting. + OpenZeppelin extends an entry when it reads it, and the kit reads back each + entry it creates through OpenZeppelin, so a new entry gets its full lifetime + in the transaction that creates it. Role entries are the exception: after a + grant, a role check extends only that account's role entry, and a revoke + rewrites the member list without extending it. + `npm run extend -w scripts/harness` restores and extends each instance, its + Wasm and each pending change. A call can restore archived entries itself once + simulation adds the restore + ([kit spec §5](./kit-spec.md#5-storage-lifetime-ttl-policy)). +- **Residual:** An entry nobody touches archives 30 days after its last renewal + (90 for a role), and on mainnet no sooner than 120 days after it was created. + The next call that needs it pays the restore, and restoring many entries can + take separate transactions. A production compliance module or identity + registry must extend its own entries the same way, or the share token stops + working when they archive. +- **Tests:** `any_vault_read_restores_the_instance_ttl` and + `a_pending_request_still_claims_after_most_of_a_month` in + [`ttl.rs`](../contracts/async-vault/src/test/ttl.rs); + `reading_a_decayed_entry_restores_the_full_ttl` in + [`strata-storage/src/test.rs`](../crates/strata-storage/src/test.rs). + +### X12. Resource limits, contention and griefing + +Many requests in one epoch, or someone who wants to get in the way, push calls +past a transaction's limits, make them compete for the same entries, or keep +others out. + +- **Control:** Pricing and wind-down rounds read only running totals, so their + cost does not grow with the number of requests, and each holder mints, burns + or is paid in their own call + ([design doc §8.4](./strata-product-and-architecture.md#84-request-lifecycle)). + No call walks over holders or requests. A holder has at most one deposit and + one redemption request per epoch. Whoever sends a request pays its fee and + the storage for its entry. Deposits need an allowlisted address, and + redemptions need shares. +- **Residual:** Requests, cancellations and claims write entries that all + holders share: the running totals in the vault's instance, each epoch's + record and settlement record, and the vault's own asset and share balances. + Calls in the same ledger compete for those entries, and nothing has measured + how many fit in one ledger. Anyone holding the asset can call `fund` with one + unit, and an allowlisted investor can request and cancel a deposit again and + again, paying only fees. An allowlisted investor can also fill the deposit + cap with a deposit and cancel it before pricing, keeping others out of that + epoch. Governance can raise the cap at once, and compliance can delist the + investor. +- **Tests:** none yet for cost or contention; a measurement is planned. + `cancellation_frees_capacity` in + [`deposit_cap.rs`](../contracts/async-vault/src/test/deposit_cap.rs) shows a + deposit holding the whole cap and its cancellation freeing it; + `the_same_investor_can_request_again_after_cancelling` in + [`cancel.rs`](../contracts/async-vault/src/test/cancel.rs). + +### X13. An upgrade cannot read what the vault stored + +An upgrade changes the shape of a stored type, so the new code cannot read +entries the old code wrote: requests, settlement records, wind-down positions, +or queued changes. + +- **Control:** After setup, upgrades wait for the timelock (R1). The vault, the + share token and the oracle each keep a `schema_version`. The kit spec says + this release is deployed fresh rather than upgraded in place over an earlier + vault, because its settlement record cannot count requests made before it + existed + ([kit spec, Invariant 7](./kit-spec.md#invariant-7-directional-rounding--non-creation-of-value)). + The oracle's own case has a written recovery (O11). +- **Residual:** No release ships a `migrate`; only test builds have one. If an + upgrade changes a stored struct's fields or a variant's payload, every entry + of the old shape fails to decode. Read from the code, not from a test, that + would mean: claims and cancellations on old requests revert, and so do + `pending_changes()`, applying a queued change of the old shape, and proposing + a code change while any change of the old shape is queued (an upgrade, or on + the vault also an upgrade-delay change). Cancelling such a change still + works, since cancelling does not read it. Recovery needs a migration written + for that release. +- **Tests:** `applying_a_wasm_change_replaces_the_code` (a probe build of the + vault with a test-only `migrate`), + `the_token_upgrades_under_live_balances_and_open_requests` + (a token upgrade that keeps the storage shape, under live balances and open + requests), and `the_vault_upgrades_under_open_requests_and_a_proposed_wind_down` + (a vault upgrade that keeps an open epoch, a priced redemption, a queued + change and a proposed wind-down, and lets the new code pay, apply, price and + wind down from them), all with `--features upgrade_wasm`, in + [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs); + `the_oracle_upgrades_under_a_live_valuation`, also with `upgrade_wasm`, in + [`nav-oracle/src/test/governance.rs`](../contracts/nav-oracle/src/test/governance.rs). + A vault upgrade over stored entries of another shape: none, because no + release changes a stored type yet. The failures above are read from the + code, and a test belongs with the first release that changes one. + +## Known limits + +These are decided, and stated as they stand: + +1. A compliance module that refuses can block a covered exit, and removing it + waits for the timelock (X6). Which modules mainnet uses is decided in + [#238](https://github.com/BootNodeDev/strata-vault-kit/issues/238). +2. A stopped manager holds exits at a price back until the open epoch has run + the minimum epoch duration, then anyone seals; with none set, holders wait + until governance replaces the manager (R8). +3. The identity verifier and compliance contracts in this repository are + testnet stubs, not for mainnet (R9, R11). +4. The vault's constructor is the only check on who holds which role, for four + pairs; later grants and the other contracts' roles are not checked (R5). The + share token does refuse a legal transfer or recovery signed by one account + as both compliance and admin (R10). The checks are being redesigned so that + a deployment may choose to run every role from one key, which the + constructor's four refusals forbid today at construction. +5. `uncovered()` leaves out what a wind-down owes, although `free_reserve()` + subtracts it, and a wind-down claim checks no coverage, so after a loss of + reserve it can spend deposit escrow (X8, X10). +6. No test signs through a relayer: the kit runs none, and whoever sends a + transaction pays its fee (R4). +7. The testnet deployment in the README runs older code than this commit + ([Scope](#scope)). +8. A clawback of the vault's cash by the asset's issuer is a loss of the fund + that the vault does not assign. While it lasts, the vault refuses by name + what its balance cannot cover: `EscrowNotCovered` for cancellations and new + deposits, `ClaimNotCovered` for claims (X10). + +## What has no test yet + +For Tranche 3's matrix: + +- Each authority's signers and thresholds, read on the network (R4). +- A privileged call sent through a relayer: dropped, since the kit runs no + relayer (R4). +- Role separation after construction and across contracts, which nothing + checks yet (R5). +- `uncovered()` with wind-down credits (X8). +- The deposit asset's issuer freezing the vault's balance, and a deploy check + of the asset's authorization flags (X10). +- The cost of a call, and contention between calls in one ledger (X12). +- A vault upgrade over requests, positions or queued changes stored in an + earlier shape (X13). +- Alerts on large price moves, on `LatestCleared` and on the report after it + (O2, O8), which belong to the [monitoring plan](./MONITORING_PLAN.md). + +## Out of scope + +The quality of the KYC behind the allowlist; the data and method behind each +valuation; the custodian's operations and solvency; the deposit asset's issuer +beyond X10; how the authorities keep and rotate their keys; who hosts a +frontend, and how; external compliance and identity contracts beyond the checks +in R11; OpenZeppelin's libraries and the Stellar network itself. diff --git a/docs/evidence/sample-valuation.md b/docs/evidence/sample-valuation.md new file mode 100644 index 00000000..103563ee --- /dev/null +++ b/docs/evidence/sample-valuation.md @@ -0,0 +1,9 @@ +# Sample valuation statement (testnet) + +This is a sample. It describes no real fund, and the prices attested with it are test values. + +- Fund: Strata testnet sample vault +- Basis: net asset value per share, in the vault's deposit asset +- Prepared by: the Strata team, for testnet demonstrations only + +An attestation cites this file by its SHA-256 hash and its URL. Changing a single byte changes the hash, so each attestation shows which version it relied on. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 80ce0d84..cb9ab550 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -21,8 +21,16 @@ The contracts enforce a set of mathematical and state-machine invariants across $`\text{free\_reserve} = \max(\text{liquid\_reserve} - \text{committed} - \text{wind\_down\_owed}, 0)`$ $`\text{uncovered} = \max(\text{committed} - \text{liquid\_reserve}, 0)`$ -- **Testable Property**: `deploy_to_custodian(assets)` is refused with `ReserveCommittedToExits` when $`\text{assets} > \text{free\_reserve}`$, so after every operation $`\text{held} \ge \text{cancellable\_escrow}`$. While $`\text{uncovered} > 0`$, the free reserve is zero and nothing leaves for the custodian. +- **Testable Property**: `deploy_to_custodian(assets)` is refused with `ReserveCommittedToExits` when $`\text{assets} > \text{free\_reserve}`$, so after every operation $`\text{held} \ge \text{cancellable\_escrow}`$, unless the asset's issuer takes from the vault's balance. While $`\text{uncovered} > 0`$, the free reserve is zero and nothing leaves for the custodian. - **Enforcement**: `deploy_to_custodian` is the only outbound path to the custodian, and it moves at most the free reserve. +- **When the vault holds less than it owes**: the asset's issuer can claw back part of the vault's balance. The kit cannot stop that. The vault then pays in this order and refuses the rest by name until it is funded: + 1. Escrow a depositor can still cancel is paid first. `cancel_deposit` pays from the vault's whole balance when that balance covers the deposit's own amount, in any order; otherwise `EscrowNotCovered`. While $`\text{held} < \text{cancellable\_escrow}`$, `request_deposit` is refused with `EscrowNotCovered`, so a new deposit never pays an earlier cancellation. + 2. Priced redemption claims come next. Each pays when the liquid reserve covers its own amount, in any order; otherwise `ClaimNotCovered`. + 3. What wind-down rounds owe sits beside priced claims in the free reserve. A wind-down claim's own gap is described in [THREAT_MODEL X8](./THREAT_MODEL.md#x8-wind-down). + 4. A refund of a deposit below one share and a transfer to the custodian come last, only from the free reserve (`ClaimNotCovered`, `ReserveCommittedToExits`). + 5. `uncovered()` shows the gap, $`\text{committed} + \text{cancellable\_escrow} - \text{held}`$ when positive. Funding that gap with `fund` lifts the refusals for cancellations, priced claims and new deposits. A refund of a deposit below one share and a transfer to the custodian also need free reserve, which `uncovered()` does not show. + 6. The vault does not decide who finally bears the loss. Until treasury funds the gap, whoever acts first within a class is paid first. Pricing does not read the balance: an epoch priced during the gap mints its deposits in full, and the loss reaches holders through the NAV. + 7. A freeze of the vault's balance, unlike a clawback, leaves the balance readable. The vault's checks pass, and the asset refuses the transfer with its own error. ### Invariant 2: Notice Period Respected - **Definition**: An epoch cannot be fulfilled or priced until its standing notice period has fully elapsed. @@ -37,14 +45,15 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Mathematical Statement**: $`\text{attested\_at} \ge \text{closed\_at}`$ - **Testable Property**: If the latest oracle attestation timestamp precedes `closed_at`, `fulfill_epoch` is rejected with `AttestationBeforeClose`. A valuation accepted in the exact closing ledger timestamp or later is accepted. + `attested_at` is the ledger time the oracle accepted the report, stored as `accepted_at`, not a valuation date: the attester submits no time of its own. So "accepted before the close" compares acceptance time. The reporter's `expires_at` can only shorten a report's life: the oracle stores `min(expires_at, attested_at + freshness_duration)`. ### Invariant 4: Covered Claims Always Pay (Exit Guarantee) - **Definition**: Once an exit is priced and covered by the liquid reserve, its cash payout cannot be blocked by an administrative pause, oracle staleness, or an investor's compliance delisting. - **Testable Property**: If `liquid_reserve >= assets_owed`, `claim_redeem` succeeds when: 1. The vault is paused (`paused == true`). - 2. The oracle feed has expired (`is_stale == true`) or ripcord is raised. - 3. The caller has been de-listed in the `IdentityVerifier`. - Priced claims are never re-priced and never identity-gated. A frozen investor is outside this guarantee (design document §8.1). + 2. The oracle feed has expired (`is_stale == true`) or the NAV is paused. + 3. The caller has been de-listed in the `IdentityVerifier`, unless the claim pays zero assets outside a wind-down (see below). + Priced claims are never re-priced, and a cash payout never needs the identity check. A frozen investor's covered claim is held until the freeze is lifted (`VaultError::Frozen`); the cash stays in `committed`. One priced claim pays no cash: a redemption priced at zero assets gives its escrowed shares back, so the holder must be able to receive them. Outside a wind-down, a delisted holder's such claim is refused with `HolderNotEligible` and the request stays until the holder is listed again; in a wind-down the claim burns the shares instead (`a_delisted_holders_redemption_that_pays_nothing_waits_until_relisted`, `a_delisted_holders_worthless_redemption_burns_in_a_wind_down`). ### Invariant 5: Exit Rights Never Gated or Pausable - **Definition**: Investors cannot be trapped in the vault by a vault pause. @@ -52,15 +61,22 @@ The contracts enforce a set of mathematical and state-machine invariants across - `request_deposit` is refused by `#[when_not_paused]` (`PausableError::EnforcedPause`). - `request_redeem` succeeds and locks shares into escrow. - `claim_redeem` and `claim_deposit` remain open. - - `cancel_redeem` remains open; only the share token's own transfer pause can refuse it (Invariant 6). + - `cancel_redeem` remains open. Outside a wind-down, a delisted holder, or one the compliance rules refuse, exits through the cash claim instead; in a wind-down the escrow is surrendered. A frozen holder is held (Invariant 6). + - `cancel_deposit` and `cancel_redeem` stay open for a sealed epoch while the vault is paused, because a paused vault cannot price it (Invariant 6). ### Invariant 6: Single-Step Atomic Cancellation -- **Definition**: Cancellation is an all-or-nothing operation, open while an epoch cannot be priced and subject to the share token's transfer pause for redemptions. +- **Definition**: Cancellation is all-or-nothing, and it is open exactly while the vault could not price the epoch now. Three rules decide it: + 1. A readable price closes cancellation, even while the notice still runs. A price is readable when the feed answers that it is valid, its report was accepted at or after the epoch closed, and it answers a share price above zero. A feed that fails to answer any of these has no readable price. + 2. If the vault can no longer price the epoch — the report expires, the NAV is paused, the vault is paused, or the feed fails to answer or prices at zero or less — cancellation opens again. That is the way out, so escrow is never stuck behind a stalled feed. An investor cannot cause the reopening; only a lapsed attester, a raised NAV pause, a paused vault or a broken feed installed by governance can. + 3. The epoch is priced at the latest valid report at fulfil time that was accepted at or after the close — not necessarily the report that first closed the window. - **Testable Property**: - - While an epoch is `Open`, or `Pending` with `priceable_at > now` (or feed stale/paused), `cancel_deposit` and `cancel_redeem` return 100% of escrowed assets/shares atomically. - - `cancel_redeem` is refused with `PausableError::EnforcedPause` while the share token's transfer pause is active; the request stays pending and the cash claim remains open. - - Once `now >= priceable_at` and a fresh attestation is available, cancellation is refused with `PriceAvailable`. + - While an epoch is `Open`, or `Pending` with no readable price or with the vault paused, `cancel_deposit` and `cancel_redeem` return 100% of the escrow, unless an issuer clawback leaves the balance below the deposit (`EscrowNotCovered`, see Invariant 1). + - A readable price refuses cancellation with `PriceAvailable`, even before `priceable_at`; once that report expires, cancellation works again (`cancellation_reopens_when_the_price_that_closed_it_expires`). + - Pausing the vault reopens cancellation; unpausing closes it again while the price is still readable (`a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price`). + - Outside a wind-down, `cancel_redeem` refuses a delisted holder (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`); the request stays pending and the cash claim remains open. It holds a frozen holder (`Frozen`): the request stays pending, and its cash claim is held too until the freeze is lifted. Neither the share token's transfer pause nor the vault's own allowlist place stops a cancellation (`a_token_pause_stops_trading_but_not_a_share_return`, `a_delisted_vault_still_returns_escrowed_shares`). + - In an active wind-down, `cancel_redeem` never returns shares: the escrow joins the holder's wind-down entitlement (`RedemptionSurrendered`), whether the holder is allowlisted or not. A frozen holder's escrow stays until the freeze is lifted. - Once fulfilled, cancellation is refused with `AlreadyPriced`. +- **Operating note**: keep `freshness_duration` longer than the notice, so a normal epoch's price does not lapse and reopen the window on its own. ### Invariant 7: Directional Rounding & Non-Creation of Value - **Definition**: Arithmetic conversions round in the vault's favor, preventing arbitrage and value creation from rounding residue. @@ -72,21 +88,44 @@ The contracts enforce a set of mathematical and state-machine invariants across $`\text{assets\_to\_shares}(\text{shares\_to\_assets}(s)) \le s`$ - **Testable Property**: For all values of $`a, s \in [1, 10^{30}]`$ and $`\text{NAV} \in [\text{min\_answer}, \text{max\_answer}]`$, a round-trip conversion never creates value. Remainder satisfies: $`(a \cdot \text{SCALE}) - (s \cdot \text{NAV}) < \text{NAV}`$ +- **Settlement**: pricing records what each epoch promised (`shares_promised`, `assets_promised`, see `get_epoch_settlement`). Claims floor one by one, so together they pay at most that. When the last request of a priced epoch settles, the remainder leaves `pending_mint_shares` and `committed` and the vault publishes `EpochReconciled { epoch, share_dust, cash_dust }`. The cash stays in the reserve, owed to nobody, and reaches every holder through the NAV. A running total that would go below zero fails with `AccountingMismatch` (6070) instead of clamping. A deposit refunded after pricing (worth less than one share base unit) leaves the priced epoch's `total_deposited` as `EpochFulfilled` published it; only a cancellation still open for the epoch lowers it. +- **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder may not take the shares back (`HolderNotEligible`, `a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch had redemption requests — because that request was never owed any cash. A priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`) is not rounding dust: the mint reverts, so that deposit's whole share entitlement stays in `pending_mint_shares`, on top of the epoch's own remainder, until the mint is allowed. Either way it is still owed to nobody. +- **A deposit worth less than one share base unit** mints nothing and is refunded at claim, from the free reserve (`a_deposit_below_one_share_is_refunded_at_claim`). Pricing moves the epoch's whole deposit into the reserve and records no refund, so `deploy_to_custodian` or a wind-down round can spend that cash first; the claim then fails with `ClaimNotCovered` until the free reserve covers it again (`a_dust_refund_never_takes_committed_money`, `a_zero_share_refund_is_refused_once_a_round_has_taken_the_reserve`). This is accepted: pricing sees only the epoch's totals, so reserving these refunds would mean visiting every request, and each is worth less than one share base unit. + +**Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version is therefore deployed fresh: an earlier vault is not upgraded to it in place. ### Invariant 8: Bounded Governance Timelocks - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. - **Testable Property**: - - Upgrade delay $`\in [7 \text{ days}, 90 \text{ days}]`$ and $`\text{delay} \ge \text{notice}`$. - - Wind-down delay $`\le 90 \text{ days}`$. - - Applying an upgrade before $`\text{proposed\_time} + \text{delay}`$ reverts with `UpgradeDelayNotElapsed`. - - Pausing freezes the upgrade timelock clock and pushes the ETA by the duration paused. + - Upgrade delay $`\in [7 \text{ days}, 90 \text{ days}]`$ and $`\text{delay} \ge \text{notice}`$, checked when a delay change is proposed and again when it applies. + - Wind-down delay $`\in [7 \text{ days}, 90 \text{ days}]`$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set. + - The vault takes no deposit, redemption, seal or pricing until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, the token has not frozen the vault's address, and the token is bound to its compliance contract. It also needs the asset to have the token's decimals, four different addresses for the asset, the token, the oracle and the vault, an oracle that answers `state()` and `freshness_duration()`, a custodian other than the vault, and the vault's own books empty: the first epoch, with no deposit or redemption booked in it, nothing committed, and no shares owed or to burn. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its own setup closed; one that does not answer them is an external module and is accepted. Shares minted before setup closes would redeem against depositors' money, and a request booked before setup would be paid from it, so both the token supply and the vault's books must be empty. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). + - After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies. + - Minimum epoch duration $`\in [1 \text{ day}, 30 \text{ days}]`$ (`InvalidAmount`); `None` until governance sets one, so only the manager seals. Set directly during setup; afterwards both raising and lowering it go through the queue, unlike the notice and wind-down delay, where raising stays instant. Once set, it stays within bounds and cannot be cleared back to `None`: manager-only sealing cannot return. + - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. + - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. + - Time spent paused does not count. Applying while paused is refused. + - Nothing is proposed or applied during an active wind-down, and no admin hand-over is accepted (`WindDownActive`). The one exception is a grant of the share token's `compliance` role, which waits the normal delay. + - One code change (upgrade or upgrade delay) waits at a time (`UpgradeProposalExists`). - Renouncing admin is permanently refused (`AdminRequired`). + - Once its one-time setup closes, the share token's upgrades, grants, `manager` revocations, admin hand-overs and compliance or verifier swaps wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. Revoking `compliance` stays instant, granting it also lands during a wind-down, and the last holder cannot renounce (`LastComplianceHolder`). + - A compliance swap needs a contract that lists the token in `linked_tokens`; one that answers `vault()` and `setup_open()` must also name the token's vault with its own setup finished (`ComplianceNotBound`). As at `finish_setup`, one that does not answer them is an external module and is accepted. A verifier swap needs a verifier whose `verify_identity` accepts the vault's address (`VerifierRefusesVault`). Both are checked when the change is proposed and again when it applies, and a contract that does not answer `linked_tokens` or `verify_identity` fails the check. + - Once its one-time setup closes, the oracle's upgrades, grants, admin hand-overs and every config change except a shorter `freshness_duration` wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. Revoking `attester` or `guardian`, the NAV pause and `clear_latest` stay instant. + - Once its one-time setup closes, the compliance contract's module changes, token bindings and admin hand-overs wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. ### Invariant 9: Wind-Down Pro-Rata Solvency -- **Definition**: In an active wind-down, total round payouts across all share claims can never exceed the capital credited to that round. -- **Mathematical Statement**: - $`\sum_{i} \text{payout}_i \le \text{round\_assets}`$ -- **Testable Property**: In any distribution round with arbitrary share distributions and rounding remainders, the vault remains solvent for the last claimant. Active wind-down is strictly irreversible. +- **Definition**: In an active wind-down, total round payouts across all share claims can never exceed the capital the rounds have credited. +- **Mathematical Statement**: with $`D`$ the assets all rounds credited and $`S`$ the snapshot, + $`\text{payout}_i = \lfloor e_i \cdot D / S \rfloor`$ and $`\sum_i \text{payout}_i \le D`$. +- **Testable Property**: In any distribution round with arbitrary share distributions and rounding remainders, the vault remains solvent for the last claimant. A round credits the whole free reserve at any price the oracle allows: the vault records assets credited, not a rate per share, so a round never rounds to zero while the pot holds one unit (`a_round_of_one_unit_at_the_lowest_price_still_credits_it`). Once every holder has claimed, `wind_down_owed` keeps less than one asset base unit per holder (`at_the_lowest_price_holders_collect_the_reserve_less_a_unit_each`). Active wind-down is strictly irreversible. +- `wind_down_claimable` and `claim_wind_down` compute the payout with one function and fail alike (`the_claimable_view_and_the_claim_fail_alike_at_the_overflow_edge`). +- The vault's own address is refused with `NoEntitlement`, since the shares it holds are escrow (`the_vault_cannot_claim_against_its_own_escrowed_shares`). +- A proposal already stops new deposits (`WindDownAlreadyProposed`); exits, pricing and treasury go on until it activates, and cancelling the proposal reopens deposits. +- An escrowed redemption cancelled during the wind-down counts toward the holder's entitlement, and so does a priced deposit a delisted holder claims. Credited before the first round, those shares count in its supply snapshot (`a_credit_before_the_first_round_leaves_every_holder_their_share`). +- During an active wind-down, a priced redemption whose claim pays nothing burns its escrowed shares instead of returning them, because the snapshot left them out (`a_redemption_that_pays_nothing_returns_no_shares_in_a_wind_down`). +- The share token refuses a plain transfer into the vault, so the snapshot counts holders' shares only (`shares_sent_to_the_vault_are_refused_so_the_snapshot_counts_holders_only`). Shares sent to a vault before its token was upgraded to carry this refusal stay in the snapshot, with no one able to claim their slice. +- Each payout floors per holder. Once every holder whose shares were all surrendered has claimed, `wind_down_owed` keeps less than one unit per such holder, plus whatever value the snapshot counted in shares that never actually minted (for example `pending_mint_shares` from an epoch that promised more shares than it later minted). That remainder is not released: a later round lets each holder's floor catch up and needs it (`a_later_round_needs_the_units_an_earlier_round_left_in_owed`, `owed_stays_below_the_holder_count_once_every_share_is_surrendered`). +- A frozen holder's slice stays in `wind_down_owed` until the freeze lifts, and lifting it needs a holder of the share token's `compliance` role. So the claim depends on a live compliance holder: the last one cannot renounce, a grant of the role passes the token's timelock during a wind-down after the normal delay, and a deployment keeps a backup holder granted during setup (§4) (`a_frozen_holder_is_paid_once_a_compliance_holder_granted_in_the_wind_down_lifts_the_freeze`). --- @@ -103,30 +142,32 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`share_token`** | `name` | `String` | Human-readable token name (e.g. `"Strata Vault USDC"`) | `share_token.constructor_args` | | **`share_token`** | `symbol` | `String` | Token ticker symbol (e.g. `"bvUSDC"`) | `share_token.constructor_args` | | **`share_token`** | `admin` | `Address` | Token administrative multisig (Governance) | `share_token.constructor_args` | -| **`share_token`** | `compliance_authority` | `Address` | Compliance multisig; receives the `compliance` role for freezes, forced transfers and recovery. The vault's `manager` role is granted after deploy | `share_token.constructor_args` | +| **`share_token`** | `compliance_authority` | `Address` | Compliance multisig; receives the `compliance` role for freezes, plus legal transfers and recovery with governance's co-signature. The vault's `manager` role is granted after deploy | `share_token.constructor_args` | | **`share_token`** | `compliance` | `Address` | Address of deployed `compliance` contract | `share_token.constructor_args` | | **`share_token`** | `identity_verifier` | `Address` | Address of deployed `identity_verifier` contract | `share_token.constructor_args` | | **`nav_oracle`** | `admin` | `Address` | Oracle admin multisig (Governance) | `nav_oracle.constructor_args` | | **`nav_oracle`** | `attester` | `Address` | Authorized reporter multisig for NAV attestations | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `guardian` | `Address` | Guardian multisig authorized to raise ripcord | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.freshness_duration` | `u64` (seconds) | Validity window of an attestation before it is stale ($`> 0`$) | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.cooldown_secs` | `u64` (seconds) | Minimum time between consecutive price attestations | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.max_up_bps` | `u32` (basis points) | Maximum allowable upward price jump (1 bps = 0.01%, $`> 0`$) | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.max_down_bps` | `Option` (bps) | Maximum allowable downward jump (`null` = loss of any size allowed) | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.min_answer` | `i128` (18 decimals) | Absolute floor price per share (cannot be zero or negative) | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.max_answer` | `i128` (18 decimals) | Absolute ceiling price per share ($`\ge \text{min\_answer}`$) | `nav_oracle.constructor_args` | -| **`async_vault`** | `asset` | `Address` | Settlement asset SAC contract address (e.g. USDC) | `async_vault.constructor_args` | +| **`nav_oracle`** | `guardian` | `Address` | Guardian multisig authorized to pause the NAV | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.freshness_duration` | `u64` (seconds) | Validity window of an attestation before it is stale; from 1 s to 35 days (`MAX_FRESHNESS_SECS`) | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.cooldown_secs` | `u64` (seconds) | Minimum time between consecutive attestations. At least 1 s (`MIN_COOLDOWN_SECS`), so at most one report per ledger. At most `freshness_duration`, so a new report can always replace the last one before the freshness window ends | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.max_up_bps` | `u32` (basis points) | Largest upward move per attestation, 1 to 10,000 | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.max_down_bps` | `u32` (basis points) | Largest downward move per attestation, 1 to 10,000; 10,000 lets a loss of any size land at once | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.min_answer` | `i128` (18 decimals) | Absolute floor price per share, at least `MIN_ANSWER` ($`10^{12}`$, a millionth of an asset unit per share). An oracle upgraded in place with a stored `min_answer` below the floor keeps it; even its instant `set_config` (a shorter freshness window) is refused until a timelocked `Config` change raises it to the floor | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.max_answer` | `i128` (18 decimals) | Absolute ceiling price per share ($`\ge \text{min\_answer}`$, $`\le 10^{6} \times 10^{18}`$, `MAX_ANSWER`) | `nav_oracle.constructor_args` | +| **`async_vault`** | `asset` | `Address` | Settlement asset SAC contract address (e.g. USDC). Must have the share token's 7 decimals: `finish_setup` refuses with `SetupIncomplete` otherwise (`health().decimals_match`). Every classic Stellar asset has 7; a Soroban token with other decimals needs a fork of the share token | `async_vault.constructor_args` | | **`async_vault`** | `share_token` | `Address` | Deployed share token contract address | `async_vault.constructor_args` | | **`async_vault`** | `oracle` | `Address` | Deployed NAV oracle contract address | `async_vault.constructor_args` | | **`async_vault`** | `roles.governance` | `Address` | Governance multisig authority | `async_vault.constructor_args` | | **`async_vault`** | `roles.treasury` | `Address` | Treasury multisig authority | `async_vault.constructor_args` | | **`async_vault`** | `roles.guardian` | `Address` | Guardian multisig authority | `async_vault.constructor_args` | -| **`async_vault`** | `roles.manager` | `Address` | Manager authority (closes epochs) | `async_vault.constructor_args` | +| **`async_vault`** | `roles.manager` | `Address` | Account that gets the vault's `manager` role (closes epochs); a role, not one of the five authorities | `async_vault.constructor_args` | | **`async_vault`** | `roles.compliance` | `Address` | Compliance multisig authority | `async_vault.constructor_args` | -| **`async_vault`** | `roles.attester` | `Address` | Attestation authority (mirrors oracle reporter) | `async_vault.constructor_args` | +| **`async_vault`** | `roles.attester` | `Address` | Attestation authority. Not read: nothing checks that it matches the oracle's attester | `async_vault.constructor_args` | + +The share token's decimals are fixed at 7 in its constructor; they are not a parameter. -#### Pairwise Role Separation Enforced at Construction -The `async_vault` constructor strictly verifies pairwise separation across authorities: +#### Role Separation Checked at Construction +The `async_vault` constructor refuses these four pairs of equal addresses and compares no other: - `treasury != guardian` - `treasury != governance` - `compliance != governance` @@ -138,30 +179,109 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | Parameter | Method | Authority | Constraints & Defaults | |---|---|---|---| -| **Standing Notice** | `set_notice(secs: u64)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $`\text{secs} \le \text{upgrade\_delay}`$ and $`\text{secs} \le 30 \text{ days}`$ (`MAX_NOTICE_SECS`). | -| **Custodian Address** | `set_custodian(custodian: Address)` | Governance | Recipient address for off-chain capital deployment. Required before `deploy_to_custodian`. | -| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital. `None` = unbounded. Refuses negative cap. | -| **Wind-Down Delay** | `set_wind_down_delay(secs: u64)` | Governance | Minimum notice before wind-down activation. Default: `0`. Capped at 90 days (`MAX_WIND_DOWN_DELAY`). | -| **Upgrade Delay** | `propose_upgrade_delay(secs: u64)` | Governance | Timelock delay for wasm upgrades. Must be $`\ge 7 \text{ days}`$, $`\le 90 \text{ days}`$, and $`\ge \text{notice}`$. | -| **Oracle Configuration** | `set_config(config: OracleConfig)` | Governance | Updates oracle parameters. Follows same validation rules as constructor. | +| **Standing Notice** | `set_notice(secs: u64)`; after setup, lowering goes through `propose_change(Notice(secs))` → `apply_change(id)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $`\text{secs} \le \text{upgrade\_delay}`$ and $`\text{secs} \le 30 \text{ days}`$ (`MAX_NOTICE_SECS`), checked again when a `Notice` change applies. Raising is instant. | +| **Custodian Address** | `set_custodian(custodian: Address)` during setup; afterwards `propose_change(Custodian(address))` → `apply_change(id)` | Governance | Recipient address for off-chain capital deployment. Required before `finish_setup` and `deploy_to_custodian`. Never the vault itself (`InvalidCustodian`), checked when it is set, proposed and applied. The deploy sets it before setup closes. | +| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital (see **Supply and Capital Figures**). `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. The cap is vault-wide; there is no cap per investor. | +| **Wind-Down Delay** | `set_wind_down_delay(secs: u64)`; after setup, lowering goes through `propose_change(WindDownDelay(secs))` → `apply_change(id)` | Governance | Minimum notice before wind-down activation. Default and minimum 7 days (`MIN_WIND_DOWN_DELAY`), maximum 90 days (`MAX_WIND_DOWN_DELAY`). Raising is instant. | +| **Minimum Epoch Duration** | `set_min_epoch_duration(secs: u64)` during setup; afterwards `propose_change(MinEpochDuration(secs))` → `apply_change(id)` | Governance | How long the open epoch must stay open before anyone may seal it. From 1 day (`MIN_EPOCH_DURATION_FLOOR`) to 30 days (`MIN_EPOCH_DURATION_CEILING`), checked again when a `MinEpochDuration` change applies (`InvalidAmount`). Default `None`: only the manager seals until governance sets one. After setup, both raising and lowering go through the timelock. | +| **Upgrade Delay** | `propose_change(UpgradeDelay(secs))` → `apply_change(id)` | Governance | Timelock delay for wasm upgrades. Must be $`\ge 7 \text{ days}`$, $`\le 90 \text{ days}`$, and $`\ge \text{notice}`$. | +| **Oracle Configuration** | `set_config(config: OracleConfig)`; after setup, `propose_change(Config(config))` → `apply_change(id)` | Governance | Updates oracle parameters within the constructor's bounds. `set_config` is instant during the oracle's setup. After it, `set_config` is instant only for a shorter `freshness_duration`: later reports go stale sooner, and no value moves. Every other change, tighter limits included, goes through `propose_change(Config(config))` → `apply_change(id)`, and the bounds are checked again when it applies. A tighter fall cap or a longer cooldown can hold the price above a real loss, and a narrower band can stop pricing. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again. | +| **Oracle Feed** | `propose_change(Oracle(address))` → `apply_change(id)` | Governance | Waits for the timelock. The new feed must name this vault through `vault()` with its own setup finished, and must answer `state()` and `freshness_duration()`, at proposal and again at apply (`FeedNotBound`). The next fulfilment prices from it, including an epoch sealed before the swap. | + +### Transfer Modes + +The SCF Tranche 2 admin panel lists allowlist management, pause, and transfer modes: disabled, or allowed when both sides are allowlisted. The kit already has both modes, so it adds no separate transfer-mode setting. + +- **Allowed when both sides are allowlisted** is the default and is always on. The share token's `transfer` and `transfer_from` refuse a move unless the sender and the receiver both pass the identity verifier (`IdentityVerificationFailed`) and neither address is frozen (`AddressFrozen`). The token runs these checks itself, whatever the compliance contract answers. The kit's compliance contract registers no modules, so its `can_transfer` accepts any pair. That leaves the decision to the token's own checks; it does not open transfers (`rules_with_no_modules_accept_any_pair_yet_the_token_still_checks_both_sides`). A module can only refuse more moves, and after setup adding one waits for the timelock. +- **Disabled** is the share token's pause. Only the token admin (governance) can call `pause` and `unpause`; anyone else is refused with `AdminRequired`, and the guardian has no role on the token. Both calls take effect at once, before and after setup: they do not go through the timelock. While the token is paused, `transfer` and `transfer_from` are refused with `EnforcedPause` (`the_token_admin_pauses_transfers_at_once_after_setup`). +- **Exits stay open in either mode.** Both modes apply only to transfers between holders. The vault moves shares with `mint`, `burn` and `forced_transfer`, which ignore the pause, and `legal_transfer` ignores it too. While the token is paused, deposit and redemption requests (the redemption escrows shares with `forced_transfer`), cancellations of both, deposit claims (mint) and redemption claims (burn and cash payout) all go through (`a_token_pause_leaves_deposits_exits_and_cancellations_open`, `a_token_pause_stops_trading_but_not_a_share_return`, `a_token_pause_does_not_hold_a_redemption_in_a_wind_down`). A delisted holder still exits in cash (Invariant 4); only a freeze holds an exit, as below. +- **Freezes and the pauses.** A freeze is per holder and set by the compliance authority. It stops that holder's transfers and also holds what the vault owes them until it is lifted (see the [compliance matrix](./COMPLIANCE_MATRIX.md)). The token pause is global and set by governance. It stops every transfer between holders and nothing else. The vault's own pause (the guardian pauses, governance unpauses) is a separate control. It stops deposit requests, pricing and applying queued changes, not transfers. + +### Health View + +`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. `health()` leaves out three `finish_setup` checks: that the share token has no shares outstanding, which stops being true at the vault's first claim, that the vault's own books are empty, which stops being true at its first request, and that no admin hand-over offered during setup is pending (`AdminTransferPending`). If `finish_setup` refuses with `SetupIncomplete` while every field from `token_bound` to `feed_answers` is true, there are two possible causes. Shares were minted before setup: the token's `total_supply()` shows them. Or the vault's books are not empty: `current_epoch()` is past 1, `get_epoch(1)` shows a deposit or redemption booked, or `committed()`, `pending_mint_shares()` or `pending_burn_shares()` is not zero. + +| Field | True when | +|---|---| +| `setup_open` | `finish_setup` has not run yet. While true, admin changes are instant and requests and epochs are refused. | +| `token_bound` | The share token names this vault through `vault()` and its own setup is finished. | +| `oracle_bound` | The oracle names this vault through `vault()` and its own setup is finished. | +| `sole_token_manager` | This vault is the share token's only `manager`. | +| `allowlisted` | This vault passes the share token's identity verifier. Escrowing and returning shares do not need it. Only a delisted holder's conversion does: it mints the shares into the vault's own escrow. While this flag is false, those conversions wait until the vault is allowlisted again, and nothing is lost. | +| `vault_unfrozen` | The share token has not frozen this vault's address. The vault's own share moves ignore an address freeze, so freezing the vault stops nothing; `finish_setup` still refuses a frozen vault. | +| `compliance_bound` | The share token is in its compliance contract's `linked_tokens`, and a compliance contract that answers `vault()` and `setup_open()` names this vault with its own setup finished. One that does not answer them is an external module and passes; its own governance is a trust assumption. | +| `decimals_match` | The asset's `decimals()` equals the share token's. Pricing converts base units at the attested price, so a mismatch would put every deposit and exit off by a power of ten. | +| `addresses_distinct` | The asset, the share token, the oracle and the vault are four different addresses. | +| `custodian_set` | A custodian is set, and it is not the vault itself. | +| `feed_answers` | The oracle answers `state()` and `freshness_duration()`. A new feed with no report passes. `oracle_bound` cannot tell a feed from another contract bound to this vault, such as the compliance contract. | +| `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired, it is in band and the NAV is not paused. | +| `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | + +`freshness_covers_notice` false means a report can expire before the notice ends. A sealed epoch's cancellation window can then reopen on its own, so set `freshness_duration` above the notice. `feed_valid` false right after a deploy is normal until the first attestation. + +### Supply and Capital Figures + +- `total_economic_supply()` counts share base units held outside the vault plus those priced for deposits and not yet minted: the token's `total_supply()`, less the vault's own share balance, plus `pending_mint_shares()`. It leaves out every share in the vault's escrow. That includes the shares of a redemption that is not priced yet: the next price still applies to them, and a cancellation can return them (`economic_supply_excludes_shares_locked_in_redemption_escrow`). The first wind-down round's supply snapshot counts those shares, so the two figures differ while redemptions wait to be priced. +- Deposited capital, which the deposit cap limits, is the asset the vault holds plus `net_deployed()`, less `committed()`. It counts money in and out, not the vault's value (what the vault and the custodian hold): a deposit request adds what it brings, and a priced redemption takes off what it pays. A gain or a loss at the custodian does not move it, even once funded back. After a gain, the figure is below the vault's value. Once leavers are paid the gain, it can go negative, and the cap then admits more than its own amount (`a_gain_paid_out_to_leavers_leaves_room_above_the_cap`). After a loss, the figure stays above the vault's value, and the cap refuses deposits sooner (`a_loss_at_the_custodian_still_counts_toward_the_cap`). +- `net_deployed()` is what `deploy_to_custodian` sent less what `fund` brought back; it goes negative once more came back than went out. The custodian repays through `fund`. A plain transfer to the vault raises the vault's balance but leaves `net_deployed` as it was, so from then on the same money counts twice in deposited capital and the cap refuses deposits sooner. + +### Days and Ledgers + +The kit measures time two ways. Durations in seconds read the ledger's close time, so they do not depend on how often ledgers close: the timelock delay, the notice, the freshness window, the cooldown, the wind-down delay and the minimum epoch duration. + +Figures counted in ledgers assume a ledger every 5 seconds, 17,280 a day. They are the storage lifetimes in §5 (30 days, renewed once 29 or fewer remain; 90 days for role entries, set by OpenZeppelin), the 7 days a new admin has to accept a hand-over (`ADMIN_ACCEPT_LEDGERS`), the 30 days the extend script asks for, and the 7-day window of the app's activity list. + +If the network makes ledgers faster, each ledger-counted figure lasts less on the clock by the same factor; the second-based ones do not change. At 2.5-second ledgers, an entry extended to 30 days lasts 15, so the extend script must run twice as often, and a hand-over must be accepted within 3.5 days. --- ## 3. Role & Authority Matrix -Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority. +Strata distributes control across five native Stellar multisig authorities, two `manager` roles and permissionless public entrypoints. Every privileged function needs exactly one of these authorities or roles, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. Each call asks each account it needs for one signature. An admin entrypoint the kit defines takes no caller argument: the stored admin signs it. ### Authority Capabilities & Anti-Capabilities | Authority | Quorum / Profile | Authorized Capabilities | Anti-Capabilities (What It Cannot Do) | |---|---|---|---| -| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | -| **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `raise_ripcord` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | -| **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | -| **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | -| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`, `forced_transfer`, `recovery`). | **Cannot touch vault reserve**; cannot pause vault operations; cannot bypass exit-only cash redemptions. | -| **Manager** | Operational keeper account / vault | `close_epoch` (vault); `mint`, `burn`, `forced_transfer` (share token). | **Cannot choose prices**; cannot prevent epoch fulfillment; cannot alter timelocks. | -| **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | +| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`, `min_epoch_duration`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, the minimum epoch duration in either direction, upgrades, the token's compliance and verifier swaps, the compliance contract's module and token-binding changes, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | +| **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | +| **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve). Capital comes back through `fund`, which anyone may call. | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | +| **Attester** | High-security attestation multisig | `attest` (submits a NAV report: the price, its expiry and a proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | +| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`; `legal_transfer` and `recovery` co-signed by governance). | **Cannot touch vault reserve**; cannot move a holder's shares without governance's signature in the same call; cannot move shares out of or into the vault's address, or freeze it once the token knows its vault (an earlier freeze makes the vault's `finish_setup` refuse); cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | +| **Manager (vault role)** | Operational keeper account; a role, not one of the five authorities | `close_epoch`: seals the open epoch at any time, nothing else. | **Cannot choose prices**; cannot mint, burn or move shares; cannot stop a sealed epoch from being priced; not sealing stalls requests only until the open epoch has run the minimum epoch duration, if one is set; cannot alter timelocks. After setup, granting or revoking it waits for the timelock. | +| **Manager (share-token role)** | The vault contract, alone: the vault's `finish_setup` refuses while another account holds it | `mint`, `burn`, `forced_transfer` on the share token: the vault mints and burns at claim and moves escrowed shares. | Cannot freeze, recover or make a legal transfer. After setup, granting or revoking it waits for the vault's timelock. See [`AUTHORIZATION_MATRIX.md`](./AUTHORIZATION_MATRIX.md) §2. | +| **Permissionless** | Any caller / keeper / investor | `close_epoch` once the open epoch has run the minimum epoch duration, `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `fund`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | + +### Replacing a Compromised Attester + +Pause first, so no report lands while the roles change, and lift the pause last: + +1. Pause the NAV: the oracle's guardian calls `pause_nav`, or its admin calls `set_nav_paused(true)`. The oracle refuses every report and the vault prices nothing; sealed epochs reopen to cancellation. The vault also refuses deposits (`FeedNotValid`) until the new attester's first report in step 6. +2. Revoke the compromised attester: `revoke_role(attester, "attester", admin)` on the oracle. It is instant, during setup and after it. +3. Grant a replacement. During the oracle's setup, `grant_role` is instant. After it, propose a `GrantRole` change and apply it once the vault's delay has run, plus any time the vault spends paused. The NAV stays paused that whole time, at least seven days, so replacing an attester stops subscriptions for at least the timelock delay. +4. Clear the stored report: `clear_latest` (admin; only while the NAV is paused). Left in place, it is valid again once the pause lifts, and anyone can price with it any epoch sealed before it was accepted, until it expires. Skip this only if you know the report predates the compromise. The next report then has no earlier price to cap its move: only `min_answer` and `max_answer` bound it. +5. Lift the NAV pause: `set_nav_paused(false)` (admin). +6. The new attester posts a report. Pricing resumes: an epoch can be priced once a report accepted after its seal is valid. + +### Upgrading the Oracle from an Earlier Release + +Releases before the proof reference stored the report as `nav_per_share`, `expires_at` and `timestamp`. This version stores `nav_per_share`, `expires_at`, `accepted_at`, `proof_hash` and `proof_uri`, and cannot read a report of the earlier shape. After an in-place upgrade, every call that reads the old report fails: `latest`, `nav_per_share`, `attested_at`, `is_stale` and `attest`, and also `state` and `ensure_consumable` while the NAV is not paused. The vault then cannot price or take a deposit, and cancelling a request in a sealed epoch fails. No migration rewrites the old report. Clear it instead: + +1. Pause the NAV before the upgrade applies: the guardian calls `pause_nav`, or the admin calls `set_nav_paused(true)`. While the NAV is paused, `state` answers `Paused` without reading the report, so the vault prices nothing and requests in sealed epochs stay cancellable. Deposits are refused while the NAV is paused (`FeedNotValid`) or the feed cannot answer, so the vault takes none from here until the attester's report in step 5. If the upgrade applied first, pause the NAV at once: neither `pause_nav` nor `clear_latest` reads the report. +2. Apply the upgrade: `apply_change` once its delay has run. +3. Remove the old report: `clear_latest` (admin). It deletes the entry without reading it. +4. Lift the NAV pause: `set_nav_paused(false)` (admin). +5. The attester posts a report with its proof. As after any clear, no earlier price caps its move: only `min_answer` and `max_answer` bound it. + +The test `a_report_of_the_earlier_shape_is_cleared_and_replaced` stores a report of the earlier shape and recovers it this way. A fresh deployment has no such report and skips these steps. + +### When the Manager or the Attester Stops + +Pricing needs both roles. Once governance has set a minimum epoch duration, anyone can seal in the manager's place; nothing on chain replaces the attester. The kit assumes both keep working; until then, holders keep these ways out: + +- **The manager stops sealing.** Requests in the open epoch stay pending, and each holder can still cancel: a deposit comes back as cash, a redemption as shares. Once governance has set a minimum epoch duration, anyone may seal the open epoch once it has been open that long; the earliest a holder then sees a price is the minimum plus the standing notice, counted from when the epoch opened. Without a minimum set, the way out is the same as today: governance grants a new manager, which after setup waits for the vault's timelock, at least seven days, plus any time the vault spends paused. +- **The attester stops reporting.** Once the last report expires, new deposits are refused (`FeedNotValid`). An epoch sealed after the last report has no readable price, so its requests stay cancellable, and claims already priced still pay. Governance revokes the attester on the oracle at once and grants a replacement, which waits for the timelock after setup, as in steps 2 and 3 above. +- **Neither comes back.** No holder can exit at a price. Governance can propose a wind-down; once its delay has run, anyone activates it, and its rounds pay every holder pro rata from the cash the vault holds and whatever the custodian returns through `fund`, with no price needed. --- @@ -169,47 +289,84 @@ Strata distributes control across five native Stellar multisig authorities and p | Contract | Entrypoint | Calling Authority | Access Control Guard | Pausable? | |---|---|---|---|---| -| **`AsyncVault`** | `request_deposit` | Investor (Caller) | `from.require_auth()` | Yes (halted by pause) | -| **`AsyncVault`** | `cancel_deposit` | Investor (Caller) | `from.require_auth()` | No (open during pause) | -| **`AsyncVault`** | `claim_deposit` | Investor (Caller) | `caller.require_auth()` | No (open during pause) | -| **`AsyncVault`** | `request_redeem` | Investor (Caller) | `from.require_auth()` | No (always open) | -| **`AsyncVault`** | `cancel_redeem` | Investor (Caller) | `from.require_auth()` | No (open during pause) | -| **`AsyncVault`** | `claim_redeem` | Investor (Caller) | `caller.require_auth()` | No (always open) | -| **`AsyncVault`** | `close_epoch` | Manager | `#[only_role(caller, "manager")]` | No | -| **`AsyncVault`** | `fulfill_epoch` | Permissionless | None | Yes (halted by pause) | +| **`AsyncVault`** | `request_deposit` | Investor (Caller) | `holder.require_auth()`; refused until setup finishes (`SetupOpen`), while the feed is not valid (`FeedNotValid`) and while the vault holds less than its pending escrow (`EscrowNotCovered`) | Yes (halted by pause) | +| **`AsyncVault`** | `cancel_deposit` | Investor (Caller) | `holder.require_auth()`; refused while the vault's balance cannot cover the refund (`EscrowNotCovered`) | No (open during pause) | +| **`AsyncVault`** | `claim_deposit` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | +| **`AsyncVault`** | `request_redeem` | Investor (Caller) | `holder.require_auth()`; refused until setup finishes (`SetupOpen`) | No (open during pause; refused in a wind-down) | +| **`AsyncVault`** | `cancel_redeem` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | +| **`AsyncVault`** | `claim_redeem` | Investor (Caller) | `holder.require_auth()` | No (always open) | +| **`AsyncVault`** | `close_epoch` | Manager; anyone once the open epoch has run the minimum epoch duration | `caller.require_auth()`; without `manager`, `MinEpochDurationNotElapsed` before the minimum, `AccessControlError::Unauthorized` when none is set; refused until setup finishes (`SetupOpen`) | No | +| **`AsyncVault`** | `fulfill_epoch` | Permissionless | None; refused until setup finishes (`SetupOpen`) | Yes (halted by pause) | | **`AsyncVault`** | `pause` | Guardian | `#[only_role(caller, "guardian")]` | N/A | -| **`AsyncVault`** | `unpause` | Governance | `#[only_admin]` | N/A | -| **`AsyncVault`** | `set_notice` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `set_custodian` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `set_deposit_cap` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `set_wind_down_delay` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `propose_wind_down` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `unpause` | Governance | `caller` must be the admin (`AdminRequired`), then `caller.require_auth()` | N/A | +| **`AsyncVault`** | `set_notice` | Governance | `#[only_admin]`; raising is instant, lowering is instant during setup, then `propose_change` | No | +| **`AsyncVault`** | `set_custodian` | Governance | `#[only_admin]`; instant during setup, then `propose_change`; never the vault itself (`InvalidCustodian`) | No | +| **`AsyncVault`** | `set_deposit_cap` | Governance | `#[only_admin]`; instant both ways | No | +| **`AsyncVault`** | `set_min_epoch_duration` | Governance | `#[only_admin]`; instant during setup, then `propose_change`; 1 to 30 days | No | +| **`AsyncVault`** | `set_wind_down_delay` | Governance | `#[only_admin]`; raising is instant, lowering is instant during setup, then `propose_change`; never below 7 days | No | +| **`AsyncVault`** | `finish_setup` | Governance | `#[only_admin]`; needs the wiring complete, the vault's own books empty, and no pending admin hand-over; once | No | +| **`AsyncVault`** | `setup_open` | Permissionless | None (view) | No | +| **`AsyncVault`** | `propose_wind_down` | Governance | `#[only_admin]`; new deposits stop until it is cancelled | No | | **`AsyncVault`** | `cancel_wind_down_proposal` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `activate_wind_down` | Permissionless | None (requires timelock elapsed) | No | | **`AsyncVault`** | `finalize_wind_down_round` | Permissionless | None | No | -| **`AsyncVault`** | `claim_wind_down` | Shareholder (Caller) | `caller.require_auth()` | No | +| **`AsyncVault`** | `claim_wind_down` | Shareholder (Caller) | `holder.require_auth()` | No | | **`AsyncVault`** | `deploy_to_custodian` | Treasury | `#[only_role(caller, "treasury")]` | No (open during pause) | -| **`AsyncVault`** | `fund` | Treasury | None (caller transfers funds) | No | -| **`AsyncVault`** | `propose_upgrade` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `propose_upgrade_delay` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `cancel_upgrade` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `apply_upgrade` | Governance | `#[only_admin]` | Refused if paused | -| **`AsyncVault`** | `transfer_admin_role` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `accept_admin_transfer` | Proposed Admin | `caller.require_auth()` | No | -| **`NavOracle`** | `attest` | Attester | `#[only_role(caller, "attester")]` | Refused if ripcord active | -| **`NavOracle`** | `raise_ripcord` | Guardian | `#[only_role(caller, "guardian")]` | N/A | -| **`NavOracle`** | `set_ripcord` | Governance | `#[only_admin]` | N/A | -| **`NavOracle`** | `set_config` | Governance | `#[only_admin]` | No | -| **`NavOracle`** | `clear_latest` | Governance | `#[only_admin]` (requires ripcord) | No | -| **`ShareToken`** | `pause` | Admin (Governance) | `#[only_admin]`, `caller.require_auth()` | N/A | -| **`ShareToken`** | `unpause` | Admin (Governance) | `#[only_admin]`, `caller.require_auth()` | N/A | +| **`AsyncVault`** | `fund` | Anyone: the custodian, treasury or a backer | `caller.require_auth()`; the caller pays | No | +| **`AsyncVault`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | +| **`AsyncVault`** | `cancel_change` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `apply_change` | Governance | `#[only_admin]` | Refused if paused or winding down | +| **`AsyncVault`** | `pending_changes` | Permissionless | None (view) | No | +| **`AsyncVault`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`AsyncVault`** | `revoke_role` | Governance | `treasury`, `guardian`: instant. `manager`: instant during setup, then `propose_change` | No | +| **`AsyncVault`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | +| **`AsyncVault`** | `accept_admin_transfer` | Proposed Admin | The pending admin's `require_auth()`; refused during an active wind-down (`WindDownActive`) | No | +| **`NavOracle`** | `attest` | Attester | `#[only_role(caller, "attester")]` | Refused if the NAV is paused | +| **`NavOracle`** | `pause_nav` | Guardian | `#[only_role(caller, "guardian")]` | N/A | +| **`NavOracle`** | `set_nav_paused` | Governance | `#[only_admin]` | N/A | +| **`NavOracle`** | `set_config` | Governance | `#[only_admin]`; instant during setup, then only for a shorter `freshness_duration`; otherwise `propose_change` | No | +| **`NavOracle`** | `clear_latest` | Governance | `#[only_admin]` (requires NAV paused) | No | +| **`NavOracle`** | `bind_vault` | Governance (oracle admin) | `#[only_admin]`; during setup, once | No | +| **`NavOracle`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | +| **`NavOracle`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | +| **`NavOracle`** | `cancel_change` | Governance | `#[only_admin]` | No | +| **`NavOracle`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay | Refused while the vault is paused or winding down | +| **`NavOracle`** | `pending_changes` | Permissionless | None (view) | No | +| **`NavOracle`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`NavOracle`** | `accept_admin_transfer` | Proposed Admin | The pending admin's `require_auth()`; refused during an active wind-down (`WindDownActive`) | No | +| **`NavOracle`** | `revoke_role` | Governance | Instant (`attester`, `guardian`) | No | +| **`NavOracle`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | +| **`ShareToken`** | `pause` | Admin (Governance) | `caller` must be the admin (`AdminRequired`), then `caller.require_auth()` | N/A | +| **`ShareToken`** | `unpause` | Admin (Governance) | `caller` must be the admin (`AdminRequired`), then `caller.require_auth()` | N/A | | **`ShareToken`** | `mint` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `burn` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | -| **`ShareToken`** | `forced_transfer` | Manager (Vault) or Compliance | `#[only_any_role(operator, ["manager", "compliance"])]` | Bypasses token pause | -| **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens`, `recover_balance` | Compliance | `#[only_role(operator, "compliance")]` | No | -| **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]` | No | +| **`ShareToken`** | `forced_transfer` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | +| **`ShareToken`** | `legal_transfer` | Compliance, co-signed by Governance (token admin) | `#[only_role(caller, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address; the destination passes `verify_identity`; a reference of 1–128 bytes | Bypasses token pause | +| **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens` | Compliance | `#[only_role(operator, "compliance")]`; never freezes the vault's address once the token knows its vault | No | +| **`ShareToken`** | `recover_balance` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address | No | +| **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `operator` must be the admin (`AdminRequired`), then `operator.require_auth()`; instant during setup, then `propose_change` | No | +| **`ShareToken`** | `bind_vault` | Governance (token admin) | `#[only_admin]`; during setup, once | No | +| **`ShareToken`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | +| **`ShareToken`** | `propose_change` | Governance | `#[only_admin]`; after setup; refused while the vault is winding down, except a grant of `compliance`; a compliance or verifier swap passes its check (`ComplianceNotBound`, `VerifierRefusesVault`) | No | +| **`ShareToken`** | `cancel_change` | Governance | `#[only_admin]` | No | +| **`ShareToken`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay; a compliance or verifier swap passes its check again | Refused while the vault is paused; while it winds down too, except a grant of `compliance` | +| **`ShareToken`** | `pending_changes` | Permissionless | None (view) | No | +| **`ShareToken`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`ShareToken`** | `accept_admin_transfer` | Proposed Admin | The pending admin's `require_auth()`; refused during an active wind-down (`WindDownActive`) | No | +| **`ShareToken`** | `revoke_role` | Governance | `compliance`: instant, the last holder included; a successor's grant waits one delay, during a wind-down too. `manager`: instant during setup, then `propose_change` | No | +| **`ShareToken`** | `renounce_role` | The role's holder | `caller.require_auth()`; the last `compliance` holder is refused (`LastComplianceHolder`) | No | +| **`ShareToken`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | | **`IdentityVerifier`** | `allow` | Admin (Compliance) | `#[only_admin]` | No | -| **`IdentityVerifier`** | `set_claim_topics_and_issuers`| Admin (Compliance) | `#[only_admin]` | No | +| **`IdentityVerifier`** | `set_claim_topics_and_issuers`| Admin (Compliance) | `#[only_admin]`; `operator` is not read | No | +| **`Compliance`** | `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | Governance | `#[only_admin]`, `operator` is not read; instant during setup, then `propose_change` | No | +| **`Compliance`** | `bind_vault` | Governance (compliance admin) | `#[only_admin]`; during setup, once | No | +| **`Compliance`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | +| **`Compliance`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | +| **`Compliance`** | `cancel_change` | Governance | `#[only_admin]` | No | +| **`Compliance`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay | Refused while the vault is paused or winding down | +| **`Compliance`** | `pending_changes` | Permissionless | None (view) | No | +| **`Compliance`** | `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`Compliance`** | `accept_admin_transfer` | Proposed Admin | The pending admin's `require_auth()`; refused during an active wind-down (`WindDownActive`) | No | --- @@ -219,11 +376,273 @@ A clean deployment executes in three sequenced phases: 1. **Phase 1: Contract Deployment** - Deploy `compliance`, `identity_verifier`, `share_token`, `nav_oracle`, and `async_vault` with constructor arguments matching `environments.toml`. + - The harness deploy script (`scripts/harness/deploy.ts`) gives each authority one key. It does not create multisig accounts or check the deposit asset's auth flags yet; see the design doc's [§8.10](./strata-product-and-architecture.md#810-deployment). 2. **Phase 2: Post-Deployment Role Wiring** - - Call `ShareToken::grant_role("manager", vault_address)` to authorize lazy share minting and burning. - - Call `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so the vault can return escrowed shares on cancelled redemptions. - - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification. + - Call `ShareToken::grant_role(vault_address, "manager", governance)` to authorize lazy share minting and burning. + - Call `ShareToken::grant_role(backup, "compliance", governance)` for a second compliance account kept cold, before the token's `finish_setup`. Required: lifting a freeze needs a `compliance` holder, and after setup a new one waits one timelock delay. + - Call `ShareToken::bind_vault(vault_address)`, then `ShareToken::finish_setup()`. The token's admin changes are instant until then and wait for the vault's timelock afterwards. + - Call `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so a delisted holder's priced deposit can be minted into the vault's escrow; `finish_setup` requires it. + - Call `Compliance::bind_token(share_token_address, governance)` to link SEP-57 rule verification, then `Compliance::bind_vault(vault_address)` and `Compliance::finish_setup()`. Its module and binding changes are instant until then and wait for the vault's timelock afterwards. + - Call `NavOracle::bind_vault(vault_address)`, then `NavOracle::finish_setup()`. The oracle's admin changes are instant until then and wait for the vault's timelock afterwards. + - Call `set_min_epoch_duration(secs)` on the vault while setup is open, so the protection is on from the start: anyone can then seal an epoch once it has run that long. Set it above the manager's usual epoch length, so an outsider only seals once the manager has really stopped, not while a normal epoch is still running. Left unset, only the manager seals, and once setup closes the minimum changes only through the timelock. + - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. The custodian is required, and the vault's own address is refused (`InvalidCustodian`). + - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place, the token has no shares outstanding and the vault's address is not frozen on it. It also needs the asset to have the token's decimals, four different addresses for the asset, the token, the oracle and the vault, an oracle that answers `state()` and `freshness_duration()`, and the vault's own books empty: the first epoch, with no deposit or redemption booked in it, nothing committed, and no shares owed or to burn. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. `health()` shows which check is false. The vault takes no deposit, redemption, seal or pricing before it. 3. **Phase 3: Operational Parameterization** - - Governance calls `set_notice(secs)` and `set_custodian(custodian_address)`. - - Governance sets `deposit_cap` if initial capital intake is bounded. - - Oracle reporter submits the genesis NAV attestation (`attest`). + - Oracle reporter submits the genesis NAV attestation (`attest`). Deposits open only once it is valid, so post it before announcing the vault. + +--- + +## 5. Storage Lifetime (TTL) Policy + +Soroban archives a ledger entry when its time to live (TTL) runs out. An +archived entry cannot be read until it is restored, so a contract whose +instance is archived refuses every call. + +The kit runs the same code on every network. Each persistent entry, the +contract instance included, has a target lifetime and a threshold one day +shorter. Writing an entry does not extend it on its own: an entry goes back to +its target only when a call asks for that and its TTL has fallen to the +threshold. The kit's storage helpers ask on every read and every write of the +kit's own entries. OpenZeppelin asks when it reads an entry that already +exists, never when it writes one, so the kit reads back each entry it creates +through OpenZeppelin. The transaction that creates an entry therefore extends +it to its target at once. The network decides only how long a new entry lives +before any extension (see **Network settings**). + +| Contract | What it extends | When | To how long | +|---|---|---|---| +| `async_vault` | Its instance, and every persistent entry it reads or writes: epochs, requests, settlements, wind-down positions, pending governance changes | Every call that reads or writes the entry | 30 days (518,400 ledgers), once 29 days or less remain | +| `nav_oracle` | Its instance, the latest report, and each pending governance change it reads or writes | `attest` extends the instance and the report; `bind_vault`, `finish_setup`, `grant_role`, `transfer_admin_role`, `propose_change`, `cancel_change` and `apply_change` extend the instance; every other call extends whichever entry it reads or writes | Same | +| `share_token` | Its instance, each balance and freeze entry it writes, and each pending governance change it reads or writes | Every call that moves shares or changes token settings: `transfer`, `transfer_from`, `approve`, `mint`, `burn`, `forced_transfer`, `legal_transfer`, freezes, recovery, `set_compliance`, `set_identity_verifier`, `pause`, `unpause`, `grant_role`, `revoke_role`, `transfer_admin_role`, `bind_vault`, `finish_setup`, `propose_change`, `cancel_change`, `apply_change`; also the reads `balance` and `total_supply`, which may be the only token calls during a wind-down | Same | +| `identity_verifier` | Its instance and each allowlist entry | `allow`, `is_allowed`, `verify_identity` | Same | +| `compliance` | Its instance, the modules and bound tokens it writes, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | +| `async_vault`, `share_token`, `nav_oracle` | The four entries a grant writes: the account's role, its member-list slot, the member count and the role list | Each grant (in the constructor, `grant_role` and an applied `GrantRole` change) extends all four. After that, a role check extends only that account's role entry; the slot, the count and the list are extended only by grants and by calls that list role members (such as the vault's `manager()` or the token's `get_role_member_count`) | 90 days (1,555,200 ledgers), once 89 days or less remain | + +The kit's own lengths are the constants in `crates/strata-storage` +(`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day +shorter). The entries OpenZeppelin stores keep OpenZeppelin's own lengths. All +of them are fixed in code: a governance setting would add an admin power for +little gain. + +**Pending governance changes.** A change waiting in the timelock of +`async_vault`, `share_token`, `nav_oracle` or `compliance` lives as a persistent entry, +extended to 30 days when it is proposed. The wait itself can run up to 90 days +(`strata_timelock::MAX_DELAY`), so a change nobody touches can archive before it is +due. Nothing is lost: the call that applies it restores the archived entry +itself and pays the restore fee, the same as any other archived entry. +`npm run extend` also keeps it alive. It reads each contract's open ids from +the contract's instance, where the timelock stores them, so it never reads the +pending entries to find them. It builds each entry's storage key the way the +timelock crate does, and a test checks that the two agree. If the entry has +archived, it restores it, then extends it the same 30 days as every instance +and Wasm. + +**What OpenZeppelin extends, and what the kit adds.** `stellar-tokens` 0.7.2 +stores the share token's balances and freeze entries, and the compliance +contract's modules and bound tokens. `stellar-access` 0.7.2 stores the roles of +the vault, the token and the oracle. OpenZeppelin extends an existing entry +when it reads it: balances, freezes, modules and bound tokens to 30 days, roles +to 90. It does not extend an entry when it writes one, so a new entry would +start at the network's minimum lifetime and wait for a later caller to read it. +The kit closes that gap by reading back what it writes, through OpenZeppelin's +own reads, in the same call: + +- the share token reads back each balance and freeze entry after `mint`, + `burn`, `transfer`, `transfer_from`, `forced_transfer`, `legal_transfer`, + `recover_balance`, `set_address_frozen`, `freeze_partial_tokens` and + `unfreeze_partial_tokens`; +- the vault, the token and the oracle read back every entry a grant writes (the + account's role, its place in the member list, the member count and the list + of roles) after each grant in their constructors, in `grant_role`, and in an + applied `GrantRole` change; +- the compliance stub reads back the hook's modules after `add_module_to` and + an applied `AddModule` change, and the bound tokens after `bind_token` and an + applied `BindToken` change. + +So a new balance, freeze, role, module or bound-token entry reaches its target +in the transaction that creates it, and the kit keeps OpenZeppelin's thresholds +without copying them. A revoke creates nothing: it rewrites the member count and +moves the last member into the freed slot with plain writes, and extends none +of the entries it moves. Allowances are temporary entries that live until their +`live_until_ledger`. OpenZeppelin never extends an instance; the kit does, as +above. The token's total supply and metadata live in its instance. + +**Network settings.** Each network sets how long a new persistent entry lives +and how far one extension can reach. Validators can change these settings by +vote; the kit hardcodes neither. Check +[Stellar Lab's network limits](https://lab.stellar.org/network-limits) for +current values. + +| Setting | Testnet | Mainnet | +|---|---|---| +| Minimum lifetime of a new persistent entry | 120,960 ledgers (about 7 days) | 2,073,600 ledgers (about 120 days) | +| Longest lifetime one extension can reach | 3,110,400 ledgers (about 180 days) | 3,110,400 ledgers (about 180 days) | + +Read over RPC on 2026-10-20. + +**What this means on each network.** On testnet a new entry would live about 7 +days, less than any threshold, so renewals start in its first week: the +transaction that creates it extends it to its target, and from the next day any +call that reads it renews it. On mainnet a new entry already lives 120 +days, more than any target, so the extension in the creating transaction +changes nothing. No call renews a 30-day entry there until about the fourth +month (day 91), or a role until day 31. A restored entry comes back with the +network's minimum lifetime, so a mainnet restore pays at least 120 days of +rent. + +**Why the same values on every network.** The protocol already applies each +network's minimum, so mainnet entries start at 120 days without the kit knowing +it. The same Wasm runs on testnet and on mainnet, so what is tested on testnet +is what ships; a build-time network profile would mean two Wasm files to +review. The threshold one day under the target means an entry in daily use is +renewed at most once a day, for about a day of rent. Capping each extension +(`extend_ttl_with_limits`) would let rarely used entries archive sooner, and +each of those restores would pay at least 120 days of rent on mainnet. + +**Who pays.** The account that sends a transaction pays the rent for every +extension that transaction triggers. The transaction that creates an entry pays +for its target, or for the network's minimum when that is longer: 30 or 90 days +on testnet, 120 days on mainnet. A later call pays only to bring an entry back +to its target once it has fallen to its threshold: about a day of rent when +the entry is read daily, up to a whole target after a long quiet period. A +restore pays for the network's minimum, as above. + +**What frontends should do.** + +- Expect uneven renewal fees. The first call after a quiet period pays to renew + every entry it reads that has fallen to its threshold, so the same call can + cost more after a quiet week than on a busy day. +- Simulate again just before submitting. The fee comes from the simulation. If + an entry falls to its threshold between simulation and submission, the call + pays a renewal the fee does not cover, and the transaction fails. +- Let RPC add a restore when simulation asks for one. When a call needs an + archived entry, simulation adds its restore to the transaction (see + **Restoring**). Submit the transaction as simulated, restore included. + +**Idle deployments.** An entry nobody reads archives when its lifetime runs +out: 30 days after its last renewal (90 for a role), and on mainnet never +sooner than 120 days after it was created. Run +`STELLAR_ACCOUNT= npm run extend -w scripts/harness` after +every deploy and before a long quiet period. It restores, when archived, and +extends by 30 days three kinds of entry only: each contract instance, the Wasm +it runs, and each pending timelock change. Other entries, such as balances and +roles, are restored by the call that needs them (see **Restoring**). It reads +the contracts in `environments.toml [staging.contracts]`; `--env ` picks +another environment, and `--config ` reads a deployment record instead. +With `--config` it uses the network the record names, unless `--env` names +another, and it prints the network and RPC before it sends anything. Put flags +after `--`, or npm swallows them: +`npm run extend -w scripts/harness -- --config `. +Extending needs no authority, so any funded account can pay. + +**Restoring.** Since Protocol 23 an invocation can restore archived entries +itself, so a call to an archived contract usually goes through once simulation +adds the restore. Restoring many entries in one transaction can still hit +per-transaction limits. Restore them first, in batches: each +`RestoreFootprintOp` transaction takes as many entries as the limits allow. +`stellar contract restore` sends one: `--id ` restores an instance, +`--id --key-xdr ` a data entry, and `--wasm-hash ` a +Wasm. After a restore, simulate the call again before sending it. + +**Production modules.** The compliance and identity contracts in this +repository are testnet stubs. A production compliance module or identity +registry must extend its own instance and entries the same way, each entry in +the transaction that creates it, or the share token stops working when they +archive. + +--- + +## 6. Contract Interfaces for Adopters + +The vault reaches the other contracts only through these traits. An adopter +who replaces one implements the trait, and the compiler checks the +implementation against what the vault calls. + +| Interface | Defined in | Implemented here by | What the vault calls | +|---|---|---|---| +| `OracleFeed` | `crates/strata-bindings` | `nav-oracle` | `state`, `nav_per_share`, `attested_at`, `freshness_duration` | +| `FungibleToken` | OpenZeppelin `stellar-tokens` 0.7.2 | `share-token` | `transfer`, `balance`, `total_supply`, `decimals` | +| `RWAToken` | OpenZeppelin `stellar-tokens` 0.7.2 | `share-token` | `mint`, `burn`, `forced_transfer` | +| `IdentityVerifier` | OpenZeppelin `stellar-tokens` 0.7.2 | `identity-verifier` (testnet stub) | `verify_identity`, called by the share token | + +A feed that implements `OracleFeed` can replace `nav-oracle`; it must report +`Stale` once its report outlives `freshness_duration`, and `attested_at` must +be the ledger time the report was accepted, not a time the reporter supplies. + +The testnet identity verifier sets one address per call (`allow`), emits no +event and offers no paged listing. These are limits of the stub: a production +identity registry replaces it. + +Two more traits connect the share token, the oracle and the kit's compliance +contract to the vault's timelock. The vault implements `ClockSource` +(`crates/strata-timelock`), and the three read its `timelock_clock` to time their own +changes. Each implements `VaultBinding` (`crates/strata-bindings`): `vault` names the +vault it is bound to, and `setup_open` says whether its admin changes are still +instant. + +The vault's `finish_setup` reads the token and the oracle through +`VaultBinding` and refuses until each reports this vault with its own setup +closed. It reads the compliance contract the same way, but accepts one that +does not answer `VaultBinding`: such an external module keeps its own +governance, which is a trust assumption. It also reads the token's +`manager` members (OpenZeppelin `AccessControl`), its `total_supply` and +`decimals` (`FungibleToken`) and the asset's `decimals`, runs +`verify_identity` on its own address, reads `linked_tokens` from the token's +compliance contract (OpenZeppelin `TokenBinder`), and calls the oracle's +`state` and `freshness_duration` (`OracleFeed`). A replacement for any of these +must answer those calls, or the vault's setup cannot finish. + +After setup, the share token makes the same check before it swaps a contract, +when the swap is proposed and again when it applies. New compliance rules must +list the token in `linked_tokens`, and rules that answer `VaultBinding` must +name the token's vault with their setup closed. Like `finish_setup`, the token +accepts an external module that does not answer `VaultBinding`, on trust. A new +identity verifier must accept the vault's address in `verify_identity`. + +--- + +## 7. Error Codes and Entrypoint Docs + +Each kit contract owns a block of error codes. A call that fails inside +another contract returns that contract's code: a deposit claim the identity +verifier refuses returns `304`, not a vault code. The blocks never overlap, so +a code inside them always names the kit contract that raised it. OpenZeppelin's +codes are shared: every contract built on its libraries returns the same code +for the same failure, so `304` says what failed, not where. + +| Contract | Error enum | Block | Codes in use | +|---|---|---|---| +| `nav_oracle` | `OracleError` | 3000–3999 | 3001–3021, 3023–3026 | +| `async_vault` | `VaultError` | 6000–6999 | 6000–6001, 6005, 6007, 6009, 6012, 6014, 6029–6035, 6037–6077 | +| `share_token` | `ShareTokenError` | 7000–7999 | 7000–7013, 7015–7019 | +| `compliance` | `ComplianceContractError` | 8000–8999 | 8001–8011 | + +The blocks are defined once, in `crates/strata-bindings` (`ORACLE_ERRORS`, +`VAULT_ERRORS`, `TOKEN_ERRORS`, `COMPLIANCE_ERRORS`). Each contract declares +its enum through `strata_bindings::error_enum!`, and the build fails if a code +leaves its contract's block or two blocks overlap. A code is never reused: a +removed variant leaves its number retired. The testnet identity verifier has +no enum of its own; it refuses with OpenZeppelin's +`RWAError::IdentityVerificationFailed` (304). OpenZeppelin's libraries keep +their codes outside the kit's blocks: below 3000 in the modules the kit uses, +and from 4000 to 5999 in its governance crate. + +Every entrypoint the kit defines or overrides carries a doc in its contract's +spec, so generated clients and block explorers show it. The doc is a one-line +summary, then `# Arguments`, `# Errors`, `# Events` and `# Authorization`; a +section that would be empty is left out, so a view has no `# Events` and no +`# Authorization`. Functions that OpenZeppelin's traits add without an +override keep OpenZeppelin's docs. + +- Amounts are in the asset's base units and shares in the share token's base + units. Prices are in WAD: asset base units per share base unit, times 10^18. + Times and durations are in seconds; `live_until_ledger` is a ledger number. +- An error named without its enum is the contract's own. Any other error + carries its enum, as in `AccessControlError::Unauthorized`. +- `NotInitialized` and `AccountingMismatch` are not listed per entrypoint. They + mean the contract's own state is broken, and any call that reads the broken + entry can return them. +- A call into another contract can fail with that contract's error. The docs + name the ones a caller should plan for, such as the asset's own error when + the payer cannot pay. A replacement identity verifier or compliance module + may refuse with codes of its own. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index a1dcb650..42888c30 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -76,26 +76,27 @@ or legal solution, and not a vault for on-chain RWA tokens. | Actor | On-chain authority | Does | |---|---|---| | Investor (LP) | None (allowlisted address) | Requests deposits and redemptions, claims shares or cash, cancels pending requests, views position | -| Valuation reporter | attestation | Attests the share price with a proof reference, under on-chain guardrails | +| Valuation reporter | attestation | Attests the share price under on-chain guardrails | | Treasury ops | treasury | Moves free reserve between the vault and the custodian | -| Epoch sealer | manager (vault role, not an authority) | Seals the open epoch (`close_epoch`), nothing else; a vault role with its own key at deploy, meant to become permissionless (§8.4) | -| Compliance | compliance | Maintains the allowlist; token interventions (freeze, forced transfer, recovery) | +| Epoch sealer | manager (vault role, not an authority) | Seals the open epoch (`close_epoch`), nothing else; a vault role with its own key at deploy. Once governance sets a minimum epoch duration, anyone may also seal an epoch open that long (§8.4) | +| Compliance | compliance | Maintains the allowlist; token interventions (freeze; legal-order transfer and recovery, each co-signed by governance) | | Guardian | guardian | Pauses the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; can never block redemption requests, payable claims, or custodian deployments | -| Governance | governance | Parameters, roles, custodian rotation, upgrades behind a timelock, wind-down proposal and proposal cancellation | +| Governance | governance | Parameters, roles, custodian rotation, upgrades, wind-down proposal and proposal cancellation; once each contract's one-time setup closes, the changes that can move value or shorten an exit wait for the timelock | | Custodian | Not an authority | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | -The authorities are native Stellar multisig accounts assigned at deploy, and -each contract grants its own roles to them. The vault makes governance its admin -and grants the manager, treasury and guardian roles. At construction it refuses a -treasury equal to guardian or governance, and a compliance equal to governance or -treasury; governance and guardian may be the same account, and the manager is -unconstrained. The oracle, the share token and the identity verifier each take -their admin as a constructor parameter, and the oracle its attester and guardian -too. The attestation authority holds the attester role. The deploy scripts give -the oracle's and the share token's admin to governance, the oracle's guardian to -the guardian account and the verifier's admin to the compliance account, but no -code ties those roles to the vault's; this document marks those equalities "by -deploy". +The authorities are meant to be native Stellar multisig accounts, assigned at +deploy, and each contract grants its own roles to them. The deploy script does +not create multisig accounts yet: it gives each authority one key (§8.10). The +vault makes governance its admin and grants the manager, treasury and guardian +roles. At construction it refuses a treasury equal to guardian or governance, +and a compliance equal to governance or treasury; governance and guardian may be +the same account, and the manager is unconstrained. The oracle, the share token +and the identity verifier each take their admin as a constructor parameter, and +the oracle its attester and guardian too. The attestation authority holds the +attester role. The deploy scripts give the oracle's and the share token's admin +to governance, the oracle's guardian to the guardian account and the verifier's +admin to the compliance account, but no code ties those roles to the vault's; +this document marks those equalities "by deploy". For the exhaustive access control matrix, dual `manager` role distinction, and entrypoint-level test mapping, see [`docs/AUTHORIZATION_MATRIX.md`](./AUTHORIZATION_MATRIX.md). @@ -137,12 +138,15 @@ For the exhaustive access control matrix, dual `manager` role distinction, and e 2. **Built on OpenZeppelin, differentiated above it.** The share token is OZ's SEP-41 token with the SEP-57 RWA extensions (freeze, forced transfer, - recovery); access control, pausable and upgradeable come from OZ crates. - Dependencies are pinned to exact versions and audit inheritance is evaluated - component by component; what Strata adds stays within its own audit scope. - What OZ does not cover is the layer Strata adds: the attested valuation - oracle, request-based flows priced against attestations, split reserve - accounting, and the operational surface. + recovery); access control, pausable and upgradeable come from OZ crates. It + follows SEP-41 for balances, allowances and transfers. Its `burn` is the + RWA extension's manager-only `burn(user, amount, operator)`, and it has no + `burn_from`: a holder cannot burn their own shares, and a generic SEP-41 + client's burn call fails. Dependencies are pinned to exact versions + and audit inheritance is evaluated component by component; what Strata adds + stays within its own audit scope. What OZ does not cover is the layer Strata + adds: the attested valuation oracle, request-based flows priced against + attestations, split reserve accounting, and the operational surface. 3. **Request-based entry and exit.** The exact price of a share does not exist when an investor acts; it is attested afterwards. Entry and exit are @@ -157,16 +161,26 @@ For the exhaustive access control matrix, dual `manager` role distinction, and e off-chain from the deployed value and the vault's public figures under a documented methodology. The contract validates, stores and exposes it. Every on-chain operation preserves that price by construction: pricing a deposit - releases escrow into the reserve and mints shares in proportion, pricing a - redemption burns shares and fixes the matching liability, and a custodian - transfer moves value between pockets without changing the total. + releases escrow into the reserve and owes shares in proportion, pricing a + redemption fixes the cash owed for its escrowed shares, and a custodian + transfer moves value between pockets without changing the total. Shares are + minted and burned only when each claim runs; the economic supply counts owed + shares from pricing and leaves escrowed shares out from the request. 5. **Payable claims always pay.** Neither the guardian pause, a delisting, nor a stale valuation can block the payment of an already-priced, funded cash - claim. A pause can delay unpriced requests. Priced claims are never re-priced - and never identity-gated. A delisted, non-frozen investor exits through the - exit-only cash path. Outside this guarantee, disclosed as trust assumptions: - a frozen investor, deposit-asset issuer controls, and reserve liquidity. + claim. A pause can delay unpriced requests. Priced claims are never + re-priced, and a cash payout never needs the identity check. A delisted, + non-frozen investor exits through the exit-only cash path. One case differs: + a redemption priced at zero assets pays no cash and gives the shares back, + so a delisted holder's claim waits until they are listed again; if they + claim during a wind-down, the shares are burned instead + ([`COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md) §2). A frozen investor's + claims are held, not paid, until the freeze is lifted + ([`COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md) §1). A legal-order transfer + moves only shares in the investor's wallet, never value the vault holds. + Outside this guarantee, disclosed as trust assumptions: deposit-asset issuer + controls and reserve liquidity. 6. **Split accounting, with priced and payable as separate states.** The vault exposes committed (priced redemption liabilities), cancellable escrow @@ -219,7 +233,7 @@ flowchart TB LP -->|"requests · claims"| V ADM -->|"admin operations"| AUTH - OR ---|"attested value + proof ref ↑"| VS + OR ---|"attested value ↑"| VS IVC ---|"verified addresses ↑"| KYC SAC ---|"reserve in / out"| TR TR -->|"deploy_to_custodian"| CU @@ -276,23 +290,25 @@ under the [Admin panel](#admin-panel) table in §8.8. Both interfaces are backend-free: they read contract state and build transactions that the authority multisigs sign. The chain never sees KYC data or -valuation methodology, only their outputs: an allowlisted address, an attested -number with a proof reference. +valuation methodology, only their outputs: an allowlisted address, and an +attested number with a hash of its evidence and, optionally, where to read it. ### 8.3 Components and authorities | Component | Role | Controls | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Vault** | Request lifecycle, epoch sealing by its manager role, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. Administrative handover follows a strict two-step transfer pattern; renouncing admin is permanently refused (`AdminRequired`) | -| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (admin-gated; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freeze, forced transfer and recovery; swapping the compliance or identity-verifier contract needs the admin | -| **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, forces transfers and recovers | Granted and revoked by the token admin (governance by deploy) | -| **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | Managed by the compliance authority via direct role access; replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules) without touching the token | +| **Vault** | Request lifecycle, epoch sealing by its manager role, or by anyone once the minimum epoch duration has run, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. The share token and the oracle upgrade under the same timelock. Administrative handover follows a strict two-step transfer pattern; renouncing admin is permanently refused (`AdminRequired`) | +| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn (per the RWA extension, §8.1) and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock, as do the token's upgrades, role grants and admin hand-overs. New rules must list the token and, if they answer `VaultBinding`, run on the vault's clock; as at setup, an external module that does not answer is accepted on trust. A new verifier must accept the vault. Both are checked when the swap is proposed and when it applies | +| **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, and makes legal transfers and recoveries co-signed by the token admin | Granted and revoked by the token admin (governance by deploy). After a one-time setup, a grant, revoking `manager` and an admin handover wait for the vault's timelock; revoking `compliance` stays instant | +| **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules). After setup, the token's swap waits for the vault's timelock, and new rules must list the token. OZ's compliance contract does not answer `VaultBinding`, so the swap, like the vault's setup, accepts it as an external module whose own governance is a trust assumption | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | | **Custodian** | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | Not an on-chain authority | -Each authority's threshold is sized to the quorum that authority requires. -Signing of privileged operations through a coordinator is verified end to end, -not assumed. +Each authority's threshold is meant to match the quorum that authority +requires. The console collects the signatures for a privileged call and +compares their weight with the account's threshold. No test signs through +multisig authorities on the network yet, because the deploy script does not +create them (§8.10). ### 8.4 Request lifecycle @@ -303,25 +319,34 @@ it can be priced, and every request in the epoch waits the same. Pricing it read the oracle and fixes one share price for every request it holds. Pricing is permissionless and refuses a feed that is not valid, so a sealed epoch waits rather than settling at a stale price, and no investor can choose their price. - -Sealing is gated on the manager role today. It is meant to become permissionless -once a minimum epoch duration bounds it; until then, whoever seals chooses the -batch boundary, though not the price it receives. +Claims round down one by one, so together they never pay more than pricing +promised. When an epoch's last request settles, the remainder leaves +`pending_mint_shares` and `committed` and stays in the reserve as fund money +(`EpochReconciled` says how much). + +The manager can seal the open epoch at any time. Once governance sets a minimum +epoch duration — between 1 and 30 days — anyone may seal an epoch that has been +open that long; left unset, only the manager seals. The first epoch counts from +the end of setup, and each later one from the seal before it; time spent paused +still counts. An outsider's seal starts the same notice as the manager's, so the +earliest a holder sees a price is the minimum plus that notice, counted from +when the epoch opened. Either way, whoever seals chooses the batch boundary, not +the price; the minimum bounds how often anyone but the manager can. #### Subscription -- Request: moves the deposit asset into escrow; at most one active request per controller. (Target: verifying the receiver is allowlisted at request time is targeted for a future milestone; in current contracts the allowlist is verified on share mint at claim time via SEP-57 hooks). +- Request: moves the deposit asset into escrow; at most one active request per holder. Refused while the feed cannot price: the report is stale or the NAV is paused. Also refused while an issuer clawback leaves the vault holding less than its pending escrow. The vault first asks the share token whether the address is frozen and asks the token's identity verifier, and refuses a frozen address or one that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. -- Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, cancellation remains open only until the epoch becomes priceable (the notice period elapses and a fresh attestation is available) or is fulfilled, preventing arbitrage against known prices while preserving an escape hatch for stalled epochs. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. -- Share claim: verifies the receiver and delivers the shares via `mint`. If verification fails, the position remains in pending mint and exits through administrative resolution or once allowlisted. No nominal refund exists after pricing. +- Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. A frozen depositor's cancellation is held until the freeze is lifted, in a wind-down too. It pays when the vault's balance covers it: after an issuer clawback, cancellations are paid in the order they arrive while the balance lasts ([THREAT_MODEL X10](./THREAT_MODEL.md#x10-the-deposit-assets-issuer-freezes-or-claws-back-the-vaults-cash)). +- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. The only refund after pricing is for a deposit worth less than one share base unit, which mints nothing: its asset comes back at claim, from the free reserve, and the epoch keeps the total it was priced with. Pricing does not set that refund aside, because it sees only the epoch's totals and reserving it would mean visiting every request for less than one share base unit each. Treasury can deploy that cash, or a wind-down round credit it, before the claim, which then waits until the free reserve covers it again. #### Redemption -- Request: moves shares into escrow; at most one active request per controller per epoch. -- Cancellation: atomic, and returns the escrowed shares under the same rules as subscription cancellation; stays open during an active wind-down. +- Request: moves shares into escrow; at most one active request per holder per epoch. A frozen holder's request is held until the freeze is lifted; a partial freeze caps it to the free shares. +- Cancellation: atomic, and returns the escrowed shares under the same rules as subscription cancellation; stays open during an active wind-down, where the escrow joins the holder's wind-down entitlement instead of coming back as shares. A frozen holder's cancellation is held until the freeze is lifted. - Pricing: the fixed cash liability enters committed at the epoch's price. Escrowed shares remain in the vault contract and are burned lazily as each claimant executes `claim_redeem`, preserving constant-bounded resource execution per transaction. Priced claims are never re-priced. - Coverage: a priced claim is payable when the liquid reserve covers that claim's own amount, in any order. An earlier unpaid claim never blocks a later one that is already covered. The gap between committed and liquid reserve is the uncovered amount treasury must top up. -- Cash claim: pays the fixed amount; it does not depend on identity. A delisted, non-frozen investor uses the exit-only cash path and cannot cancel back to shares. +- Cash claim: pays the fixed amount; it does not depend on identity. A delisted, non-frozen investor uses the exit-only cash path and cannot cancel back to shares. A frozen investor's claim is held until the freeze is lifted. ```mermaid sequenceDiagram @@ -331,7 +356,7 @@ sequenceDiagram I->>V: request_deposit(amount), asset to escrow Note over V: pending, cancellable O->>V: attestation - Note over V: priced: shares minted and held,
      cancellation closed + Note over V: priced: shares owed, minted at claim,
      cancellation closed I->>V: claim, re-checks allowlist V-->>I: shares delivered ``` @@ -344,7 +369,7 @@ sequenceDiagram participant T as Treasury authority I->>V: request_redeem(shares), shares to escrow O->>V: attestation - Note over V: priced: shares burned,
      cash liability fixed + Note over V: priced: cash liability fixed,
      escrowed shares burned at claim alt liquid reserve covers the claim I->>V: claim V-->>I: deposit asset paid @@ -358,28 +383,51 @@ sequenceDiagram ### 8.5 Valuation and accounting -The NAV is a permissioned attestation of the share price, published with a proof -reference. The reporter computes it off-chain under a published methodology. The -contract checks bounds, cooldown and the deviation cap, then stores the price -and exposes it with the liquidity figures it owns: +The NAV is a permissioned attestation of the share price. The reporter computes +it off-chain under a published methodology. A report carries the price, the +time until which the reporter vouches for it, and a proof reference. The proof +reference is a 32-byte hash of the evidence behind the price, which must not be +all zeros, and an optional URI of at most 256 bytes that says where to read the +evidence. The contract anchors the proof and never verifies it. It checks +bounds, cooldown and the deviation cap, then stores the report with its +acceptance time, publishes it in `NavAttested`, and exposes the price with the +liquidity figures it owns: ```text liquid_reserve = reserve - cancellable_deposit_escrow -free_reserve = max(liquid_reserve - committed, 0) +free_reserve = max(liquid_reserve - committed - wind_down_owed, 0) uncovered = max(committed - liquid_reserve, 0) ``` -**Attestation guardrails:** the reporter is a multisig, never a single key. Each -attestation carries the share price and a proof reference; attestations are -ordered by their acceptance time on the ledger. The price must stay within -configured bounds, a minimum cooldown bounds frequency, and the deviation cap is -directional: the upward bound is mandatory and non-zero, the downward bound is -optional, and leaving it unset lets a loss of any size land in one attestation. -An epoch is never priced against a valuation accepted before it closed, and the -oracle's cooldown therefore bounds how soon a closed epoch can be priced. +`wind_down_owed` is what the wind-down rounds have credited to holders and they +have not claimed yet. It is zero outside a wind-down. + +The vault also reports `total_economic_supply`: shares held outside the vault +plus shares priced for deposits and not yet minted. It leaves out the shares +escrowed by a redemption that is not priced yet, although the next price still +applies to them. The deposit cap limits deposited capital: what the vault holds +plus `net_deployed` (sent to the custodian less funded back), less `committed`. +It counts money in and out, not the vault's value. A deposit request adds what +it brings, and a priced redemption takes off what it pays. A gain or a loss at +the custodian does not move it. Once leavers are paid a gain, the figure can go +negative, and the cap then admits more than its own amount. After a loss it +stays above the vault's value, and the cap refuses deposits sooner. + +**Attestation guardrails:** the reporter is meant to be a multisig, never a +single key; the deploy script does not create one yet (§8.10). Attestations +are ordered by their acceptance time on the ledger. The price must +stay within configured bounds, a minimum cooldown bounds frequency, and the +deviation cap is directional: the upward bound is mandatory and non-zero, the +downward bound is required too, and setting it to 100% lets a loss of any size +land in one attestation. A report's own expiry can shorten its life but never +extend it past the freshness window, and no report is accepted while the NAV is +paused: unpausing it is a deliberate act before reporting resumes. An epoch is +never priced against a valuation accepted before it closed, and the oracle's +cooldown therefore bounds how soon a closed epoch can be priced. **Freshness and pause:** each attestation opens a validity window; when it -lapses the feed is stale and new requests stop being priced. The guardian can +lapses the feed is stale, new requests stop being priced and new deposit +requests are refused. The same holds while the NAV is paused. The guardian can pause the vault: deposit requests, epoch pricing and upgrade application stop and the upgrade timelock freezes; redemption requests, custodian deployments, payable claims, and pending cancellations continue; attestations that pass the @@ -390,16 +438,18 @@ and stale are independent: freshness lapses on its own, the pause is an administrative decision. **The share token's pause is separate.** The token has its own transfer pause, -gated on the token's admin (governance by deploy); the token has no guardian -role. While active it refuses SEP-41 transfers and the vault's escrowed-share -return (`cancel_redeem`); pricing, share and cash claims, mint, burn and forced -transfer are unaffected. It is a transfer-restriction control, not an emergency -stop for the vault, and only the token admin lifts it: pending cancellations -resume when it does. - -**Integrator surface:** the oracle exposes the attested share price directly, as -the latest report with its proof reference and acceptance time (`nav_per_share`). -The SEP-40 feed named in section 4 is an adapter over that surface scheduled for +controlled by the token's admin (governance) and never by the guardian. While +active it refuses SEP-41 transfers between holders. The vault's own escrow +moves (escrowing a redemption, returning escrowed shares on `cancel_redeem`), +pricing, share and cash claims, mint, burn and forced transfer are unaffected. +It is a trading restriction, not an emergency stop for the vault, and only +governance lifts it. + +**Integrator surface:** the oracle exposes the attested share price directly: +the latest report (`latest`), which returns `nav_per_share`, `expires_at`, +`accepted_at`, `proof_hash` and `proof_uri`; the price alone (`nav_per_share`); +and the acceptance time alone (`attested_at`). The +SEP-40 feed named in section 4 is an adapter over that surface scheduled for Tranche 3 and is not implemented yet; integrators read the oracle's own interface today. @@ -411,9 +461,15 @@ interface today. replaces it with OZ's contracts without touching the token. - KYC happens wherever the operator runs it; the chain sees only its output. The compliance authority writes allowlist entries on the identity verifier. -- Token interventions (freeze, unfreeze, forced transfer, recovery) are - compliance operations through the share token's `compliance` role, available even while the vault is - paused. +- Token interventions are compliance operations through the share token's + `compliance` role, available even while the vault is paused. Freezing and + unfreezing are compliance's alone; moving shares under a legal order + (`legal_transfer`) and recovering a lost wallet (`recover_balance`) also need + governance's signature in the same call. Neither moves shares out of or into + the vault's address, and that address cannot be frozen once the token knows + its vault; an earlier freeze makes the vault's `finish_setup` refuse. Lifting + a freeze needs a `compliance` holder, so the last one cannot renounce and a + grant of the role is never shut out by a wind-down. - The complete specification of investor states (Allowlisted, De-listed, Frozen) and the exit-only cash path across all vault lifecycle operations is detailed in [`docs/COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md). @@ -423,40 +479,59 @@ interface today. - The custodian is a genesis-configured slot; only governance can rotate it. Transfers to the custodian move free reserve only, only to that address, and only while nothing is uncovered. -- Transfers from the custodian are always open and credit only assets actually - received. +- Transfers from the custodian are always open: `fund`, which anyone may call, + credits only the assets it receives and lowers `net_deployed` by them. The + custodian repays through `fund`. A plain transfer to the vault also lands in + the reserve but leaves `net_deployed` as it was, so from then on the same + money counts twice toward the deposit cap. - The exposed figures (share price, liquid reserve, committed, uncovered) make reserve coverage legible to investors and integrators. - Closing a vault is a dedicated, announced state. Governance proposes a - wind-down and may cancel the proposal during the delay. Once its delay has run, - anyone may activate it, so the operator cannot announce a closure and then stall - it. Once activated, wind-down is strictly irreversible: the vault permanently - ceases regular intake and distributes capital through pro-rata rounds. While it - is active the vault takes no new requests, prices nothing and sends nothing to - the custodian, while cancellation, priced claims and funding all stay open. + wind-down and may cancel the proposal during the delay, which is never + shorter than seven days. From the proposal on, the vault takes no new + deposit; exits and pricing go on until activation. Once its delay has run, + anyone may activate it, so the operator cannot announce a closure and then + stall it. Once activated, wind-down is strictly irreversible: the vault + permanently ceases regular intake and distributes capital through pro-rata + rounds. While it is active the vault takes no new requests, prices nothing + and sends nothing to the custodian, and no admin power changes hands; + cancellation, priced claims and funding all stay open. Anyone may then finalize distribution rounds: each round takes the free reserve, so priced exit liabilities and refundable subscription escrow are paid first, and holders pull their pro-rata share against a supply snapshot taken at - the first round. Every payment rounds in the vault's favour and the remainder - joins the next round's pot. + the first round. Each round credits the whole free reserve. Each holder's + payment rounds down once, in the vault's favour, and what rounding keeps stays + owed: less than one unit per holder. - A wind-down distributes what is on chain. The vault cannot compel the custodian to return capital; recovering the rest is off-chain work, and nothing here is a - guarantee that it will be recovered. Two further limits are on the record: a + guarantee that it will be recovered. Three further limits are on the record: a holder who never surrenders their shares is never paid, because no entrypoint may iterate over holders; a refund owed to a deposit priced at zero shares - is not tracked as a cash liability, so a round can credit the assets it needed; - and symmetrically, a redemption priced at zero assets before the snapshot - returns its shares outside the wind-down distribution. + is not tracked as a cash liability, so a round can credit the assets it needed + (§8.4); + and symmetrically, a redemption priced at zero assets pays nothing, and once + the wind-down is active its claim burns the escrowed shares instead of + returning them. +- A compliance module can block wind-down claims. A claim that surrenders shares + burns them through the token, which calls the compliance contract's + `destroyed` hook, so a module that starts failing during a wind-down blocks + those claims; it cannot be removed then, because removing a module waits for + the timelock and no timelocked change but a `compliance` grant applies during + a wind-down. - Funding a vault that is winding down cannot be undone. What arrives first covers priced exits and refundable escrow; the surplus is taken by the next round and credited to holders, and no entrypoint returns it. Treasury sends only what it intends to distribute. - A wind-down never declares itself finished, because no holder can be made to - claim. It is finished when three exposed figures read zero together: what the - rounds still owe holders (zero up to rounding dust), the share supply, and the - free reserve. The owed figure counts holders who have not claimed yet and falls - only when one is paid, so its reaching zero up to rounding dust means every credit - has been collected rather than merely offered. + claim. It is finished when six exposed figures read zero together: what the + rounds still owe holders (`wind_down_owed()`, zero up to rounding dust), the + share supply (the share token's `total_supply()`), the free reserve + (`free_reserve()`), the escrow depositors can still cancel + (`cancellable_escrow()`), the cash owed to priced redemptions (`committed()`), + and the shares priced for deposits but not yet minted + (`pending_mint_shares()`). The owed figure counts holders who have not claimed + yet and falls only when one is paid, so its reaching zero up to rounding dust + means every credit has been collected rather than merely offered. ### 8.8 Reference interfaces @@ -476,10 +551,11 @@ investor sees the exit-only path. ### Admin panel -Operates an existing vault; deploys nothing. Each privileged entrypoint is gated -on one role of one contract, except the share token's `forced_transfer`, which -accepts its `manager` (the vault) or `compliance` role. The oracle's ripcord has -two entrypoints: `raise_ripcord` for its guardian and `set_ripcord` for its +Operates an existing vault; deploys nothing. Each privileged entrypoint needs +one role of one contract, except the share token's `legal_transfer` and +`recover_balance`, which need its `compliance` role and its admin in the same +call. The oracle's NAV pause has +two entrypoints: `pause_nav` for its guardian and `set_nav_paused` for its admin. The panel groups the entrypoints into five domain surfaces. A surface is visible to the authorities that act in it, and each action only to its own (permissionless actions to any connected address): @@ -487,10 +563,10 @@ visible to the authorities that act in it, and each action only to its own | Surface | Caller | Cadence | Operations | | ------------- | ----------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cycle | manager, attestation, treasury; anyone prices, funds, activates and finalizes | Continuous | Epoch sealing (manager); attestations; transfers to the custodian (treasury); pricing, funding, wind-down activation and round finalization (permissionless) | -| Compliance | compliance; verifier admin (compliance by deploy) | Continuous | Allowlist (verifier admin); freeze/unfreeze of an address or of part of its shares, forced transfer (compliance); recovery is not offered, the kit's verifier names no recovery target | -| Emergency | guardian, governance; oracle admin and share-token admin (both governance by deploy) | Rare and urgent | Vault pause (guardian) and unpause (governance); oracle ripcord raise (oracle guardian, or oracle admin through `set_ripcord`), reset and `clear_latest` (oracle admin); share-token pause/unpause (share-token admin) | -| Configuration | governance; oracle admin and share-token admin (both governance by deploy) | Rare and deliberate | Custodian slot, deposit cap, notice and wind-down delay (governance); oracle guardrails (oracle admin); compliance and identity-verifier contracts (share-token admin) | -| Governance | governance; oracle admin and share-token admin for their own roles and handover (both governance by deploy) | Very rare | Roles and admin handover on the vault, oracle and share token; upgrade and its delay, wind-down proposal and cancellation (governance) | +| Compliance | compliance; verifier admin (compliance by deploy); share-token admin (governance by deploy) as co-signer | Continuous | Allowlist (verifier admin); freeze/unfreeze of an address or of part of its shares (compliance); legal-order transfer (compliance and share-token admin); recovery is not offered, the kit's verifier names no target | +| Emergency | guardian, governance; oracle admin and share-token admin (both governance by deploy) | Rare and urgent | Vault pause (guardian) and unpause (governance); oracle NAV pause (oracle guardian, or oracle admin through `set_nav_paused`), reset and `clear_latest` (oracle admin); share-token pause/unpause (share-token admin) | +| Configuration | governance; oracle admin and share-token admin (both governance by deploy) | Rare and deliberate | Custodian slot, deposit cap, notice and wind-down delay (governance); oracle guardrails (oracle admin); compliance and identity-verifier contracts (share-token admin). After setup, the vault's custodian and a lower notice or wind-down delay wait for the timelock, as do the oracle and token changes listed in §8.9; raising the notice or the delay and the deposit cap stay instant | +| Governance | governance; oracle admin and share-token admin for their own roles and handover (both governance by deploy) | Very rare | Roles and admin handover on the vault, oracle and share token; upgrade and its delay, wind-down proposal and cancellation (governance). After setup, every grant, `manager` revoke, handover and upgrade waits for the timelock; revoking an operational role (treasury, guardian, attester, compliance) stays instant | ```mermaid flowchart LR @@ -520,8 +596,8 @@ flowchart LR subgraph ORACLE["Oracle"] OATT[attest] - ORAISE[raise_ripcord] - OSET[set_ripcord · clear_latest] + ORAISE[pause_nav] + OSET[set_nav_paused · clear_latest] OCFG[set_config] OGOV[roles · admin handover] end @@ -530,7 +606,8 @@ flowchart LR TPAUSE[pause · unpause] TCFG[set_compliance ·
      set_identity_verifier] TGOV[roles · admin handover] - TCMP[freeze · unfreeze ·
      partial freeze · forced_transfer] + TCMP[freeze · unfreeze ·
      partial freeze] + TLEG[legal_transfer] end subgraph VERIFIER["Identity verifier"] @@ -561,6 +638,8 @@ flowchart LR OADM --> OGOV --> GVN TADM --> TGOV --> GVN CMP --> TCMP --> CPL + CMP --> TLEG --> CPL + TADM -->|co-signs| TLEG VADM --> VALLOW --> CPL GOV -.->|by deploy| OADM GOV -.->|by deploy| TADM @@ -581,26 +660,43 @@ public surface given the vault address. | Boundary | Risk | Mitigation | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report upward, and `min_answer` is what bounds it downward, so that floor is a risk parameter and not a sanity check; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts | -| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused. | +| Attestation authority | Wrong or compromised reports misprice requests | The reporter is meant to be a multisig, never a single key; the deploy script does not create one yet (§8.10). The deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report, which would otherwise price epochs again once the pause lifts, then lift the pause ([`kit-spec.md`](./kit-spec.md) §3) | +| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself: at least seven days, never shorter than the standing notice, at most 90 days. The delay is an announcement period. A pause freezes the timelock clock and pushes the ETA back by the time paused, so the whole delay runs unpaused, and applying while paused is refused. The delay does not guarantee an exit before a change lands: an exit also needs a sealed epoch, a valid price accepted after the seal, and cash in the reserve to pay the claim. Nothing but a grant of the share token's `compliance` role is proposed or applied while the vault winds down, and no admin hand-over is accepted then. The vault's own custodian, role grants, `manager` revocations, admin handovers, lowering of the notice or the wind-down delay, and the minimum epoch duration in either direction wait on that clock once its one-time setup closes, and before then it takes no request and seals or prices no epoch; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, a custodian other than the vault is set, and the vault's own books are empty, with nothing requested, committed or owed in shares (a request booked earlier would be paid from depositors' money); the kit's own compliance contract must also run on the vault's clock with its setup closed. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes; new rules must also list the token and, if they answer `VaultBinding`, run on that clock (an external module that does not answer is taken on trust, as at setup), and a new verifier must accept the vault. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | +| Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold and its own books are empty, and the vault takes no request and moves no epoch before that | | Treasury keys | Reserve drained | Only free reserve is movable, only to the genesis-configured custodian, verified on-chain; outbound transfers are blocked while anything is uncovered, and escrowed subscriptions never leave the vault | | Guardian keys | Griefing via pause | Guardian can only pause the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; it cannot halt redemptions, custodian deployments, or payable claims, nor move funds; governance unpauses and rotates the vault role. It holds no role on the share token: the token's transfer pause is admin-gated (governance by deploy), not a guardian lever. No contract keeps governance and guardian apart | -| Share-token pause | Cancellations and secondary transfers halted | Admin-gated (governance by deploy; the token has no guardian role); it refuses SEP-41 transfers and escrowed-share returns only, never pricing, claims, mint, burn or forced transfer; only the token admin lifts it (§8.5) | -| Compliance keys | Wrongful delisting or freeze | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation | -| Compliance module | Faulty module blocks transfers | Fail-closed semantics; replaceable by governance without touching the token | -| Deposit asset issuer | Freeze or clawback of the vault's reserve | Not mitigated by the kit; declared risk of the chosen asset, verified and reported at genesis (auth flags) | +| Share-token pause | Secondary transfers halted | Controlled by governance (the token admin), never the guardian; it refuses SEP-41 transfers between holders only, never the vault's escrow moves, pricing, claims, mint, burn or forced transfer; only governance lifts it (§8.5) | +| Compliance keys | Wrongful delisting, freeze or transfer | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation; moving a holder's shares also needs governance's signature in the same call, lands only on an allowlisted account and never moves shares out of or into the vault's address, which cannot be frozen once the token knows its vault | +| Vault's own listing | Compliance delists the vault's address | Share returns do not depend on it: the vault checks the holder itself and moves the shares as the token's manager. A delisted holder's conversion, which mints into the vault's escrow, waits until the vault is allowlisted again; nothing is lost, and `health().allowlisted` turns false | +| Compliance module | Faulty module blocks transfers | Fail-closed semantics. Governance, the compliance contract's admin, adds and removes modules: instantly during its one-time setup, and after it only through the vault's timelock, so a change cannot land while the vault is paused and never lands during a wind-down. Removing a faulty module waits the same way, and during a wind-down it cannot be removed at all (§8.7) | +| Deposit asset issuer | Freeze or clawback of the vault's reserve | Not mitigated by the kit; a declared risk of the chosen asset, whose auth flags the deploy is meant to check and report at genesis (not built yet, §8.10). After a clawback the vault pays pending escrow first, then priced claims, and refuses the rest by name, new deposits included, until treasury funds the gap; the loss reaches holders through the NAV ([THREAT_MODEL X10](./THREAT_MODEL.md#x10-the-deposit-assets-issuer-freezes-or-claws-back-the-vaults-cash)) | | Custodian / real world | Underlying loss or delay | Reflected through attested NAV; the kit constrains what reaches the chain, it does not verify the world | | Wind-down distribution | Unfinalized rounds stall distribution | Finalizing a round is permissionless: once treasury returns capital, anyone can finalize the round to credit holders; the operator cannot stall payouts after funding | +The full threat model, with the test for each control, is in +[`docs/THREAT_MODEL.md`](./THREAT_MODEL.md). + +The monitoring plan, with what an operator watches on chain and the response to +each signal, is in [`docs/MONITORING_PLAN.md`](./MONITORING_PLAN.md). + Disclosed trust assumptions: the accuracy of the operator's KYC process, the quality of the data behind each attestation, and the operator's key ceremony. ### 8.10 Deployment -Deployment is scripted and ends with no human key holding governance. It is -complete only when the final state is verified on-chain: every authority is the -intended multisig, no bootstrap key retains any role, the configuration matches -the request, and the deposit asset's auth flags are checked and reported. +Deployment is scripted. The target is a deployment that ends with no human key +holding governance, verified on chain: every authority is the intended +multisig, the share token's `compliance` role has a second, cold holder, no +bootstrap key keeps any role, the configuration matches the request, and the +deposit asset's auth flags are checked and reported. + +The deploy script builds part of that today. It gives each authority one key, +not a multisig. It checks that the vault's setup has closed and every wiring +flag of `health()` is true, that the vault holds the share token's `manager` +role, and that the token's `compliance` role has two holders. It does not yet +create multisig accounts or set their thresholds, check that each authority is +the intended multisig, check that no bootstrap key keeps a role, or read the +asset's auth flags. Those steps are planned before a mainnet deployment. ## 9. Relationship to existing Stellar tooling @@ -610,9 +706,10 @@ evaluated (Templar, Untangled OctoVault, DeFindex). | Component | Already exists | Strata | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| SEP-41 token + SEP-57 RWA extensions (freeze, forced transfer, recovery) | OpenZeppelin stellar-tokens; the RWA Wizard scaffolds the regulated token | Consumed and extended; the kit's compliance module implements the SEP-57 interfaces the token expects | +| SEP-41 token + SEP-57 RWA extensions (freeze, forced transfer, recovery) | OpenZeppelin stellar-tokens; the RWA Wizard scaffolds the regulated token | Consumed and extended; the kit's compliance module implements the SEP-57 interfaces the token expects. Burn follows the RWA extension, not SEP-41 (§8.1) | | Synchronous tokenized vault | SEP-56 / OZ Token Vault | Not a base for Strata: SEP-56 assumes the price exists at call time, so its interface cannot express a request lifecycle. Only OZ conversion and rounding math reused, as library code | -| Access control, pausable, upgradeable, timelock | OZ crates | Consumed; pinned by exact version, audit coverage and gaps documented per component | +| Access control, pausable, upgradeable | OZ crates | Consumed; pinned by exact version, audit coverage and gaps documented per component | +| Timelock for admin changes | OZ `stellar-governance` timelock | Not used. The kit's own `strata-timelock` crate: one clock for the four contracts, an ETA that a vault pause pushes back, a setup window, 7 to 90 day bounds, and no change but a share-token `compliance` grant lands during a wind-down | | Multisig and signing coordination | Native Stellar + existing coordinators, OZ Role Manager | Integrated | | Request lifecycle priced against attestations | Not provided (ERC-7540 on EVM, where OpenZeppelin ships an implementation) | Core of the kit | | Guarded valuation oracle with freshness and pause | Not provided | Core of the kit | @@ -641,9 +738,10 @@ adopter feedback and adoption metrics. Ordered by expected business impact. 1. **Fee module.** Management and performance fees with a high-water mark, crystallized by share dilution; rates as per-deployment configuration. -2. **Operator notifications and alerting.** Implementation of the monitoring - plan as push alerts (stale valuation, reserve coverage, privileged actions) - via DMe, BootNode's open-source notification layer. +2. **Operator notifications and alerting.** Implementation of the + [monitoring plan](./MONITORING_PLAN.md) as push alerts (stale valuation, + reserve coverage, privileged actions) via DMe, BootNode's open-source + notification layer. 3. **No-code vault launcher.** A wizard that configures and deploys a vault instance without touching code. 4. **Factory contract.** One-transaction deployment of a configured vault diff --git a/environments.toml b/environments.toml index 1d1e898a..c199a851 100644 --- a/environments.toml +++ b/environments.toml @@ -34,13 +34,14 @@ constructor_args = "--admin me" client = true constructor_args = '--name "Strata Vault USDC" --symbol bvUSDC --admin me --compliance_authority me --compliance compliance --identity_verifier identity_verifier' -# `--config` takes the OracleConfig struct as JSON. The values mirror the -# contract's own tests: a one-hour freshness window, a one-minute cooldown, a -# 10% deviation cap and a 0.5–100 band at the 18-decimal scale. The two i128 -# fields are strings because they do not survive a JSON number round trip. +# `--config` takes the OracleConfig struct as JSON. The values match the +# harness's testnet oracle: a one-hour freshness window, a one-minute cooldown, +# a 10% cap on a rise, no cap on a fall (10,000 bps lets a loss of any size +# land at once) and a 0.5–100 band at the 18-decimal scale. The two i128 fields +# are strings because they do not survive a JSON number round trip. [development.contracts.nav_oracle] client = true -constructor_args = "--admin me --attester me --guardian me --config '{\"freshness_duration\":3600,\"cooldown_secs\":60,\"max_up_bps\":1000,\"max_down_bps\":null,\"min_answer\":\"500000000000000000\",\"max_answer\":\"100000000000000000000\"}'" +constructor_args = "--admin me --attester me --guardian me --config '{\"freshness_duration\":3600,\"cooldown_secs\":60,\"max_up_bps\":1000,\"max_down_bps\":10000,\"min_answer\":\"500000000000000000\",\"max_answer\":\"100000000000000000000\"}'" [development.contracts.async_vault] client = true @@ -51,8 +52,18 @@ constructor_args = '--asset share_token --share_token share_token --oracle nav_o # The vault needs the share token's manager role and needs allowlisting on the # identity verifier, and both target other contracts while requiring the vault's # own address, which only exists after it deploys last. The deploy in -# scripts/harness performs and verifies both. A deployment made only from -# this file can take deposits but can neither deliver shares nor return them. +# scripts/harness performs and verifies both. +# +# Setup cannot finish from this file either. The vault's `finish_setup` checks +# the token, oracle and compliance bindings, the manager role and the +# allowlist, which only the harness deploy wires, and that the token has no +# shares outstanding. It also checks that the asset has the token's decimals, +# that the asset, the token, the oracle and the vault are four different +# addresses (here the asset is the share token, so this one fails too), that +# the oracle answers as a feed, and that a custodian other than the vault is +# set. A deploy made only from this file leaves the compliance +# contract's, the token's, the oracle's and the vault's setup open, so its +# vault refuses deposits with `SetupOpen`. The harness deploy closes all four. ### Staging environment configuration @@ -71,11 +82,11 @@ default = true # underlying Stellar Asset Contract and has no crate here; it still gets a # generated client, because a SAC carries its own spec. [staging.contracts] -async_vault = { id = "CD5LYYXGOJ7R23P6D7YUN6DUJQYMI23KEUEOIFKXOSWOX3KHKTXRITED" } -nav_oracle = { id = "CBKVMUMIX7MXYHX5K34N5HYZOP6S2VGWF7MN27RIWBV3A3WD3X3L63CO" } -share_token = { id = "CDOISQTQRCYOTH6S3DFQ6UKJ2TEXLNO7NFIGOZS4RZL63IRCVXXCGP5N" } -identity_verifier = { id = "CCTCCY7AVFAIRGEWWJZT2UDMBHS3NGNKVUWLY43CHDNN75AQEV6P3EOY" } -compliance = { id = "CDEH6FJBK7NDFLALO3X3GDXZUREYHUSNC7ODPM64RBG3BBXW2USMW7OO" } +async_vault = { id = "CDJHQ2R5XUJGP6WUKCW4VBEVGWFAZ5TZLEONBITKITNXFDLWNNK7WVYO" } +nav_oracle = { id = "CCZ4X25GLMYRQ33PM4ZYDMGKAJJKPUG2ABUZ4NIGUSA7W4RUTBOEEDYI" } +share_token = { id = "CDOZT5VNIKSLQSFELF4PHVZRZA3GCJYJEHVDZTUB7O2QMBHBKKOYORQI" } +identity_verifier = { id = "CCGHNGPI66WGVSLLALQVCIRIOZKX73QW23UDZLZ3LMXLGEG7SLVUOULD" } +compliance = { id = "CCQI5NFRWBOO77LYSFY54D6PSU4TVWD5A3CFX2A5AWLEURVR35XISMKG" } asset = { id = "CCOHKZLQ5EAD2A3RLHYHH7W4K2TNLNELXUVLWZIZALH45WXHH5H3QV7E" } ### Production environment configuration diff --git a/fixtures/timelock-storage-keys.txt b/fixtures/timelock-storage-keys.txt new file mode 100644 index 00000000..0af48424 --- /dev/null +++ b/fixtures/timelock-storage-keys.txt @@ -0,0 +1,7 @@ +# The timelock's storage keys as ScVal XDR, in hex. The strata-timelock tests +# and the harness unit tests both check their own encoding against this file. +TimelockOpen 0000001000000001000000010000000f0000000c54696d656c6f636b4f70656e +TimelockPending(1) 0000001000000001000000020000000f0000000f54696d656c6f636b50656e64696e67000000000300000001 +TimelockPending(2) 0000001000000001000000020000000f0000000f54696d656c6f636b50656e64696e67000000000300000002 +TimelockPending(16) 0000001000000001000000020000000f0000000f54696d656c6f636b50656e64696e67000000000300000010 +TimelockPending(4294967295) 0000001000000001000000020000000f0000000f54696d656c6f636b50656e64696e670000000003ffffffff diff --git a/fixtures/upgrade-target/Cargo.lock b/fixtures/upgrade-target/Cargo.lock deleted file mode 100644 index 9590c045..00000000 --- a/fixtures/upgrade-target/Cargo.lock +++ /dev/null @@ -1,2045 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "ark-bls12-381" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" -dependencies = [ - "ark-ec", - "ark-ff", - "ark-serialize", - "ark-std", -] - -[[package]] -name = "ark-bn254" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" -dependencies = [ - "ark-ec", - "ark-ff", - "ark-std", -] - -[[package]] -name = "ark-ec" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" -dependencies = [ - "ahash", - "ark-ff", - "ark-poly", - "ark-serialize", - "ark-std", - "educe", - "fnv", - "hashbrown 0.15.5", - "itertools", - "num-bigint", - "num-integer", - "num-traits", - "zeroize", -] - -[[package]] -name = "ark-ff" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" -dependencies = [ - "ark-ff-asm", - "ark-ff-macros", - "ark-serialize", - "ark-std", - "arrayvec", - "digest 0.10.7", - "educe", - "itertools", - "num-bigint", - "num-traits", - "paste", - "zeroize", -] - -[[package]] -name = "ark-ff-asm" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "ark-ff-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" -dependencies = [ - "num-bigint", - "num-traits", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "ark-poly" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" -dependencies = [ - "ahash", - "ark-ff", - "ark-serialize", - "ark-std", - "educe", - "fnv", - "hashbrown 0.15.5", -] - -[[package]] -name = "ark-serialize" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" -dependencies = [ - "ark-serialize-derive", - "ark-std", - "arrayvec", - "digest 0.10.7", - "num-bigint", -] - -[[package]] -name = "ark-serialize-derive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "ark-std" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" -dependencies = [ - "num-traits", - "rand", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes-lit" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0adabf37211a5276e46335feabcbb1530c95eb3fdf85f324c7db942770aa025d" -dependencies = [ - "num-bigint", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "cc" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "cfg_eval" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crate-git-revision" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" -dependencies = [ - "serde", - "serde_derive", - "serde_json", -] - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", - "rustc_version", - "subtle", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" -dependencies = [ - "darling_core 0.24.1", - "darling_macro 0.24.1", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_core" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 3.0.6", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" -dependencies = [ - "darling_core 0.24.1", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.20", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid", - "crypto-common 0.1.6", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519", - "rand_core", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "educe" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" -dependencies = [ - "enum-ordinalize", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "rand_core", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "enum-ordinalize" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" -dependencies = [ - "enum-ordinalize-derive", -] - -[[package]] -name = "enum-ordinalize-derive" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "escape-bytes" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" - -[[package]] -name = "ethnum" -version = "1.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - -[[package]] -name = "find-msvc-tools" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core", - "subtle", -] - -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -dependencies = [ - "serde", -] - -[[package]] -name = "hex-literal" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "indexmap-nostd" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link", -] - -[[package]] -name = "jiff-core" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" -dependencies = [ - "defmt", - "log", -] - -[[package]] -name = "jiff-static" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "k256" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" -dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", - "sha2", -] - -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures 0.2.17", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "macro-string" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "subtle", - "zeroize", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_with" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" -dependencies = [ - "base64 0.23.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.2", - "jiff", - "schemars 0.8.22", - "schemars 0.9.0", - "schemars 1.2.2", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" -dependencies = [ - "darling 0.24.1", - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest 0.10.7", - "keccak", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "soroban-builtin-sdk-macros" -version = "26.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4a09f48a67357fdad682b601f4a2f3fdd8d897f3a97c2e1704fa22b0a474e0" -dependencies = [ - "itertools", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "soroban-env-common" -version = "26.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ededa4063061c00886490bb3290e416ee0b03d7732a7378ccba7bb19211fb00c" -dependencies = [ - "crate-git-revision", - "ethnum", - "num-derive", - "num-traits", - "soroban-env-macros", - "soroban-wasmi", - "static_assertions", - "stellar-xdr", - "wasmparser", -] - -[[package]] -name = "soroban-env-guest" -version = "26.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ff2525f31e2453b52e90400a2952ce4618f8925bd03167938573e83c320c33f" -dependencies = [ - "soroban-env-common", - "static_assertions", -] - -[[package]] -name = "soroban-env-host" -version = "26.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755806fedf2c948e882dff4bf69a8e844c9950b1d534abcadba4026150180274" -dependencies = [ - "ark-bls12-381", - "ark-bn254", - "ark-ec", - "ark-ff", - "ark-serialize", - "curve25519-dalek 5.0.0", - "ecdsa", - "ed25519-dalek", - "elliptic-curve", - "generic-array", - "getrandom", - "hex-literal", - "hmac", - "k256", - "num-derive", - "num-integer", - "num-traits", - "p256", - "rand", - "rand_chacha", - "sec1", - "sha2", - "sha3", - "soroban-builtin-sdk-macros", - "soroban-env-common", - "soroban-wasmi", - "static_assertions", - "stellar-strkey 0.0.13", - "wasmparser", -] - -[[package]] -name = "soroban-env-macros" -version = "26.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e446ac7f005d89cb77cc4e2cc006377e1a8c970c9b05d11968a32f78e10e4ef4" -dependencies = [ - "itertools", - "proc-macro2", - "quote", - "serde", - "serde_json", - "stellar-xdr", - "syn 2.0.119", -] - -[[package]] -name = "soroban-sdk" -version = "26.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce2a22afcbfc9407fe32ce789f441636d62cd078a4cc126191932b74b1e6c4" -dependencies = [ - "bytes-lit", - "crate-git-revision", - "rand", - "rustc_version", - "serde", - "serde_json", - "soroban-env-guest", - "soroban-env-host", - "soroban-sdk-macros", - "stellar-strkey 0.0.16", - "visibility", -] - -[[package]] -name = "soroban-sdk-macros" -version = "26.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6538ceb7d40c01499c2779b449a697c56c62a60bae6e5c5705d3a782075b5028" -dependencies = [ - "darling 0.20.11", - "heck", - "itertools", - "macro-string", - "proc-macro2", - "quote", - "sha2", - "soroban-env-common", - "soroban-spec", - "soroban-spec-rust", - "stellar-xdr", - "syn 2.0.119", -] - -[[package]] -name = "soroban-spec" -version = "26.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca18ced644f86e1e36a0fddb856d45a6a8478296e6b54941fb5e2daa96076bf7" -dependencies = [ - "base64 0.22.1", - "sha2", - "stellar-xdr", - "thiserror 1.0.69", - "wasmparser", -] - -[[package]] -name = "soroban-spec-rust" -version = "26.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f3e3933a8ca588de7fb70460a48a5e0b043f43836aeb11b6aa0128143d95814" -dependencies = [ - "prettyplease", - "proc-macro2", - "quote", - "sha2", - "soroban-spec", - "stellar-xdr", - "syn 2.0.119", - "thiserror 1.0.69", -] - -[[package]] -name = "soroban-wasmi" -version = "0.31.1-soroban.20.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" -dependencies = [ - "smallvec", - "spin", - "wasmi_arena", - "wasmi_core", - "wasmparser-nostd", -] - -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "stellar-contract-utils" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c45490c0ca2176bf9283445f59734ab304b17153f55fe9155de7f5cdffa4a85" -dependencies = [ - "soroban-sdk", -] - -[[package]] -name = "stellar-strkey" -version = "0.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" -dependencies = [ - "crate-git-revision", - "data-encoding", -] - -[[package]] -name = "stellar-strkey" -version = "0.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" -dependencies = [ - "crate-git-revision", - "data-encoding", - "heapless", -] - -[[package]] -name = "stellar-xdr" -version = "26.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6e29c7e1f071c2767916460d006668197843d5d93f0ec8893a26f72a14f595" -dependencies = [ - "base64 0.22.1", - "cfg_eval", - "crate-git-revision", - "escape-bytes", - "ethnum", - "hex", - "serde", - "serde_with", - "sha2", - "stellar-strkey 0.0.13", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[package]] -name = "upgrade-target" -version = "0.1.0" -dependencies = [ - "soroban-sdk", - "stellar-contract-utils", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "visibility" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasmi_arena" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" - -[[package]] -name = "wasmi_core" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" -dependencies = [ - "downcast-rs", - "libm", - "num-traits", - "paste", -] - -[[package]] -name = "wasmparser" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" -dependencies = [ - "indexmap 2.14.2", - "semver", -] - -[[package]] -name = "wasmparser-nostd" -version = "0.100.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" -dependencies = [ - "indexmap-nostd", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fixtures/upgrade-target/Cargo.toml b/fixtures/upgrade-target/Cargo.toml deleted file mode 100644 index 6d1b9c22..00000000 --- a/fixtures/upgrade-target/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[workspace] - -[package] -name = "upgrade-target" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" -publish = false - -[lib] -crate-type = ["lib", "cdylib"] -doctest = false - -[dependencies] -soroban-sdk = "=26.1.1" -stellar-contract-utils = "=0.7.2" - -[profile.release] -opt-level = "z" -overflow-checks = true -lto = true -codegen-units = 1 -panic = "abort" -strip = "symbols" diff --git a/fixtures/upgrade-target/src/lib.rs b/fixtures/upgrade-target/src/lib.rs deleted file mode 100644 index 211e4462..00000000 --- a/fixtures/upgrade-target/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Stands in for a future vault version in the upgrade tests. Not deployed. -#![no_std] - -use soroban_sdk::{contract, contracterror, contractimpl, panic_with_error, Address, Env}; -use stellar_contract_utils::upgradeable; - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum TargetError { - AlreadyMigrated = 1, -} - -#[contract] -pub struct UpgradeTarget; - -#[contractimpl] -impl UpgradeTarget { - pub fn schema_version(e: &Env) -> u32 { - upgradeable::get_schema_version(e) - } - - pub fn migrate(e: &Env, caller: Address) { - caller.require_auth(); - if upgradeable::get_schema_version(e) >= 2 { - panic_with_error!(e, TargetError::AlreadyMigrated); - } - upgradeable::set_schema_version(e, 2); - } -} diff --git a/flake.nix b/flake.nix index 4b742272..0940c210 100644 --- a/flake.nix +++ b/flake.nix @@ -29,21 +29,29 @@ hash = "sha256-o0GtzBUuGGXqYdbDMudbPYKx8vMhCBmPrJfFCHixcDQ="; }; }; + # stellar-scaffold prebuilt tarball, same version CI installs (build.yml). + scaffoldVersion = "0.0.27"; + scaffoldHashes = { + x86_64-darwin = "sha256-kzM5ikay1etB1bBLtCo8jNq7jZgJstl36OkUClc2jWY="; + aarch64-darwin = "sha256-un7QXXsfi82zb0w98fOZEbHxkTGlTlQtl3ZqINWb9ic="; + x86_64-linux = "sha256-8kcJ3lfjMVXv1yVHyShrUi1DYKN5veWvnutVGTlvEMw="; + aarch64-linux = "sha256-IACcbOz+tDZl4RHTIfXOqvD+a+vptTVbBMNrcOQh82E="; + }; systems = builtins.attrNames cliAssets; forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system nixpkgs.legacyPackages.${system}); - mkStellarCli = - system: pkgs: - let - asset = cliAssets.${system}; - in + mkPrebuilt = + pkgs: + { + pname, + version, + bin, + url, + hash, + }: pkgs.stdenvNoCC.mkDerivation { - pname = "stellar-cli"; - version = cliVersion; - src = pkgs.fetchurl { - url = "https://github.com/stellar/stellar-cli/releases/download/v${cliVersion}/stellar-cli-${cliVersion}-${asset.target}.tar.gz"; - inherit (asset) hash; - }; + inherit pname version; + src = pkgs.fetchurl { inherit url hash; }; sourceRoot = "."; nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.autoPatchelfHook ]; buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ @@ -51,14 +59,35 @@ pkgs.openssl pkgs.zlib ]; - installPhase = "runHook preInstall; install -Dm755 stellar $out/bin/stellar; runHook postInstall"; - meta.mainProgram = "stellar"; + installPhase = "runHook preInstall; install -Dm755 ${bin} $out/bin/${bin}; runHook postInstall"; + meta.mainProgram = bin; + }; + + mkStellarCli = + system: pkgs: + mkPrebuilt pkgs { + pname = "stellar-cli"; + version = cliVersion; + bin = "stellar"; + url = "https://github.com/stellar/stellar-cli/releases/download/v${cliVersion}/stellar-cli-${cliVersion}-${cliAssets.${system}.target}.tar.gz"; + inherit (cliAssets.${system}) hash; + }; + + mkStellarScaffold = + system: pkgs: + mkPrebuilt pkgs { + pname = "stellar-scaffold-cli"; + version = scaffoldVersion; + bin = "stellar-scaffold"; + url = "https://github.com/stellar-scaffold/cli/releases/download/stellar-scaffold-cli-v${scaffoldVersion}/stellar-scaffold-cli-v${scaffoldVersion}-${cliAssets.${system}.target}.tar.gz"; + hash = scaffoldHashes.${system}; }; in { packages = forAllSystems ( system: pkgs: { stellar-cli = mkStellarCli system pkgs; + stellar-scaffold = mkStellarScaffold system pkgs; default = mkStellarCli system pkgs; } ); @@ -70,10 +99,12 @@ # Soroban / Rust pkgs.rustup # honors ./rust-toolchain.toml (stable + wasm32v1-none + rust-src) (mkStellarCli system pkgs) + (mkStellarScaffold system pkgs) pkgs.wabt # wasm2wat / wasm-objdump pkgs.twiggy # wasm size profiler pkgs.cargo-nextest pkgs.cargo-llvm-cov # line/region coverage (needs llvm-tools-preview) + pkgs.cargo-deny # licence, advisory and source checks (deny.toml) pkgs.nodejs_22 # Formal methods pkgs.tlaplus # tlc + pcal (TLA+ / PlusCal) @@ -85,6 +116,7 @@ rustup show >/dev/null 2>&1 || true echo "open-rwa-vault devshell" echo " stellar: $(stellar --version 2>/dev/null | head -1)" + echo " scaffold: $(stellar-scaffold --version 2>/dev/null | head -1)" echo " rust: $(rustc --version 2>/dev/null || echo '(run any cargo/rustc to trigger rustup)')" echo " tla: $(tlc 2>&1 | head -1 | sed 's/^/ /' | xargs echo | cut -c1-60) (tlc + pcal)" echo " loop: pcal spec/Token.tla -> tlc -config spec/Token.cfg spec/Token.tla" diff --git a/package-lock.json b/package-lock.json index 850ccb10..1a8f144e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@testing-library/react": "16.3.3", "@theahaco/ts-config": "1.3.2", "jsdom": "29.1.1", - "prettier": "^3.8.3", + "prettier": "3.9.6", "vitest": "4.1.11" } }, @@ -56,7 +56,7 @@ "version": "0.0.0", "dependencies": { "@creit.tech/stellar-wallets-kit": "^2.5.0", - "@stellar/stellar-sdk": "^16.1.0", + "@stellar/stellar-sdk": "^16.4.1", "zod": "^4.3.5" } }, @@ -70,29 +70,6 @@ "typescript": "^5.6.2" } }, - "app-lib/clients/asset/node_modules/@stellar/stellar-sdk": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", - "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", - "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-base": "^14.1.0", - "axios": "^1.13.3", - "bignumber.js": "^9.3.1", - "commander": "^14.0.2", - "eventsource": "^2.0.2", - "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - }, - "bin": { - "stellar-js": "bin/stellar-js" - }, - "engines": { - "node": ">=20.0.0" - } - }, "app-lib/clients/async_vault": { "version": "0.0.0", "dependencies": { @@ -143,24 +120,6 @@ "typescript": "^5.6.2" } }, - "app-lib/node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "app-lib/node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "app-lib/node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5303,76 +5262,16 @@ "node": ">=10" } }, - "node_modules/@stellar/js-xdr": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", - "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", - "license": "Apache-2.0" - }, - "node_modules/@stellar/stellar-base": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", - "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", - "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", - "license": "Apache-2.0", - "dependencies": { - "@noble/curves": "^1.9.6", - "@stellar/js-xdr": "^3.1.2", - "base32.js": "^0.1.0", - "bignumber.js": "^9.3.1", - "buffer": "^6.0.3", - "sha.js": "^2.4.12" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@stellar/stellar-base/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@stellar/stellar-base/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@stellar/stellar-base/node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/@stellar/stellar-sdk": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.1.0.tgz", - "integrity": "sha512-MOIrUvbzuTnSvUurL+9nrS1UW6Ko+QTW00RggSomm4WaWkE8onKPo2MRJMd89X7qPaGPjYjxNX6B671AEQlzxQ==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.4.1.tgz", + "integrity": "sha512-0kT+ShOUE6dc0smLEsajuizss1hvlGDsCooM88FqgxNOCKpY8IuQPzCs75RNKMSMITz+dX7KZ5qGhQ2EWGMTTg==", "license": "Apache-2.0", "dependencies": { "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.2.0", "@stellar/js-xdr": "4.0.0", - "axios": "1.18.0", + "axios": "1.20.0", "base32.js": "^0.1.0", "bignumber.js": "^11.1.4", "buffer": "^6.0.3", @@ -12817,12 +12716,6 @@ "node": ">=0.6" } }, - "node_modules/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "license": "MIT" - }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", @@ -13189,12 +13082,6 @@ "punycode": "^2.1.0" } }, - "node_modules/urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "license": "MIT" - }, "node_modules/url": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", @@ -13834,7 +13721,7 @@ "name": "@strata-vault-kit/harness", "version": "0.1.0", "dependencies": { - "@stellar/stellar-sdk": "16.1.0" + "@stellar/stellar-sdk": "16.4.1" }, "devDependencies": { "@theahaco/ts-config": "1.3.2", diff --git a/package.json b/package.json index 2e81f9be..667a3685 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,13 @@ "@testing-library/react": "16.3.3", "@theahaco/ts-config": "1.3.2", "jsdom": "29.1.1", - "prettier": "^3.8.3", + "prettier": "3.9.6", "vitest": "4.1.11" }, "prettier": "@theahaco/ts-config/prettier", "overrides": { - "@stellar/stellar-sdk": "16.1.0", - "axios": "^1.18.0" + "@stellar/stellar-sdk": "16.4.1", + "axios": "1.18.0" }, "packageManager": "npm@11.7.0" } diff --git a/scripts/ci/check-tests-ran.sh b/scripts/ci/check-tests-ran.sh new file mode 100755 index 00000000..24621163 --- /dev/null +++ b/scripts/ci/check-tests-ran.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage="usage: $(basename "$0") ..." + +if [ "$#" -lt 2 ]; then + echo "$usage" >&2 + exit 2 +fi + +log=$1 +shift + +for module in "$@"; do + if ! grep -Eq "^test ${module}::[[:alnum:]_:]+ \.\.\. ok$" "$log"; then + echo "no passing test ran in ${module}" >&2 + exit 1 + fi +done diff --git a/scripts/ci/install-tools.sh b/scripts/ci/install-tools.sh new file mode 100755 index 00000000..3bdfb387 --- /dev/null +++ b/scripts/ci/install-tools.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Installs pinned x86_64 Linux release binaries after checking each archive's SHA-256. +set -euo pipefail + +STELLAR_CLI_VERSION=27.0.0 +STELLAR_CLI_SHA256=357bf712f6353c28cd33c794402a3c87231757a5b305e6ef1604365af4fdd556 +STELLAR_SCAFFOLD_VERSION=0.0.27 +STELLAR_SCAFFOLD_SHA256=f24709de57e33155efd72547c9286b522d4360a379bde5af9eeb5519396f10cc +GITLEAKS_VERSION=8.30.1 +GITLEAKS_SHA256=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + +bin_dir="${INSTALL_DIR:-$HOME/.local/bin}" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +install_binary() { + local url=$1 sha256=$2 binary=$3 archive="$work/$3.tar.gz" + curl -fsSL --retry 3 -o "$archive" "$url" + if ! echo "$sha256 $archive" | sha256sum --check --strict --status; then + echo "$url does not match its pinned SHA-256; nothing installed." >&2 + exit 1 + fi + tar -xzf "$archive" -C "$work" "$binary" + install -Dm755 "$work/$binary" "$bin_dir/$binary" + echo "installed $binary from $url" +} + +[ $# -gt 0 ] || { echo "usage: $0 stellar|stellar-scaffold|gitleaks..." >&2; exit 2; } +for tool in "$@"; do + case "$tool" in + stellar) install_binary "https://github.com/stellar/stellar-cli/releases/download/v$STELLAR_CLI_VERSION/stellar-cli-$STELLAR_CLI_VERSION-x86_64-unknown-linux-gnu.tar.gz" "$STELLAR_CLI_SHA256" stellar ;; + stellar-scaffold) install_binary "https://github.com/stellar-scaffold/cli/releases/download/stellar-scaffold-cli-v$STELLAR_SCAFFOLD_VERSION/stellar-scaffold-cli-v$STELLAR_SCAFFOLD_VERSION-x86_64-unknown-linux-gnu.tar.gz" "$STELLAR_SCAFFOLD_SHA256" stellar-scaffold ;; + gitleaks) install_binary "https://github.com/gitleaks/gitleaks/releases/download/v$GITLEAKS_VERSION/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" "$GITLEAKS_SHA256" gitleaks ;; + *) echo "unknown tool: $tool" >&2; exit 2 ;; + esac +done +if [ -n "${GITHUB_PATH:-}" ]; then echo "$bin_dir" >>"$GITHUB_PATH"; fi diff --git a/scripts/dev-guard.mjs b/scripts/dev-guard.mjs index 76bddcef..8e34ee5b 100644 --- a/scripts/dev-guard.mjs +++ b/scripts/dev-guard.mjs @@ -7,15 +7,15 @@ const TOOLS = [ { name: "Stellar CLI", check: "stellar --version", - install: "cargo install --locked stellar-cli", - binstall: "cargo binstall -y --locked stellar-cli", + install: "cargo install --locked stellar-cli@27.0.0", + binstall: "cargo binstall -y --locked stellar-cli@27.0.0", url: "https://developers.stellar.org/docs/tools/developer-tools/cli/install-cli", }, { name: "Stellar Scaffold CLI", check: "stellar scaffold --version", - install: "cargo install --locked stellar-scaffold-cli", - binstall: "cargo binstall -y --locked stellar-scaffold-cli", + install: "cargo install --locked stellar-scaffold-cli@0.0.27", + binstall: "cargo binstall -y --locked stellar-scaffold-cli@0.0.27", url: "https://github.com/stellar-scaffold/cli#quickstart-new-developers-welcome", }, ] diff --git a/scripts/generate-addresses.mjs b/scripts/generate-addresses.mjs index dd32bcf9..57413d55 100644 --- a/scripts/generate-addresses.mjs +++ b/scripts/generate-addresses.mjs @@ -10,8 +10,6 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") const source = resolve(repoRoot, "environments.toml") const target = resolve(repoRoot, "app/src/config/addresses.ts") -// Only deployed environments declare their addresses here; a local one gets -// them when it deploys, which is after this runs. const environment = selectedEnvironment(process.env) const addresses = contractIds(readFileSync(source, "utf8"), environment) diff --git a/scripts/harness/README.md b/scripts/harness/README.md index fd5a0c8c..c7bd8ff5 100644 --- a/scripts/harness/README.md +++ b/scripts/harness/README.md @@ -1,6 +1,8 @@ # Test & deploy harness -Deploys the vault contracts to a network and runs integration tests. +Deploys the vault contracts to a network and runs integration tests. Before it +closes setup, the deploy sets the vault's custodian and its notice to twice +the network's oracle cooldown. ## Usage @@ -13,7 +15,11 @@ npm run deploy # Unit tests of the harness itself, no network needed npm run test:unit +``` + +CI runs these in the `js` job through the root `npm run test`. +```sh # Run test suites npm run test:happy-path npm run test:guards @@ -21,12 +27,19 @@ npm run test:guards `HARNESS_NETWORK` picks the network: `local` (the default) or `testnet`. +Each attestation carries the SHA-256 of `docs/evidence/sample-valuation.md` and +a URL to it: the copy on `main`, or `PROOF_URI` when set. + ## Account keys -The harness signs with thirteen accounts: `deployer`, `governance`, `manager`, -`treasury`, `guardian`, `compliance`, `attester`, `custodian`, `issuer`, -`holder-1`, `holder-2`, `holder-3` and `outsider`. Their secret keys live in -`.env.keys.` at the repository root, one line per account. +The harness signs with fourteen accounts: `deployer`, `governance`, `manager`, +`treasury`, `guardian`, `compliance`, `compliance-backup`, `attester`, +`custodian`, `issuer`, `holder-1`, `holder-2`, `holder-3` and `outsider`. +Their secret keys live in `.env.keys.` at the repository root, one +line per account. + +`compliance-backup` holds the share token's `compliance` role as a cold spare +and signs nothing in the tests. - The first deploy creates the file, with a new key for every account. - Later deploys reuse the keys in the file. A deploy adds a key only for an @@ -55,9 +68,55 @@ file. ## Deployment records -`deployed..json` records the deployed contract IDs and the accounts, -secrets included. A new deploy keeps the previous record as +`deployed..json` records the deployed contract IDs and each account's +**public** key. It holds no secret key. `.env.keys.` is the only +harness file that does. Every script that signs reads it, and stops if a key +there is not the one the deployment was made with. + +Records written by older versions of the harness also hold each account's +secret key. Keep a record while you still use its deployment. Make sure +`.env.keys.` holds the same keys (copy any missing one from the +record's `secret` field, as `HOLDER_1_SECRET=S...`), then remove the `secret` +fields. Delete old timestamped records you no longer need. + +A new deploy keeps the previous record as `deployed...json`, named after the time of that deploy, instead of overwriting it. Git ignores these files too. +A deploy that stops after creating a contract leaves +`deployed..incomplete.json` with the IDs created so far. The next +deploy stops and names it; move or delete it to deploy a new set. The last +complete record is left alone. + +### Checking a deployment against the source + +The record keeps `commit`, the commit checked out when the deploy ran, and +`wasmHashes`, the SHA-256 of each Wasm file it deployed. The deploy does not +rebuild, so build right before you deploy. With `uncommittedChanges: true`, +the commit alone cannot reproduce the build. + +Rebuild and compare on the same operating system and CPU as the deploy: + +```sh +git checkout +stellar contract build # stellar-cli 27.0.0, the toolchain in rust-toolchain.toml +shasum -a 256 target/wasm32v1-none/release/*.wasm +``` + +Each hash should equal the matching entry in `wasmHashes`. A build on +another platform can differ: CI's Linux build and a macOS build of the same +commit hash differently for four of the five contracts, with no build path +in either. + +To check what the network runs, which needs no rebuild: + +```sh +stellar contract fetch --id --rpc-url \ + --network-passphrase "" --out-file fetched.wasm +shasum -a 256 fetched.wasm +``` + +Run `stellar contract fetch --help` for the current flags. CI prints the +hash of each contract in its main build, built on Linux x86_64. + Test reports write to `runs/.json`. diff --git a/scripts/harness/build-clients.ts b/scripts/harness/build-clients.ts index 2dacff6c..d7e3e1da 100644 --- a/scripts/harness/build-clients.ts +++ b/scripts/harness/build-clients.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs" import { join } from "node:path" -import { generateClient } from "./lib/cli.js" +import { generateClient, rootSdkVersion } from "./lib/cli.js" import { CONTRACT_NAMES, REPO_ROOT, @@ -21,8 +21,9 @@ async function main(): Promise { ) } + const sdkVersion = rootSdkVersion() for (const name of CONTRACT_NAMES) { - await generateClient(wasmOf(name), clientDir(name)) + await generateClient(wasmOf(name), clientDir(name), sdkVersion) } console.log("clients generated") } diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 1987170c..0bd3a475 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -2,31 +2,51 @@ import { existsSync } from "node:fs" import { basename, join, relative } from "node:path" import { pathToFileURL } from "node:url" -import { Asset, rpc, type Keypair } from "@stellar/stellar-sdk" +import { Asset, Keypair, rpc } from "@stellar/stellar-sdk" import { basicNodeSigner } from "@stellar/stellar-sdk/contract" +import type * as AsyncVaultModule from "../../app-lib/clients/async_vault/dist/index.js" import type * as ComplianceModule from "../../app-lib/clients/compliance/dist/index.js" import type * as IdentityVerifierModule from "../../app-lib/clients/identity_verifier/dist/index.js" +import type * as NavOracleModule from "../../app-lib/clients/nav_oracle/dist/index.js" import type * as ShareTokenModule from "../../app-lib/clients/share_token/dist/index.js" -import { fund, type Accounts } from "./lib/accounts.js" +import { ACCOUNT_NAMES, fund, pay, type Accounts } from "./lib/accounts.js" +import { returnToIssuer } from "./lib/balances.js" import { deployContract, ensureAssetContract, generateClient, + rootSdkVersion, } from "./lib/cli.js" import { + clearIncomplete, CONTRACT_NAMES, + publicKeysOf, + refuseIncomplete, REPO_ROOT, saveDeployment, + writeIncomplete, type ContractName, type Deployment, + type IncompleteDeployment, } from "./lib/deployment.js" import { ensureKeys } from "./lib/keys.js" import { network, type Network } from "./lib/network.js" +import { readProvenance, type Provenance } from "./lib/provenance.js" +import { withAccountRetry } from "./lib/rpc.js" -const wasmOf = (name: ContractName): string => - join(REPO_ROOT, "target", "wasm32v1-none", "release", `${name}.wasm`) +const RELEASE_DIR = join(REPO_ROOT, "target", "wasm32v1-none", "release") +export const SHORT_WIND_DOWN_DIR = join( + REPO_ROOT, + "target", + "short-wind-down", + "wasm32v1-none", + "release", +) + +export const wasmOf = (name: ContractName, dir = RELEASE_DIR): string => + join(dir, `${name}.wasm`) const clientDir = (name: ContractName): string => join(REPO_ROOT, "app-lib", "clients", name) @@ -34,8 +54,10 @@ const clientDir = (name: ContractName): string => const clientEntry = (name: ContractName): string => pathToFileURL(join(clientDir(name), "dist", "index.js")).href -function requireWasm(): void { - const missing = CONTRACT_NAMES.filter((name) => !existsSync(wasmOf(name))) +function requireWasm(vaultWasm: string = wasmOf("async_vault")): void { + const missing = CONTRACT_NAMES.filter( + (name) => !existsSync(name === "async_vault" ? vaultWasm : wasmOf(name)), + ) if (missing.length > 0) { throw new Error( `no wasm for ${missing.join(", ")}; run \`stellar contract build\` first`, @@ -43,13 +65,19 @@ function requireWasm(): void { } } -function clientOptions(net: Network, contractId: string, signer: Keypair) { +function clientOptions( + net: Network, + contractId: string, + signer: Keypair, + server: rpc.Server, +) { return { contractId, rpcUrl: net.rpcUrl, networkPassphrase: net.passphrase, allowHttp: net.name === "local", publicKey: signer.publicKey(), + server, ...basicNodeSigner(signer, net.passphrase), } } @@ -58,66 +86,63 @@ async function deployContracts( net: Network, accounts: Accounts, assetContract: string, + wasms: Record, + onDeployed?: (name: ContractName, id: string) => void, ): Promise> { const pk = (name: keyof Accounts): string => accounts[name].publicKey() const deployer = accounts.deployer const governance = pk("governance") - const compliance = await deployContract(net, wasmOf("compliance"), deployer, { + const compliance = await deployContract(net, wasms.compliance, deployer, { admin: governance, }) console.log("compliance", compliance) + onDeployed?.("compliance", compliance) const identityVerifier = await deployContract( net, - wasmOf("identity_verifier"), + wasms.identity_verifier, deployer, { admin: pk("compliance") }, ) console.log("identity_verifier", identityVerifier) + onDeployed?.("identity_verifier", identityVerifier) - const shareToken = await deployContract( - net, - wasmOf("share_token"), - deployer, - { - name: "Strata Vault USDC", - symbol: "bvUSDC", - admin: governance, - compliance_authority: pk("compliance"), - compliance, - identity_verifier: identityVerifier, - }, - ) + const shareToken = await deployContract(net, wasms.share_token, deployer, { + name: "Strata Vault USDC", + symbol: "bvUSDC", + admin: governance, + compliance_authority: pk("compliance"), + compliance, + identity_verifier: identityVerifier, + }) console.log("share_token", shareToken) + onDeployed?.("share_token", shareToken) - const navOracle = await deployContract(net, wasmOf("nav_oracle"), deployer, { + const navOracle = await deployContract(net, wasms.nav_oracle, deployer, { admin: governance, attester: pk("attester"), guardian: pk("guardian"), config: JSON.stringify(net.oracle), }) console.log("nav_oracle", navOracle) + onDeployed?.("nav_oracle", navOracle) - const asyncVault = await deployContract( - net, - wasmOf("async_vault"), - deployer, - { - asset: assetContract, - share_token: shareToken, - oracle: navOracle, - roles: JSON.stringify({ - governance, - manager: pk("manager"), - treasury: pk("treasury"), - guardian: pk("guardian"), - compliance: pk("compliance"), - attester: pk("attester"), - }), - }, - ) + const asyncVault = await deployContract(net, wasms.async_vault, deployer, { + asset: assetContract, + share_token: shareToken, + oracle: navOracle, + roles: JSON.stringify({ + governance, + manager: pk("manager"), + treasury: pk("treasury"), + guardian: pk("guardian"), + compliance: pk("compliance"), + attester: pk("attester"), + }), + }) console.log("async_vault", asyncVault) + onDeployed?.("async_vault", asyncVault) return { compliance, @@ -128,14 +153,15 @@ async function deployContracts( } } -// The vault mints and burns, so it needs the token's manager role; it returns -// escrowed shares through the checked transfer, so it must itself be -// allowed; and compliance only runs its hooks for a bound token, so the +// The vault mints and burns, so it needs the token's manager role; it mints a +// delisted holder's converted deposit into its own escrow, so it must itself +// be allowed; and compliance only runs its hooks for a bound token, so the // share token must be bound to it. async function grantAndVerify( net: Network, accounts: Accounts, contracts: Record, + server: rpc.Server, ): Promise { const governance = accounts.governance const caller = governance.publicKey() @@ -145,7 +171,7 @@ async function grantAndVerify( clientEntry("share_token") )) as typeof ShareTokenModule const token = new ShareToken( - clientOptions(net, contracts.share_token, governance), + clientOptions(net, contracts.share_token, governance, server), ) await ( await token.grant_role({ account: vault, role: "manager", caller }) @@ -157,17 +183,38 @@ async function grantAndVerify( throw new Error("the vault did not receive the share token's manager role") console.log("vault holds share_token manager role") + const backup = accounts["compliance-backup"].publicKey() + await ( + await token.grant_role({ account: backup, role: "compliance", caller }) + ).signAndSend() + const holders = (await token.get_role_member_count({ role: "compliance" })) + .result + if (holders !== 2) + throw new Error("the share token needs a backup compliance holder") + console.log("share_token has a backup compliance holder") + + // Grants are instant only during setup, so the token's setup closes after them. + await (await token.bind_vault({ vault })).signAndSend() + await (await token.finish_setup()).signAndSend() + if ((await token.setup_open()).result) + throw new Error("the share token's setup did not finish") + console.log("share_token bound to the vault, setup finished") + const { Client: IdentityVerifier } = (await import( clientEntry("identity_verifier") )) as typeof IdentityVerifierModule const verifier = new IdentityVerifier( - clientOptions(net, contracts.identity_verifier, accounts.compliance), + clientOptions( + net, + contracts.identity_verifier, + accounts.compliance, + server, + ), ) await ( await verifier.allow({ account: vault, allowed: true, - caller: accounts.compliance.publicKey(), }) ).signAndSend() const allowed = (await verifier.is_allowed({ account: vault })).result @@ -179,7 +226,7 @@ async function grantAndVerify( clientEntry("compliance") )) as typeof ComplianceModule const compliance = new Compliance( - clientOptions(net, contracts.compliance, governance), + clientOptions(net, contracts.compliance, governance, server), ) await ( await compliance.bind_token({ @@ -191,20 +238,93 @@ async function grantAndVerify( if (!linked.includes(contracts.share_token)) throw new Error("the share token was not bound to the compliance contract") console.log("share_token bound to compliance") -} -async function main(): Promise { - requireWasm() - const net = network() - const server = new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }) - console.log(`deploying to ${net.name} (${net.rpcUrl})`) + // Binding the token is instant only during setup, so the compliance + // contract's setup closes after it. + await (await compliance.bind_vault({ vault })).signAndSend() + await (await compliance.finish_setup()).signAndSend() + if ((await compliance.setup_open()).result) + throw new Error("the compliance contract's setup did not finish") + console.log("compliance bound to the vault, setup finished") - const { file, accounts, created } = ensureKeys(net.name, REPO_ROOT) - console.log( - `account keys in ${relative(REPO_ROOT, file)} (${created.length} new)`, + const { Client: NavOracle } = (await import( + clientEntry("nav_oracle") + )) as typeof NavOracleModule + const feed = new NavOracle( + clientOptions(net, contracts.nav_oracle, governance, server), + ) + await (await feed.bind_vault({ vault })).signAndSend() + await (await feed.finish_setup()).signAndSend() + if ((await feed.setup_open()).result) + throw new Error("the oracle's setup did not finish") + console.log("nav_oracle bound to the vault, setup finished") +} + +// The custodian is set while setup is open; afterwards it changes only through +// the timelock. finish_setup refuses until the custodian and every wiring step +// above are in place. +async function finishVaultSetup( + net: Network, + accounts: Accounts, + contracts: Record, + server: rpc.Server, +): Promise { + const governance = accounts.governance + const { Client: AsyncVault } = (await import( + clientEntry("async_vault") + )) as typeof AsyncVaultModule + const vault = new AsyncVault( + clientOptions(net, contracts.async_vault, governance, server), ) + await ( + await vault.set_custodian({ + custodian: accounts.custodian.publicKey(), + }) + ).signAndSend() + const noticeSecs = BigInt(net.vault.notice_secs) + await (await vault.set_notice({ secs: noticeSecs })).signAndSend() + const notice = (await vault.notice()).result + if (notice !== noticeSecs) + throw new Error(`vault notice is ${notice}, expected ${noticeSecs}`) + console.log(`vault notice set to ${noticeSecs} seconds`) + await (await vault.finish_setup()).signAndSend() + if ((await vault.setup_open()).result) + throw new Error("the vault's setup did not finish") + console.log("vault setup finished") + + const health = (await vault.health()).result + console.log("health", JSON.stringify(health)) + const wiring = [ + "token_bound", + "oracle_bound", + "sole_token_manager", + "allowlisted", + "vault_unfrozen", + "compliance_bound", + "decimals_match", + "addresses_distinct", + "custodian_set", + "feed_answers", + ] as const + const broken = wiring.filter((flag) => !health[flag]) + if (health.setup_open || broken.length > 0) + throw new Error(`the vault is not wired: ${broken.join(", ")}`) +} + +async function freshSet( + net: Network, + wasms: Record, + provenance: Provenance, + accounts: Accounts, + server: rpc.Server, + onProgress?: (progress: IncompleteDeployment) => void, +): Promise { + // fund() reads "not found" as "not yet created"; it needs the plain server. + const fundingServer = new rpc.Server(net.rpcUrl, { + allowHttp: net.name === "local", + }) const keys = Object.values(accounts) - const funded = await fund(net, server, keys) + const funded = await fund(net, fundingServer, keys) console.log( `funded ${funded} accounts; ${keys.length - funded} already existed`, ) @@ -218,42 +338,132 @@ async function main(): Promise { ) console.log("asset contract", assetContract) - const contracts = await deployContracts(net, accounts, assetContract) - - for (const name of CONTRACT_NAMES) { - await generateClient(wasmOf(name), clientDir(name)) - } - console.log("clients generated") + const issuer = asset.getIssuer() ?? accounts.issuer.publicKey() + const returned = await returnToIssuer(server, keys, asset, (from, amount) => + pay(net, server, from, issuer, asset, amount), + ) + console.log( + `returned the deposit asset of ${returned} accounts to its issuer`, + ) - await grantAndVerify(net, accounts, contracts) + const assetInfo = { code: asset.getCode(), issuer, contract: assetContract } + const startedAt = new Date().toISOString() + const progress: Partial> = {} + const onDeployed = onProgress + ? (name: ContractName, id: string): void => { + progress[name] = id + onProgress({ + network: net.name, + startedAt, + complete: false, + asset: assetInfo, + contracts: { ...progress }, + }) + } + : undefined + const contracts = await deployContracts( + net, + accounts, + assetContract, + wasms, + onDeployed, + ) - const record: Deployment = { + return { network: net.name, deployedAt: new Date().toISOString(), + ...provenance, ledger: (await server.getLatestLedger()).sequence, - asset: { - code: asset.getCode(), - issuer: asset.getIssuer() ?? accounts.issuer.publicKey(), - contract: assetContract, - }, + asset: assetInfo, contracts, - accounts: Object.fromEntries( - Object.entries(accounts).map(([name, key]) => [ - name, - { public: key.publicKey(), secret: key.secret() }, - ]), - ) as Deployment["accounts"], + accounts: publicKeysOf(accounts), + } +} + +// Random accounts, so a throwaway set leaves the kept keys and their funds alone. +const throwawayAccounts = (): Accounts => + Object.fromEntries( + ACCOUNT_NAMES.map((name) => [name, Keypair.random()]), + ) as Accounts + +// A full, wired vault set built from a different vault Wasm. It reuses the +// clients the main deploy generated, and nothing is written to disk. +export async function deployThrowaway( + net: Network, + vaultWasm: string, +): Promise<{ deployment: Deployment; keys: Accounts }> { + requireWasm(vaultWasm) + const wasms = Object.fromEntries( + CONTRACT_NAMES.map((name) => [name, wasmOf(name)]), + ) as Record + wasms.async_vault = vaultWasm + const provenance = readProvenance(REPO_ROOT, wasms) + const keys = throwawayAccounts() + const server = withAccountRetry( + new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }), + ) + const deployment = await freshSet(net, wasms, provenance, keys, server) + await grantAndVerify(net, keys, deployment.contracts, server) + await finishVaultSetup(net, keys, deployment.contracts, server) + return { deployment, keys } +} + +async function main(): Promise { + requireWasm() + const net = network() + refuseIncomplete(net.name) + console.log(`deploying to ${net.name} (${net.rpcUrl})`) + const wasms = Object.fromEntries( + CONTRACT_NAMES.map((name) => [name, wasmOf(name)]), + ) as Record + const provenance = readProvenance(REPO_ROOT, wasms) + const sdkVersion = rootSdkVersion() + const { file, accounts, created } = ensureKeys(net.name, REPO_ROOT) + console.log( + `account keys in ${relative(REPO_ROOT, file)} (${created.length} new)`, + ) + const server = withAccountRetry( + new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }), + ) + const record = await freshSet( + net, + wasms, + provenance, + accounts, + server, + (progress) => writeIncomplete(progress), + ) + console.log( + `commit ${record.commit}${record.uncommittedChanges ? " with uncommitted changes" : ""}`, + ) + + for (const name of CONTRACT_NAMES) { + await generateClient(wasmOf(name), clientDir(name), sdkVersion) } + console.log("clients generated") + + await grantAndVerify(net, accounts, record.contracts, server) + await finishVaultSetup(net, accounts, record.contracts, server) + const kept = saveDeployment(record) + clearIncomplete(net.name) if (kept !== undefined) console.log(`kept the previous record as ${basename(kept)}`) console.log(`wrote deployed.${net.name}.json`) if (net.name === "testnet") { - console.log("updated environments.toml [staging.contracts]") + console.log( + "updated environments.toml [staging.contracts] and the README addresses: commit both, or the app keeps building against the previous contracts", + ) } } -main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : error) - process.exitCode = 1 -}) +const invokedDirectly = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href + +if (invokedDirectly) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/scripts/harness/eslint.config.js b/scripts/harness/eslint.config.js index 559e8191..7cdca512 100644 --- a/scripts/harness/eslint.config.js +++ b/scripts/harness/eslint.config.js @@ -11,4 +11,22 @@ export default [ parserOptions: { tsconfigRoot: import.meta.dirname }, }, }, + { + files: ["**/*.ts"], + ignores: ["**/*.test.ts", "vitest.config.ts"], + rules: { + "@typescript-eslint/no-restricted-imports": [ + "error", + { + paths: [ + { + name: "vitest", + message: + "Only tests import vitest: scripts such as seed.ts run without it.", + }, + ], + }, + ], + }, + }, ] diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts new file mode 100644 index 00000000..b386f04b --- /dev/null +++ b/scripts/harness/extend.ts @@ -0,0 +1,26 @@ +import { describeTarget, resolveTarget } from "./lib/extend-target.js" +import { keepDeploymentAlive } from "./lib/keep-alive.js" +import { latestLedger } from "./lib/ttl.js" + +// Restores, when archived, and extends by 30 days each contract instance, the +// Wasm it runs and each pending timelock change, so an idle deployment stays +// callable. Extending needs no authority: any funded account pays. + +async function main(): Promise { + if (!process.env["STELLAR_ACCOUNT"]) + throw new Error( + "set STELLAR_ACCOUNT to a funded account: a stellar CLI identity name or a secret key", + ) + const target = resolveTarget(process.argv.slice(2), process.env) + console.log(describeTarget(target)) + const latest = await latestLedger(target.rpc) + console.log( + `extending ${target.contracts.length} contracts, latest ledger ${latest}`, + ) + await keepDeploymentAlive(target.rpc, target.contracts) +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 586a7cdb..25f899ba 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -1,9 +1,9 @@ -import { setTimeout as sleep } from "node:timers/promises" -import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { beforeAll, describe, expect, it } from "vitest" import { pay, trustline } from "./lib/accounts.js" import { assetsFor, price, sharesFor, units } from "./lib/amounts.js" import { + complianceContract, connect, identityVerifier, oracle, @@ -13,7 +13,8 @@ import { vault, type Connected, } from "./lib/clients.js" -import { attest } from "./lib/oracle.js" +import { waitForNotice } from "./lib/notice.js" +import { attest, freshFeed, navInput } from "./lib/oracle.js" import { expectRefused } from "./lib/refused.js" const c: Connected = connect() @@ -46,8 +47,10 @@ const epochOf = async (id: bigint) => const closeEpoch = () => send(vault(c, manager).close_epoch({ caller: manager.publicKey() })) -const fulfill = (id: bigint) => - send(vault(c, outsider).fulfill_epoch({ epoch_id: id })) +const fulfill = async (id: bigint) => { + await waitForNotice(c, reader, id) + return send(vault(c, outsider).fulfill_epoch({ epoch_id: id })) +} beforeAll(async () => { decimals = (await shareToken(c, outsider).decimals()).result @@ -61,16 +64,9 @@ beforeAll(async () => { verifier.allow({ account: holder.publicKey(), allowed: true, - caller: compliance.publicKey(), }), ) } - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), - ) }) describe("an account with no role", () => { @@ -90,32 +86,28 @@ describe("an account with no role", () => { ) }) - it("cannot set the custodian", async () => { + it("cannot change the custodian", async () => { await expect( - send(vault(c, outsider).set_custodian({ custodian: who, caller: who })), + send( + vault(c, outsider).propose_change({ + change: { tag: "Custodian", values: [who] }, + }), + ), ).rejects.toThrow(/signatures/) }) it("cannot attest a price", async () => { const o = oracle(c, outsider) - const now = BigInt(Math.floor(Date.now() / 1000)) expectRefused( - await o.attest({ - report: { - nav_per_share: price(2), - expires_at: now + 600n, - timestamp: now, - }, - caller: who, - }), + await o.attest({ input: navInput(price(2)), caller: who }), o, "Unauthorized", ) }) - it("cannot raise the ripcord", async () => { + it("cannot pause the NAV", async () => { const o = oracle(c, outsider) - expectRefused(await o.raise_ripcord({ caller: who }), o, "Unauthorized") + expectRefused(await o.pause_nav({ caller: who }), o, "Unauthorized") }) it("cannot pause the vault", async () => { @@ -125,29 +117,30 @@ describe("an account with no role", () => { it("cannot unpause the vault", async () => { await send(vault(c, guardian).pause({ caller: guardian.publicKey() })) - await expect( - send(vault(c, outsider).unpause({ caller: who })), - ).rejects.toThrow(/signatures/) + const v = vault(c, outsider) + expectRefused(await v.unpause({ caller: who }), v, "AdminRequired") await send(vault(c, governance).unpause({ caller: governance.publicKey() })) }) - it("cannot set the ripcord", async () => { + it("cannot pause the share token", async () => { + const token = shareToken(c, outsider) + expectRefused(await token.pause({ caller: who }), token, "AdminRequired") + }) + + it("cannot set the NAV pause", async () => { await expect( - send(oracle(c, outsider).set_ripcord({ paused: true, caller: who })), + send(oracle(c, outsider).set_nav_paused({ paused: true })), ).rejects.toThrow(/signatures/) }) it("cannot clear the latest report", async () => { - await send( - oracle(c, guardian).raise_ripcord({ caller: guardian.publicKey() }), + await send(oracle(c, guardian).pause_nav({ caller: guardian.publicKey() })) + await expect(send(oracle(c, outsider).clear_latest())).rejects.toThrow( + /signatures/, ) - await expect( - send(oracle(c, outsider).clear_latest({ caller: who })), - ).rejects.toThrow(/signatures/) await send( - oracle(c, governance).set_ripcord({ + oracle(c, governance).set_nav_paused({ paused: false, - caller: governance.publicKey(), }), ) }) @@ -169,7 +162,6 @@ describe("the compliance authority's reach", () => { identityVerifier(c, governance).allow({ account: outsider.publicKey(), allowed: true, - caller: governance.publicKey(), }), ), ).rejects.toThrow(/signatures/) @@ -203,23 +195,173 @@ describe("the compliance authority's reach", () => { ) }) - it("compliance cannot swap the identity verifier", async () => { + it("compliance cannot propose a new identity verifier", async () => { await expect( send( - shareToken(c, compliance).set_identity_verifier({ - identity_verifier: c.deployment.contracts.compliance, - operator: compliance.publicKey(), + shareToken(c, compliance).propose_change({ + change: { + tag: "IdentityVerifier", + values: [c.deployment.contracts.identity_verifier], + }, }), ), ).rejects.toThrow(/signatures/) }) }) +describe("the share token after setup", () => { + const caller = () => governance.publicKey() + const verifier = () => c.deployment.contracts.identity_verifier + + it("refuses an instant verifier swap, even from governance", async () => { + const token = shareToken(c, governance) + expectRefused( + await token.set_identity_verifier({ + identity_verifier: verifier(), + operator: caller(), + }), + token, + "UseTimelock", + ) + }) + + it("queues the swap behind the vault's delay", async () => { + const token = shareToken(c, governance) + const id = await send( + token.propose_change({ + change: { tag: "IdentityVerifier", values: [verifier()] }, + }), + ) + expectRefused(await token.apply_change({ id }), token, "ChangeNotReady") + await send(token.cancel_change({ id })) + expect((await token.pending_changes()).result).toEqual([]) + }) +}) + +describe("the oracle after setup", () => { + it("governance cannot loosen the feed config at once", async () => { + const config = (await feed.config()).result + const o = oracle(c, governance) + expectRefused( + await o.set_config({ + config: { + ...config, + freshness_duration: config.freshness_duration + 60n, + }, + }), + o, + "UseTimelock", + ) + }) + + it("governance cannot tighten the fall cap at once either", async () => { + const config = (await feed.config()).result + const o = oracle(c, governance) + expectRefused( + await o.set_config({ + config: { ...config, max_down_bps: config.max_down_bps - 1 }, + }), + o, + "UseTimelock", + ) + }) + + it("queues a config change behind the vault's delay", async () => { + const config = (await feed.config()).result + const o = oracle(c, governance) + const id = await send( + o.propose_change({ + change: { + tag: "Config", + values: [{ ...config, max_down_bps: config.max_down_bps - 1 }], + }, + }), + ) + expectRefused(await o.apply_change({ id }), o, "ChangeNotReady") + await send(o.cancel_change({ id })) + expect((await o.pending_changes()).result).toEqual([]) + }) +}) + +describe("the compliance contract after setup", () => { + const caller = () => governance.publicKey() + const canCreate = { tag: "CanCreate", values: undefined } as const + + it("governance cannot add a module at once", async () => { + const rules = complianceContract(c, governance) + expectRefused( + await rules.add_module_to({ + hook: canCreate, + module: outsider.publicKey(), + operator: caller(), + }), + rules, + "UseTimelock", + ) + }) + + it("queues a module behind the vault's delay", async () => { + const rules = complianceContract(c, governance) + const id = await send( + rules.propose_change({ + change: { tag: "AddModule", values: [canCreate, outsider.publicKey()] }, + }), + ) + expectRefused(await rules.apply_change({ id }), rules, "ChangeNotReady") + await send(rules.cancel_change({ id })) + expect((await rules.pending_changes()).result).toEqual([]) + }) +}) + +describe("the vault after setup", () => { + it("governance cannot change the custodian at once", async () => { + const v = vault(c, governance) + expectRefused( + await v.set_custodian({ + custodian: outsider.publicKey(), + }), + v, + "UseTimelock", + ) + expect((await reader.custodian()).result).toBe(custodian.publicKey()) + }) + + it("governance cannot make the vault its own custodian", async () => { + const v = vault(c, governance) + const vaultId = c.deployment.contracts.async_vault + expectRefused( + await v.set_custodian({ custodian: vaultId }), + v, + "InvalidCustodian", + ) + expectRefused( + await v.propose_change({ + change: { tag: "Custodian", values: [vaultId] }, + }), + v, + "InvalidCustodian", + ) + expect((await reader.custodian()).result).toBe(custodian.publicKey()) + }) + + it("queues a custodian change behind its delay", async () => { + const v = vault(c, governance) + const id = await send( + v.propose_change({ + change: { tag: "Custodian", values: [outsider.publicKey()] }, + }), + ) + expectRefused(await v.apply_change({ id }), v, "ChangeNotReady") + await send(v.cancel_change({ id })) + expect((await v.pending_changes()).result).toEqual([]) + }) +}) + describe("invalid amounts and duplicate requests in an epoch", () => { it("rejects zero deposit amount", async () => { const v = vault(c, first) expectRefused( - await v.request_deposit({ from: first.publicKey(), amount: 0n }), + await v.request_deposit({ holder: first.publicKey(), amount: 0n }), v, "InvalidAmount", ) @@ -228,23 +370,38 @@ describe("invalid amounts and duplicate requests in an epoch", () => { it("rejects zero redeem shares", async () => { const v = vault(c, first) expectRefused( - await v.request_redeem({ from: first.publicKey(), shares: 0n }), + await v.request_redeem({ holder: first.publicKey(), shares: 0n }), v, "InvalidAmount", ) }) it("rejects a second request from the same account in one epoch", async () => { + await freshFeed(c, firstPrice) const v = vault(c, first) const epoch = await send( - v.request_deposit({ from: first.publicKey(), amount: usdc(10) }), + v.request_deposit({ holder: first.publicKey(), amount: usdc(10) }), ) expectRefused( - await v.request_deposit({ from: first.publicKey(), amount: usdc(10) }), + await v.request_deposit({ holder: first.publicKey(), amount: usdc(10) }), v, "RequestOutstanding", ) - await send(v.cancel_deposit({ from: first.publicKey(), epoch_id: epoch })) + await send(v.cancel_deposit({ holder: first.publicKey(), epoch_id: epoch })) + }) +}) + +describe("who may subscribe", () => { + it("refuses a deposit request from an address that is not allowlisted", async () => { + const v = vault(c, outsider) + expectRefused( + await v.request_deposit({ + holder: outsider.publicKey(), + amount: usdc(10), + }), + v, + "NotAllowlisted", + ) }) }) @@ -255,16 +412,18 @@ let epochB = 0n describe("two epochs pending at once", () => { it("both read Pending", async () => { + await freshFeed(c, firstPrice) epochA = await send( vault(c, first).request_deposit({ - from: first.publicKey(), + holder: first.publicKey(), amount: usdc(100), }), ) expect(await closeEpoch()).toBe(epochA) + await freshFeed(c, firstPrice) epochB = await send( vault(c, second).request_deposit({ - from: second.publicKey(), + holder: second.publicKey(), amount: usdc(100), }), ) @@ -273,7 +432,7 @@ describe("two epochs pending at once", () => { expect((await epochOf(epochB))?.status.tag).toBe("Pending") expectRefused( await vault(c, first).claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epochA, }), vault(c, first), @@ -294,7 +453,7 @@ describe("two epochs pending at once", () => { expect( await send( vault(c, first).claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epochA, }), ), @@ -302,14 +461,14 @@ describe("two epochs pending at once", () => { expect( await send( vault(c, second).claim_deposit({ - caller: second.publicKey(), + holder: second.publicKey(), epoch_id: epochB, }), ), ).toBe(sharesFor(usdc(100), secondPrice)) expectRefused( await vault(c, first).claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epochA, }), vault(c, first), @@ -317,7 +476,7 @@ describe("two epochs pending at once", () => { ) expectRefused( await vault(c, outsider).claim_deposit({ - caller: outsider.publicKey(), + holder: outsider.publicKey(), epoch_id: epochA, }), vault(c, outsider), @@ -336,13 +495,13 @@ describe("a small covered claim pays while a larger one waits", () => { large = await shares(second.publicKey()) await send( vault(c, first).request_redeem({ - from: first.publicKey(), + holder: first.publicKey(), shares: small, }), ) epoch = await send( vault(c, second).request_redeem({ - from: second.publicKey(), + holder: second.publicKey(), shares: large, }), ) @@ -364,7 +523,7 @@ describe("a small covered claim pays while a larger one waits", () => { const before = await usdcBalance(c, first.publicKey()) const paid = await send( vault(c, first).claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), ) @@ -375,7 +534,7 @@ describe("a small covered claim pays while a larger one waits", () => { it("the large claim waits", async () => { const v = vault(c, second) expectRefused( - await v.claim_redeem({ caller: second.publicKey(), epoch_id: epoch }), + await v.claim_redeem({ holder: second.publicKey(), epoch_id: epoch }), v, "ClaimNotCovered", ) @@ -385,13 +544,13 @@ describe("a small covered claim pays while a larger one waits", () => { it("pays once the capital returns", async () => { await send( vault(c, custodian).fund({ - from: custodian.publicKey(), + caller: custodian.publicKey(), assets: usdc(150), }), ) const paid = await send( vault(c, second).claim_redeem({ - caller: second.publicKey(), + holder: second.publicKey(), epoch_id: epoch, }), ) @@ -399,7 +558,7 @@ describe("a small covered claim pays while a larger one waits", () => { expect((await reader.uncovered()).result).toBe(0n) expectRefused( await vault(c, second).claim_redeem({ - caller: second.publicKey(), + holder: second.publicKey(), epoch_id: epoch, }), vault(c, second), @@ -413,8 +572,12 @@ describe("a deposit too small to mint a share", () => { it("is refunded at claim", async () => { const before = await usdcBalance(c, third.publicKey()) + await freshFeed(c, firstPrice) epoch = await send( - vault(c, third).request_deposit({ from: third.publicKey(), amount: 1n }), + vault(c, third).request_deposit({ + holder: third.publicKey(), + amount: 1n, + }), ) expect(await closeEpoch()).toBe(epoch) await attest(c, secondPrice) @@ -422,7 +585,7 @@ describe("a deposit too small to mint a share", () => { expect( await send( vault(c, third).claim_deposit({ - caller: third.publicKey(), + holder: third.publicKey(), epoch_id: epoch, }), ), @@ -433,26 +596,25 @@ describe("a deposit too small to mint a share", () => { }) describe("a de-listed holder", () => { - const from = first.publicKey() + const holder = first.publicKey() let epoch = 0n let redeeming = 0n it("cannot cancel a redemption back to shares", async () => { redeeming = sharesFor(usdc(10), secondPrice) epoch = await send( - vault(c, first).request_redeem({ from, shares: redeeming }), + vault(c, first).request_redeem({ holder, shares: redeeming }), ) await send( identityVerifier(c, compliance).allow({ account: first.publicKey(), allowed: false, - caller: compliance.publicKey(), }), ) expectRefused( - await vault(c, first).cancel_redeem({ from, epoch_id: epoch }), - shareToken(c, outsider), - "IdentityVerificationFailed", + await vault(c, first).cancel_redeem({ holder, epoch_id: epoch }), + vault(c, first), + "NotAllowlisted", ) }) @@ -463,7 +625,7 @@ describe("a de-listed holder", () => { const before = await usdcBalance(c, first.publicKey()) const paid = await send( vault(c, first).claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), ) @@ -476,7 +638,6 @@ describe("a de-listed holder", () => { identityVerifier(c, compliance).allow({ account: first.publicKey(), allowed: true, - caller: compliance.publicKey(), }), ) expect( @@ -495,15 +656,16 @@ describe("a sealed epoch while the feed is down", () => { it("cannot be cancelled while the feed can price it", async () => { redeeming = await shares(first.publicKey()) + await freshFeed(c, firstPrice) await send( vault(c, first).request_deposit({ - from: first.publicKey(), + holder: first.publicKey(), amount: usdc(100), }), ) epoch = await send( vault(c, first).request_redeem({ - from: first.publicKey(), + holder: first.publicKey(), shares: redeeming, }), ) @@ -511,21 +673,20 @@ describe("a sealed epoch while the feed is down", () => { await attest(c, secondPrice) const v = vault(c, first) expectRefused( - await v.cancel_deposit({ from: first.publicKey(), epoch_id: epoch }), + await v.cancel_deposit({ holder: first.publicKey(), epoch_id: epoch }), v, "PriceAvailable", ) expectRefused( - await v.cancel_redeem({ from: first.publicKey(), epoch_id: epoch }), + await v.cancel_redeem({ holder: first.publicKey(), epoch_id: epoch }), v, "PriceAvailable", ) }) - it("the guardian raises the ripcord and pricing is refused", async () => { - await send( - oracle(c, guardian).raise_ripcord({ caller: guardian.publicKey() }), - ) + it("the guardian pauses the NAV and pricing is refused", async () => { + await waitForNotice(c, reader, epoch) + await send(oracle(c, guardian).pause_nav({ caller: guardian.publicKey() })) expect((await feed.state()).result.tag).toBe("Paused") expectRefused( await vault(c, outsider).fulfill_epoch({ epoch_id: epoch }), @@ -539,7 +700,7 @@ describe("a sealed epoch while the feed is down", () => { expect( await send( vault(c, first).cancel_deposit({ - from: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), ), @@ -551,7 +712,7 @@ describe("a sealed epoch while the feed is down", () => { expect( await send( vault(c, first).cancel_redeem({ - from: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), ), @@ -562,7 +723,7 @@ describe("a sealed epoch while the feed is down", () => { it("a second cancel finds no request", async () => { expectRefused( await vault(c, first).cancel_redeem({ - from: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), reader, @@ -572,38 +733,28 @@ describe("a sealed epoch while the feed is down", () => { }) describe("the notice period and valuation age rules", () => { - const noticeSecs = 10n + const noticeSecs = BigInt(c.net.vault.notice_secs) let epochA = 0n let epochB = 0n beforeAll(async () => { await send( - oracle(c, governance).set_ripcord({ + oracle(c, governance).set_nav_paused({ paused: false, - caller: governance.publicKey(), }), ) await send( vault(c, governance).set_notice({ secs: noticeSecs, - caller: governance.publicKey(), - }), - ) - }) - - afterAll(async () => { - await send( - vault(c, governance).set_notice({ - secs: 0n, - caller: governance.publicKey(), }), ) }) it("records priceable_at at close and holds it through the notice", async () => { + await freshFeed(c, firstPrice) await send( vault(c, first).request_deposit({ - from: first.publicKey(), + holder: first.publicKey(), amount: usdc(100), }), ) @@ -620,12 +771,7 @@ describe("the notice period and valuation age rules", () => { }) it("refuses pricing after the notice when the valuation is before the close", async () => { - const info = await epochOf(epochA) - const now = BigInt(Math.floor(Date.now() / 1000)) - const waitMs = Math.max(0, Number(info!.priceable_at - now + 2n) * 1000) - if (waitMs > 0) { - await sleep(waitMs) - } + await waitForNotice(c, reader, epochA) expectRefused( await vault(c, outsider).fulfill_epoch({ epoch_id: epochA }), reader, @@ -640,9 +786,10 @@ describe("the notice period and valuation age rules", () => { }) it("refuses cancellation while the notice is running once price is readable", async () => { + await freshFeed(c, firstPrice) await send( vault(c, first).request_deposit({ - from: first.publicKey(), + holder: first.publicKey(), amount: usdc(100), }), ) @@ -650,7 +797,7 @@ describe("the notice period and valuation age rules", () => { await attest(c, secondPrice) const v = vault(c, first) expectRefused( - await v.cancel_deposit({ from: first.publicKey(), epoch_id: epochB }), + await v.cancel_deposit({ holder: first.publicKey(), epoch_id: epochB }), v, "PriceAvailable", ) @@ -659,12 +806,13 @@ describe("the notice period and valuation age rules", () => { reader, "NoticeNotElapsed", ) - const info = await epochOf(epochB) - const now = BigInt(Math.floor(Date.now() / 1000)) - const waitMs = Math.max(0, Number(info!.priceable_at - now + 2n) * 1000) - if (waitMs > 0) { - await sleep(waitMs) - } + await waitForNotice(c, reader, epochB) expect(await fulfill(epochB)).toBe(secondPrice) }) + + it("governance cannot lower the notice at once", async () => { + const v = vault(c, governance) + expectRefused(await v.set_notice({ secs: 0n }), v, "UseTimelock") + expect((await reader.notice()).result).toBe(noticeSecs) + }) }) diff --git a/scripts/harness/happy-path.test.ts b/scripts/harness/happy-path.test.ts index 43fe0ec0..e89a9b7f 100644 --- a/scripts/harness/happy-path.test.ts +++ b/scripts/harness/happy-path.test.ts @@ -12,7 +12,8 @@ import { vault, type Connected, } from "./lib/clients.js" -import { attest } from "./lib/oracle.js" +import { waitForNotice } from "./lib/notice.js" +import { attest, freshFeed, navInput, SAMPLE_PROOF_URI } from "./lib/oracle.js" import { expectRefused } from "./lib/refused.js" const c: Connected = connect() @@ -22,15 +23,7 @@ const holders = [ c.keys["holder-3"], ] as const const [first, second, canceller] = holders -const { - governance, - compliance, - custodian, - outsider, - issuer, - manager, - treasury, -} = c.keys +const { compliance, custodian, outsider, issuer, manager, treasury } = c.keys let decimals = 7 const usdc = (whole: number) => units(whole, decimals) @@ -79,7 +72,6 @@ describe("setup", () => { verifier.allow({ account: holder.publicKey(), allowed: true, - caller: compliance.publicKey(), }), ) expect( @@ -96,13 +88,14 @@ describe("subscription", () => { let freeBefore = 0n it("two holders subscribe in the same epoch and see their escrow", async () => { + await freshFeed(c, price(2)) subscriptionEpoch = (await reader.current_epoch()).result escrowBefore = (await reader.cancellable_escrow()).result freeBefore = (await reader.free_reserve()).result for (const holder of [first, second]) { const epoch = await send( vault(c, holder).request_deposit({ - from: holder.publicKey(), + holder: holder.publicKey(), amount: each(), }), ) @@ -110,7 +103,7 @@ describe("subscription", () => { const request = ( await reader.get_deposit_request({ epoch_id: subscriptionEpoch, - controller: holder.publicKey(), + holder: holder.publicKey(), }) ).result expect(request?.amount).toBe(each()) @@ -128,7 +121,7 @@ describe("subscription", () => { const v = vault(c, first) expectRefused( await v.cancel_deposit({ - from: first.publicKey(), + holder: first.publicKey(), epoch_id: subscriptionEpoch + 1000n, }), v, @@ -140,12 +133,12 @@ describe("subscription", () => { const before = await usdcBalance(c, canceller.publicKey()) const v = vault(c, canceller) await send( - v.request_deposit({ from: canceller.publicKey(), amount: each() }), + v.request_deposit({ holder: canceller.publicKey(), amount: each() }), ) expect(await usdcBalance(c, canceller.publicKey())).toBe(before - each()) const returned = await send( v.cancel_deposit({ - from: canceller.publicKey(), + holder: canceller.publicKey(), epoch_id: subscriptionEpoch, }), ) @@ -180,12 +173,15 @@ describe("pricing", () => { await attest(c, nav) const latest = (await oracle(c, outsider).latest()).result expect(latest.nav_per_share).toBe(nav) - expect(latest.expires_at - latest.timestamp).toBe( + expect(latest.expires_at - latest.accepted_at).toBe( BigInt(c.net.oracle.freshness_duration), ) + expect(latest.proof_hash).toEqual(navInput(nav).proof_hash) + expect(latest.proof_uri).toBe(SAMPLE_PROOF_URI) }) it("an account with no role prices the epoch", async () => { + await waitForNotice(c, reader, subscriptionEpoch) const priced = await send( vault(c, outsider).fulfill_epoch({ epoch_id: subscriptionEpoch }), ) @@ -209,7 +205,7 @@ describe("pricing", () => { const before = await shares(holder.publicKey()) const got = await send( vault(c, holder).claim_deposit({ - caller: holder.publicKey(), + holder: holder.publicKey(), epoch_id: subscriptionEpoch, }), ) @@ -224,7 +220,7 @@ describe("pricing", () => { const v = vault(c, first) expectRefused( await v.claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: subscriptionEpoch, }), v, @@ -275,7 +271,7 @@ describe("redemption", () => { const held = await shares(holder.publicKey()) const epoch = await send( vault(c, holder).request_redeem({ - from: holder.publicKey(), + holder: holder.publicKey(), shares: held, }), ) @@ -290,7 +286,7 @@ describe("redemption", () => { it("refuses a second exit request in one epoch", async () => { const v = vault(c, first) expectRefused( - await v.request_redeem({ from: first.publicKey(), shares: 1n }), + await v.request_redeem({ holder: first.publicKey(), shares: 1n }), v, "RequestOutstanding", ) @@ -300,7 +296,7 @@ describe("redemption", () => { const v = vault(c, first) expectRefused( await v.claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: redemptionEpoch, }), v, @@ -313,14 +309,9 @@ describe("treasury", () => { const deployed = () => usdc(150) let owed = 0n - it("governance sets the custodian", async () => { - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), - ) + it("the deploy set the custodian before setup closed", async () => { expect((await reader.custodian()).result).toBe(custodian.publicKey()) + expect((await reader.setup_open()).result).toBe(false) }) it("the treasury deploys part of the reserve and the free reserve falls by exactly that amount", async () => { @@ -341,6 +332,7 @@ describe("treasury", () => { it("the exits are priced while the reserve is deployed", async () => { await send(vault(c, manager).close_epoch({ caller: manager.publicKey() })) await attest(c, nav) + await waitForNotice(c, reader, redemptionEpoch) await send(vault(c, outsider).fulfill_epoch({ epoch_id: redemptionEpoch })) expect(await epochStatus(redemptionEpoch)).toBe("Fulfilled") const total = [...redeemed.values()].reduce((a, b) => a + b, 0n) @@ -352,7 +344,7 @@ describe("treasury", () => { const v = vault(c, first) expectRefused( await v.claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: redemptionEpoch, }), v, @@ -375,7 +367,7 @@ describe("treasury", () => { it("fund returns the capital and the refused claims then pay", async () => { await send( vault(c, custodian).fund({ - from: custodian.publicKey(), + caller: custodian.publicKey(), assets: deployed(), }), ) @@ -384,7 +376,7 @@ describe("treasury", () => { const before = await usdcBalance(c, holder.publicKey()) const paid = await send( vault(c, holder).claim_redeem({ - caller: holder.publicKey(), + holder: holder.publicKey(), epoch_id: redemptionEpoch, }), ) diff --git a/scripts/harness/lib/account-table.test.ts b/scripts/harness/lib/account-table.test.ts new file mode 100644 index 00000000..2dc91bcc --- /dev/null +++ b/scripts/harness/lib/account-table.test.ts @@ -0,0 +1,18 @@ +import { Keypair } from "@stellar/stellar-sdk" +import { describe, expect, it } from "vitest" + +import { accountRow } from "./account-table.js" + +describe("accountRow", () => { + it("lists the role, public key and balances, and never the secret key", () => { + const key = Keypair.random() + const row = accountRow("holder-1", key, "1.0000", "2.0000") + expect(row).toEqual({ + Role: "holder-1", + PublicKey: key.publicKey(), + "USDC Balance": "1.0000", + "Shares Balance": "2.0000", + }) + expect(JSON.stringify(row)).not.toContain(key.secret()) + }) +}) diff --git a/scripts/harness/lib/account-table.ts b/scripts/harness/lib/account-table.ts new file mode 100644 index 00000000..a9ca95fa --- /dev/null +++ b/scripts/harness/lib/account-table.ts @@ -0,0 +1,22 @@ +import { type Keypair } from "@stellar/stellar-sdk" + +export interface AccountRow { + Role: string + PublicKey: string + "USDC Balance": string + "Shares Balance": string +} + +export function accountRow( + role: string, + key: Keypair, + usdc: string, + shares: string, +): AccountRow { + return { + Role: role, + PublicKey: key.publicKey(), + "USDC Balance": usdc, + "Shares Balance": shares, + } +} diff --git a/scripts/harness/lib/accounts.test.ts b/scripts/harness/lib/accounts.test.ts index 75d11405..55009836 100644 --- a/scripts/harness/lib/accounts.test.ts +++ b/scripts/harness/lib/accounts.test.ts @@ -20,10 +20,11 @@ const networkWith = (funding: Funding): Network => ({ freshness_duration: 300, cooldown_secs: 5, max_up_bps: 1000, - max_down_bps: null, + max_down_bps: 10_000, min_answer: "1", max_answer: "2", }, + vault: { notice_secs: 10 }, }) // Knows which accounts exist, and creates the ones a transaction creates. diff --git a/scripts/harness/lib/accounts.ts b/scripts/harness/lib/accounts.ts index d6a49be0..efc9f0b9 100644 --- a/scripts/harness/lib/accounts.ts +++ b/scripts/harness/lib/accounts.ts @@ -19,6 +19,7 @@ export const ACCOUNT_NAMES = [ "treasury", "guardian", "compliance", + "compliance-backup", "attester", "custodian", "issuer", diff --git a/scripts/harness/lib/balances.test.ts b/scripts/harness/lib/balances.test.ts new file mode 100644 index 00000000..8b76a4a9 --- /dev/null +++ b/scripts/harness/lib/balances.test.ts @@ -0,0 +1,160 @@ +import { Asset, Keypair, xdr, type rpc } from "@stellar/stellar-sdk" +import { describe, expect, it } from "vitest" + +import { + assetBalance, + classicAmount, + returnToIssuer, + trustlineBalance, +} from "./balances.js" + +const trustlineKey = (account: string, asset: Asset): xdr.LedgerKey => + xdr.LedgerKey.trustline( + new xdr.LedgerKeyTrustLine({ + accountId: Keypair.fromPublicKey(account).xdrAccountId(), + asset: asset.toTrustLineXDRObject(), + }), + ) + +const trustlineEntry = (account: string, asset: Asset, balance: bigint) => + xdr.LedgerEntryData.trustline( + new xdr.TrustLineEntry({ + accountId: Keypair.fromPublicKey(account).xdrAccountId(), + asset: asset.toTrustLineXDRObject(), + balance: xdr.Int64.fromString(balance.toString()), + limit: xdr.Int64.fromString("9223372036854775807"), + flags: 1, + ext: new xdr.TrustLineEntryExt(0), + }), + ) + +function fakeLedger(balances: Map, asset: Asset) { + const requestedKeys: string[] = [] + const getLedgerEntries = (...keys: xdr.LedgerKey[]) => { + const [key] = keys + if (key === undefined) throw new Error("no key requested") + const keyXdr = key.toXDR("base64") + requestedKeys.push(keyXdr) + for (const [account, balance] of balances) { + if (trustlineKey(account, asset).toXDR("base64") !== keyXdr) continue + return Promise.resolve({ + entries: [{ val: trustlineEntry(account, asset, balance) }], + latestLedger: 1, + }) + } + return Promise.resolve({ entries: [], latestLedger: 1 }) + } + return { getLedgerEntries, requestedKeys } as unknown as rpc.Server & { + requestedKeys: string[] + } +} + +describe("trustlineBalance", () => { + it("reads the balance from the account's trustline entry", async () => { + const asset = new Asset("USDC", Keypair.random().publicKey()) + const holder = Keypair.random() + const server = fakeLedger(new Map([[holder.publicKey(), 1234567n]]), asset) + + const balance = await trustlineBalance(server, holder.publicKey(), asset) + + expect(balance).toBe(1234567n) + expect(server.requestedKeys).toEqual([ + trustlineKey(holder.publicKey(), asset).toXDR("base64"), + ]) + }) + + it("answers undefined for an account with no trustline", async () => { + const asset = new Asset("USDC", Keypair.random().publicKey()) + const holder = Keypair.random() + const server = fakeLedger(new Map(), asset) + + expect( + await trustlineBalance(server, holder.publicKey(), asset), + ).toBeUndefined() + }) + + it("passes an RPC failure through instead of reading it as no trustline", async () => { + const asset = new Asset("USDC", Keypair.random().publicKey()) + const server = { + getLedgerEntries: () => Promise.reject(new Error("connect ECONNREFUSED")), + } as unknown as rpc.Server + + await expect( + trustlineBalance(server, Keypair.random().publicKey(), asset), + ).rejects.toThrow(/ECONNREFUSED/) + }) +}) + +describe("assetBalance", () => { + it("reads zero for an account with no trustline", async () => { + const asset = new Asset("USDC", Keypair.random().publicKey()) + const holder = Keypair.random() + const server = fakeLedger(new Map(), asset) + + expect( + await assetBalance(server, holder.publicKey(), asset, "passphrase"), + ).toBe(0n) + }) + + it("reads a contract's balance through the asset contract", async () => { + const asset = new Asset("USDC", Keypair.random().publicKey()) + const contract = "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5" + + const withBalance = { + getAssetBalance: () => + Promise.resolve({ + latestLedger: 1, + balanceEntry: { amount: "500" }, + }), + } as unknown as rpc.Server + expect(await assetBalance(withBalance, contract, asset, "passphrase")).toBe( + 500n, + ) + + const withoutBalance = { + getAssetBalance: () => Promise.resolve({ latestLedger: 1 }), + } as unknown as rpc.Server + expect( + await assetBalance(withoutBalance, contract, asset, "passphrase"), + ).toBe(0n) + }) +}) + +describe("classicAmount", () => { + it("writes stroops as a seven-decimal amount", () => { + expect(classicAmount(10_000_000n)).toBe("1.0000000") + expect(classicAmount(1n)).toBe("0.0000001") + expect(classicAmount(123_456_789_012n)).toBe("12345.6789012") + }) +}) + +describe("returnToIssuer", () => { + it("pays each balance back to the issuer and skips accounts with nothing to return", async () => { + const issuer = Keypair.random() + const asset = new Asset("USDC", issuer.publicKey()) + const a = Keypair.random() + const b = Keypair.random() + const c = Keypair.random() + const server = fakeLedger( + new Map([ + [a.publicKey(), 5n], + [c.publicKey(), 0n], + ]), + asset, + ) + const paid: Array<[Keypair, string]> = [] + + const result = await returnToIssuer( + server, + [a, b, c, issuer], + asset, + (from, amount) => { + paid.push([from, amount]) + return Promise.resolve() + }, + ) + + expect(paid).toEqual([[a, "0.0000005"]]) + expect(result).toBe(1) + }) +}) diff --git a/scripts/harness/lib/balances.ts b/scripts/harness/lib/balances.ts new file mode 100644 index 00000000..9a778733 --- /dev/null +++ b/scripts/harness/lib/balances.ts @@ -0,0 +1,68 @@ +import { + Keypair, + StrKey, + xdr, + type Asset, + type rpc, +} from "@stellar/stellar-sdk" + +const DECIMALS = 7 +const SCALE = 10n ** BigInt(DECIMALS) + +export async function trustlineBalance( + server: Pick, + account: string, + asset: Asset, +): Promise { + const key = xdr.LedgerKey.trustline( + new xdr.LedgerKeyTrustLine({ + accountId: Keypair.fromPublicKey(account).xdrAccountId(), + asset: asset.toTrustLineXDRObject(), + }), + ) + const { entries } = await server.getLedgerEntries(key) + const [entry] = entries + if (entry === undefined) return undefined + return BigInt(entry.val.trustLine().balance().toString()) +} + +export async function assetBalance( + server: Pick, + address: string, + asset: Asset, + passphrase: string, +): Promise { + if (StrKey.isValidEd25519PublicKey(address)) { + return (await trustlineBalance(server, address, asset)) ?? 0n + } + const { balanceEntry } = await server.getAssetBalance( + address, + asset, + passphrase, + ) + return BigInt(balanceEntry?.amount ?? "0") +} + +export function classicAmount(stroops: bigint): string { + const whole = stroops / SCALE + const fraction = stroops % SCALE + return `${whole}.${fraction.toString().padStart(DECIMALS, "0")}` +} + +export async function returnToIssuer( + server: Pick, + accounts: readonly Keypair[], + asset: Asset, + payBack: (from: Keypair, amount: string) => Promise, +): Promise { + const issuer = asset.getIssuer() + let paid = 0 + for (const account of accounts) { + if (account.publicKey() === issuer) continue + const balance = await trustlineBalance(server, account.publicKey(), asset) + if (balance === undefined || balance === 0n) continue + await payBack(account, classicAmount(balance)) + paid += 1 + } + return paid +} diff --git a/scripts/harness/lib/cli.test.ts b/scripts/harness/lib/cli.test.ts index 068a78c0..a7c87ad7 100644 --- a/scripts/harness/lib/cli.test.ts +++ b/scripts/harness/lib/cli.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs" +import { join } from "node:path" + import { Address, Asset, @@ -8,7 +11,8 @@ import { } from "@stellar/stellar-sdk" import { describe, expect, it } from "vitest" -import { ensureAssetContract } from "./cli.js" +import { ensureAssetContract, pinClientSdk, rootSdkVersion } from "./cli.js" +import { REPO_ROOT } from "./deployment.js" import { type Network } from "./network.js" describe("ensureAssetContract", () => { @@ -32,3 +36,42 @@ describe("ensureAssetContract", () => { expect(await ensureAssetContract(net, server, asset, issuer)).toBe(id) }) }) + +describe("pinClientSdk", () => { + it("pins a client's stellar-sdk dependency to the given version", () => { + const pkg = { + name: "example", + dependencies: { "@stellar/stellar-sdk": "^14.5.0", buffer: "6.0.3" }, + } + + expect(pinClientSdk(pkg, "16.4.1")).toEqual({ + name: "example", + dependencies: { "@stellar/stellar-sdk": "16.4.1", buffer: "6.0.3" }, + }) + }) + + it("leaves a package without the dependency unchanged", () => { + const pkg = { name: "example", dependencies: { buffer: "6.0.3" } } + + expect(pinClientSdk(pkg, "16.4.1")).toBe(pkg) + }) + + it("pins to the version the root package.json overrides", () => { + const version = rootSdkVersion() + const pkg = { dependencies: { "@stellar/stellar-sdk": "^14.5.0" } } + + expect(pinClientSdk(pkg, version)).toEqual({ + dependencies: { "@stellar/stellar-sdk": version }, + }) + }) +}) + +describe("rootSdkVersion", () => { + it("reads the stellar-sdk override from the root package.json", () => { + const rootPkg = JSON.parse( + readFileSync(join(REPO_ROOT, "package.json"), "utf8"), + ) as { overrides?: Record } + + expect(rootSdkVersion()).toBe(rootPkg.overrides?.["@stellar/stellar-sdk"]) + }) +}) diff --git a/scripts/harness/lib/cli.ts b/scripts/harness/lib/cli.ts index 1e4dbd69..227b631e 100644 --- a/scripts/harness/lib/cli.ts +++ b/scripts/harness/lib/cli.ts @@ -12,6 +12,7 @@ import { type rpc, } from "@stellar/stellar-sdk" +import { REPO_ROOT } from "./deployment.js" import { type Network } from "./network.js" const run = promisify(execFile) @@ -42,14 +43,17 @@ async function exec( } } -async function stellar( +export async function stellar( args: readonly string[], options: Record = {}, ): Promise { return exec("stellar", args, options) } -function rpcArgs(net: Network): string[] { +/** The two settings every network command needs. */ +export type Rpc = Pick + +export function rpcArgs(net: Rpc): string[] { return ["--rpc-url", net.rpcUrl, "--network-passphrase", net.passphrase] } @@ -130,9 +134,39 @@ export async function ensureAssetContract( return deployAssetContract(net, asset, source) } +export type ClientPackageJson = Record & { + dependencies?: Record +} + +/** Pins a generated client's stellar-sdk dependency, leaving other packages untouched. */ +export function pinClientSdk( + pkg: T, + version: string, +): T { + if (pkg.dependencies?.["@stellar/stellar-sdk"] === undefined) return pkg + return { + ...pkg, + dependencies: { ...pkg.dependencies, "@stellar/stellar-sdk": version }, + } +} + +export function rootSdkVersion(): string { + const rootPkg = JSON.parse( + readFileSync(join(REPO_ROOT, "package.json"), "utf8"), + ) as { overrides?: Record } + const version = rootPkg.overrides?.["@stellar/stellar-sdk"] + if (version === undefined) { + throw new Error( + 'root package.json is missing overrides["@stellar/stellar-sdk"]', + ) + } + return version +} + export async function generateClient( wasm: string, outDir: string, + sdkVersion: string, ): Promise { await stellar([ "contract", @@ -146,12 +180,10 @@ export async function generateClient( ]) const pkgPath = join(outDir, "package.json") if (existsSync(pkgPath)) { - const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { - dependencies?: Record - } - if (pkg.dependencies?.["@stellar/stellar-sdk"]) { - pkg.dependencies["@stellar/stellar-sdk"] = "16.1.0" - writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n") + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as ClientPackageJson + const pinned = pinClientSdk(pkg, sdkVersion) + if (pinned !== pkg) { + writeFileSync(pkgPath, JSON.stringify(pinned, null, 2) + "\n") } } // TypeScript 6 rejects the CLI-generated tsconfig without an explicit rootDir. diff --git a/scripts/harness/lib/clients.ts b/scripts/harness/lib/clients.ts index 203db0b0..b8f0bc53 100644 --- a/scripts/harness/lib/clients.ts +++ b/scripts/harness/lib/clients.ts @@ -1,22 +1,28 @@ -import { Asset, Keypair, rpc } from "@stellar/stellar-sdk" +import { Asset, rpc, type Keypair } from "@stellar/stellar-sdk" import { basicNodeSigner } from "@stellar/stellar-sdk/contract" import { Client as VaultClient } from "../../../app-lib/clients/async_vault/dist/index.js" +import { Client as ComplianceClient } from "../../../app-lib/clients/compliance/dist/index.js" import { Client as IdentityVerifierClient } from "../../../app-lib/clients/identity_verifier/dist/index.js" import { Client as OracleClient } from "../../../app-lib/clients/nav_oracle/dist/index.js" import { Client as ShareTokenClient } from "../../../app-lib/clients/share_token/dist/index.js" -import { ACCOUNT_NAMES, type AccountName } from "./accounts.js" +import { type Accounts } from "./accounts.js" +import { assetBalance } from "./balances.js" import { loadDeployment, + publicKeysOf, + REPO_ROOT, type ContractName, type Deployment, } from "./deployment.js" +import { keysFor } from "./keys.js" import { network, type Network } from "./network.js" +import { withAccountRetry } from "./rpc.js" export interface Connected { net: Network deployment: Deployment - keys: Record + keys: Accounts server: rpc.Server asset: Asset } @@ -24,17 +30,23 @@ export interface Connected { export function connect(): Connected { const net = network() const deployment = loadDeployment(net.name) - const keys = Object.fromEntries( - ACCOUNT_NAMES.map((name) => [ - name, - Keypair.fromSecret(deployment.accounts[name].secret), - ]), - ) as Record + const keys = keysFor(deployment, REPO_ROOT) + // An older record also holds secret keys; keep only the public ones in memory. + return connectTo(net, { ...deployment, accounts: publicKeysOf(keys) }, keys) +} + +export function connectTo( + net: Network, + deployment: Deployment, + keys: Accounts, +): Connected { return { net, deployment, keys, - server: new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }), + server: withAccountRetry( + new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }), + ), asset: new Asset(deployment.asset.code, deployment.asset.issuer), } } @@ -46,6 +58,7 @@ function options(c: Connected, contract: ContractName, as: Keypair) { networkPassphrase: c.net.passphrase, allowHttp: c.net.name === "local", publicKey: as.publicKey(), + server: c.server, ...basicNodeSigner(as, c.net.passphrase), } } @@ -58,6 +71,8 @@ export const shareToken = (c: Connected, as: Keypair) => new ShareTokenClient(options(c, "share_token", as)) export const identityVerifier = (c: Connected, as: Keypair) => new IdentityVerifierClient(options(c, "identity_verifier", as)) +export const complianceContract = (c: Connected, as: Keypair) => + new ComplianceClient(options(c, "compliance", as)) interface Sendable { simulation?: unknown @@ -73,23 +88,6 @@ export async function send(tx: Promise>): Promise { return (await awaited.signAndSend()).result } -export async function usdcBalance( - c: Connected, - address: string, -): Promise { - try { - const { balanceEntry } = await c.server.getAssetBalance( - address, - c.asset, - c.net.passphrase, - ) - return BigInt(balanceEntry?.amount ?? "0") - } catch (error) { - if ( - error instanceof Error && - /Trustline for .* not found for/.test(error.message) - ) - return 0n - throw error - } +export function usdcBalance(c: Connected, address: string): Promise { + return assetBalance(c.server, address, c.asset, c.net.passphrase) } diff --git a/scripts/harness/lib/deployment.test.ts b/scripts/harness/lib/deployment.test.ts index a42cdcba..63a97e40 100644 --- a/scripts/harness/lib/deployment.test.ts +++ b/scripts/harness/lib/deployment.test.ts @@ -9,12 +9,20 @@ import { import { tmpdir } from "node:os" import { join } from "node:path" +import { Keypair } from "@stellar/stellar-sdk" import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { ACCOUNT_NAMES, type Accounts } from "./accounts.js" import { + clearIncomplete, deploymentFile, + incompleteFile, + publicKeysOf, + refuseIncomplete, saveDeployment, + writeIncomplete, type Deployment, + type IncompleteDeployment, } from "./deployment.js" let dir = "" @@ -30,6 +38,8 @@ afterEach(() => { const record = (deployedAt: string): Deployment => ({ network: "local", deployedAt, + commit: "0123456789abcdef0123456789abcdef01234567", + uncommittedChanges: false, ledger: 1, asset: { code: "USDC", issuer: "G", contract: "C" }, contracts: { @@ -39,6 +49,13 @@ const record = (deployedAt: string): Deployment => ({ nav_oracle: "C4", async_vault: "C5", }, + wasmHashes: { + compliance: "H1", + identity_verifier: "H2", + share_token: "H3", + nav_oracle: "H4", + async_vault: "H5", + }, accounts: {} as Deployment["accounts"], }) @@ -100,3 +117,64 @@ describe("saveDeployment", () => { }, ) }) + +const progress = (): IncompleteDeployment => ({ + network: "local", + startedAt: "2026-10-28T12:00:00.000Z", + complete: false, + asset: { code: "USDC", issuer: "G", contract: "C" }, + contracts: { compliance: "C1", identity_verifier: "C2" }, +}) + +describe("writeIncomplete", () => { + it("keeps the contracts of an unfinished deploy in their own file", () => { + writeIncomplete(progress(), dir) + + expect( + JSON.parse(readFileSync(incompleteFile("local", dir), "utf8")), + ).toEqual(progress()) + expect(readdirSync(dir)).toEqual(["deployed.local.incomplete.json"]) + }) +}) + +describe("refuseIncomplete", () => { + it("refuses a deploy while an unfinished deploy's file is there", () => { + writeIncomplete(progress(), dir) + + expect(() => refuseIncomplete("local", dir)).toThrow( + /deployed\.local\.incomplete\.json/, + ) + expect(() => refuseIncomplete("local", dir)).toThrow( + "Move or delete that file", + ) + }) + + it("lets a deploy run once the file is gone", () => { + writeIncomplete(progress(), dir) + + clearIncomplete("local", dir) + + expect(() => refuseIncomplete("local", dir)).not.toThrow() + expect(() => clearIncomplete("local", dir)).not.toThrow() + }) +}) + +describe("publicKeysOf", () => { + it("records each account's public key and no secret key", () => { + const accounts = Object.fromEntries( + ACCOUNT_NAMES.map((name) => [name, Keypair.random()]), + ) as Accounts + saveDeployment( + { + ...record("2026-10-01T12:30:45.678Z"), + accounts: publicKeysOf(accounts), + }, + dir, + ) + const text = readFileSync(deploymentFile("local", dir), "utf8") + for (const name of ACCOUNT_NAMES) { + expect(text).toContain(accounts[name].publicKey()) + expect(text).not.toContain(accounts[name].secret()) + } + }) +}) diff --git a/scripts/harness/lib/deployment.ts b/scripts/harness/lib/deployment.ts index 08e8f29d..d1abd9cf 100644 --- a/scripts/harness/lib/deployment.ts +++ b/scripts/harness/lib/deployment.ts @@ -2,13 +2,14 @@ import { existsSync, readFileSync, renameSync, + rmSync, statSync, writeFileSync, } from "node:fs" -import { dirname, join, resolve } from "node:path" +import { dirname, join, relative, resolve } from "node:path" import { fileURLToPath } from "node:url" -import { type AccountName } from "./accounts.js" +import { ACCOUNT_NAMES, type AccountName, type Accounts } from "./accounts.js" import { type NetworkName } from "./network.js" export const CONTRACT_NAMES = [ @@ -24,10 +25,19 @@ export type ContractName = (typeof CONTRACT_NAMES)[number] export interface Deployment { network: NetworkName deployedAt: string + commit: string + uncommittedChanges: boolean ledger: number asset: { code: string; issuer: string; contract: string } contracts: Record - accounts: Record + wasmHashes: Record + accounts: Record +} + +export function publicKeysOf(accounts: Accounts): Deployment["accounts"] { + return Object.fromEntries( + ACCOUNT_NAMES.map((name) => [name, { public: accounts[name].publicKey() }]), + ) as Deployment["accounts"] } export const PACKAGE_ROOT = resolve( @@ -157,3 +167,47 @@ export function saveDeployment( export function loadDeployment(network: NetworkName): Deployment { return JSON.parse(readFileSync(deploymentFile(network), "utf8")) as Deployment } + +export interface IncompleteDeployment { + network: NetworkName + startedAt: string + complete: false + asset: Deployment["asset"] + contracts: Partial> +} + +export function incompleteFile( + network: NetworkName, + dir = PACKAGE_ROOT, +): string { + return join(dir, `deployed.${network}.incomplete.json`) +} + +export function writeIncomplete( + progress: IncompleteDeployment, + dir = PACKAGE_ROOT, +): void { + writeFileSync( + incompleteFile(progress.network, dir), + `${JSON.stringify(progress, null, 2)}\n`, + ) +} + +export function clearIncomplete( + network: NetworkName, + dir = PACKAGE_ROOT, +): void { + const file = incompleteFile(network, dir) + if (existsSync(file)) rmSync(file) +} + +export function refuseIncomplete( + network: NetworkName, + dir = PACKAGE_ROOT, +): void { + const file = incompleteFile(network, dir) + if (!existsSync(file)) return + throw new Error( + `a deploy to ${network} stopped before it finished. The contracts it created are listed in ${relative(REPO_ROOT, file)}, and nothing else uses them. Move or delete that file, then deploy again for a new set.`, + ) +} diff --git a/scripts/harness/lib/extend-target.test.ts b/scripts/harness/lib/extend-target.test.ts new file mode 100644 index 00000000..a01e043e --- /dev/null +++ b/scripts/harness/lib/extend-target.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest" + +import { describeTarget, resolveTarget } from "./extend-target.js" +import { networkNamed } from "./network.js" + +const TOML = ` +[staging.network] +rpc-url = "https://rpc.example" +network-passphrase = "Test Passphrase" + +[staging.contracts] +async_vault = { id = "CAAA" } +` + +const RECORD_PATH = "/w/deployed.local.json" +const ENV = { INIT_CWD: "/w" } + +function readFileFor(record: unknown): (path: string) => string { + return (path) => { + if (path.endsWith("environments.toml")) return TOML + if (path === RECORD_PATH) return JSON.stringify(record) + throw new Error(`unexpected read ${path}`) + } +} + +describe("resolveTarget", () => { + const localRecord = { + network: "local", + contracts: { async_vault: "CV" }, + asset: { contract: "CS" }, + } + + it("uses the record's network for --config", () => { + const target = resolveTarget( + ["--config", "deployed.local.json"], + ENV, + readFileFor(localRecord), + ) + + const local = networkNamed("local") + expect(target.rpc.rpcUrl).toBe(local.rpcUrl) + expect(target.rpc.passphrase).toBe(local.passphrase) + expect(target.network).toBe("local") + expect(target.contracts).toEqual([ + ["async_vault", "CV"], + ["asset", "CS"], + ]) + }) + + it("lets --env override the record's network", () => { + const target = resolveTarget( + ["--config", "deployed.local.json", "--env", "staging"], + ENV, + readFileFor(localRecord), + ) + + expect(target.rpc.rpcUrl).toBe("https://rpc.example") + expect(target.contracts).toEqual([ + ["async_vault", "CV"], + ["asset", "CS"], + ]) + expect(target.source).toBe("--env, overriding the record's local") + }) + + it("ignores ADDRESSES_ENV when the record names its network", () => { + const target = resolveTarget( + ["--config", "deployed.local.json"], + { ...ENV, ADDRESSES_ENV: "staging" }, + readFileFor(localRecord), + ) + + expect(target.network).toBe("local") + }) + + it("reads environments.toml without --config", () => { + const target = resolveTarget([], ENV, readFileFor(undefined)) + + expect(target.network).toBe("staging") + expect(target.source).toBe("environments.toml") + expect(target.contracts).toEqual([["async_vault", "CAAA"]]) + }) + + it.each([ + { contracts: { async_vault: "CV" } }, + { network: "mainnet", contracts: { async_vault: "CV" } }, + ])("refuses a record with no known network", (record) => { + expect(() => + resolveTarget( + ["--config", "deployed.local.json"], + ENV, + readFileFor(record), + ), + ).toThrow(/no network of local or testnet/) + }) + + it("refuses a record that lists no contracts", () => { + const record = { network: "local", contracts: {} } + + expect(() => + resolveTarget( + ["--config", "deployed.local.json"], + ENV, + readFileFor(record), + ), + ).toThrow(/lists no contracts/) + }) + + it("refuses contracts that are not an object", () => { + const record = { network: "local", contracts: "oops" } + + expect(() => + resolveTarget( + ["--config", "deployed.local.json"], + ENV, + readFileFor(record), + ), + ).toThrow(/lists no contracts/) + }) + + it("refuses contracts that are not an object, even with a valid asset", () => { + const record = { + network: "local", + contracts: "garbage", + asset: { contract: "CS" }, + } + + expect(() => + resolveTarget( + ["--config", "deployed.local.json"], + ENV, + readFileFor(record), + ), + ).toThrow(/lists no contracts/) + }) + + it("refuses a contract id that is not a string", () => { + const record = { network: "local", contracts: { async_vault: 5 } } + + expect(() => + resolveTarget( + ["--config", "deployed.local.json"], + ENV, + readFileFor(record), + ), + ).toThrow(/contract id that is not a string: async_vault/) + }) + + it.each([null, {}, { contract: 5 }])( + "refuses an asset with no string contract", + (asset) => { + const record = { + network: "local", + contracts: { async_vault: "CV" }, + asset, + } + + expect(() => + resolveTarget( + ["--config", "deployed.local.json"], + ENV, + readFileFor(record), + ), + ).toThrow(/contract id that is not a string: asset/) + }, + ) + + it.each(["--config", "--env"] as const)( + "refuses a flag with no value: %s", + (flag) => { + expect(() => resolveTarget([flag], ENV, readFileFor(undefined))).toThrow( + /needs a value/, + ) + }, + ) + + it("refuses --config followed by another flag as its value", () => { + expect(() => + resolveTarget( + ["--config", "--env", "testnet"], + ENV, + readFileFor(undefined), + ), + ).toThrow(/--config needs a value/) + }) + + it.each([["rec.json"], ["--config=rec.json"], ["--confg", "x"]])( + "refuses an argument npm let through as-is: %j", + (...argv) => { + expect(() => resolveTarget(argv, ENV, readFileFor(undefined))).toThrow( + /npm run extend -w scripts\/harness -- --config /, + ) + }, + ) + + it("names the file when its JSON is malformed", () => { + expect(() => + resolveTarget( + ["--config", "deployed.local.json"], + ENV, + () => "{ not json", + ), + ).toThrow(/deployed\.local\.json/) + }) + + it("names the file when its JSON is null", () => { + expect(() => + resolveTarget(["--config", "deployed.local.json"], ENV, () => "null"), + ).toThrow(/deployed\.local\.json/) + }) + + it("describes the network and RPC", () => { + const target = resolveTarget( + ["--config", "deployed.local.json"], + ENV, + readFileFor(localRecord), + ) + + expect(describeTarget(target)).toBe( + `using network local (record deployed.local.json), RPC ${networkNamed("local").rpcUrl}`, + ) + }) +}) diff --git a/scripts/harness/lib/extend-target.ts b/scripts/harness/lib/extend-target.ts new file mode 100644 index 00000000..3ea4919c --- /dev/null +++ b/scripts/harness/lib/extend-target.ts @@ -0,0 +1,143 @@ +import { readFileSync } from "node:fs" +import { join, resolve } from "node:path" + +import { + contractIds, + networkOf, + selectedEnvironment, +} from "../../lib/environments.mjs" + +import { type Rpc } from "./cli.js" +import { REPO_ROOT } from "./deployment.js" +import { networkNamed } from "./network.js" + +export interface ExtendTarget { + network: string + source: string + rpc: Rpc + contracts: Array<[string, string]> +} + +interface DeploymentRecord { + network?: unknown + contracts?: unknown + asset?: unknown +} + +const USAGE = "npm run extend -w scripts/harness -- --config " + +interface ParsedFlags { + config?: string + env?: string +} + +/** + * Without the `--` separator, npm swallows `--config`/`--env` on its own, + * silently handing the script whatever is left over. Accept only the two + * known flags, each immediately followed by its value. + */ +function parseArgv(argv: readonly string[]): ParsedFlags { + const flags: ParsedFlags = {} + for (let at = 0; at < argv.length; at += 2) { + const flag = argv[at] + if (flag !== "--config" && flag !== "--env") + throw new Error(`unexpected argument "${flag}"; use ${USAGE}`) + const value = argv[at + 1] + if (value === undefined || value.startsWith("--")) + throw new Error(`${flag} needs a value; use ${USAGE}`) + if (flag === "--config") flags.config = value + else flags.env = value + } + return flags +} + +function assetContract(asset: unknown): string | undefined { + if (typeof asset !== "object" || asset === null) return undefined + const contract = (asset as Record)["contract"] + return typeof contract === "string" ? contract : undefined +} + +function recordContracts( + record: DeploymentRecord, + source: string, +): Array<[string, string]> { + const raw = record.contracts + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) + throw new Error(`${source} lists no contracts`) + + const contracts: Array<[string, string]> = [] + for (const [name, id] of Object.entries(raw)) { + if (typeof id !== "string") + throw new Error( + `${source} has a contract id that is not a string: ${name}`, + ) + contracts.push([name, id]) + } + if (record.asset !== undefined) { + const contract = assetContract(record.asset) + if (contract === undefined) + throw new Error(`${source} has a contract id that is not a string: asset`) + contracts.push(["asset", contract]) + } + if (contracts.length === 0) throw new Error(`${source} lists no contracts`) + return contracts +} + +export function resolveTarget( + argv: readonly string[], + env: Record, + readFile: (path: string) => string = (p) => readFileSync(p, "utf8"), +): ExtendTarget { + const { config, env: envFlag } = parseArgv(argv) + const toml = () => readFile(join(REPO_ROOT, "environments.toml")) + + if (config === undefined) { + const name = envFlag ?? selectedEnvironment(env) + const { rpcUrl, networkPassphrase } = networkOf(toml(), name) + return { + network: name, + source: "environments.toml", + rpc: { rpcUrl, passphrase: networkPassphrase }, + contracts: contractIds(toml(), name).map( + ({ name: contract, id }) => [contract, id] as [string, string], + ), + } + } + + // npm runs workspace scripts from the workspace; resolve from where it was called. + const path = resolve(env["INIT_CWD"] ?? process.cwd(), config) + let parsed: unknown + try { + parsed = JSON.parse(readFile(path)) + } catch (cause) { + throw new Error(`${config} is not valid JSON (${String(cause)})`) + } + if (typeof parsed !== "object" || parsed === null) + throw new Error(`${config} is not a JSON object`) + const record = parsed as DeploymentRecord + if (record.network !== "local" && record.network !== "testnet") + throw new Error(`${config} has no network of local or testnet`) + const contracts = recordContracts(record, config) + + if (envFlag === undefined) { + const { rpcUrl, passphrase } = networkNamed(record.network) + return { + network: record.network, + source: `record ${config}`, + rpc: { rpcUrl, passphrase }, + contracts, + } + } + + const { rpcUrl, networkPassphrase } = networkOf(toml(), envFlag) + return { + network: envFlag, + source: `--env, overriding the record's ${record.network}`, + rpc: { rpcUrl, passphrase: networkPassphrase }, + contracts, + } +} + +export function describeTarget(target: ExtendTarget): string { + return `using network ${target.network} (${target.source}), RPC ${target.rpc.rpcUrl}` +} diff --git a/scripts/harness/lib/fake-ledger.ts b/scripts/harness/lib/fake-ledger.ts new file mode 100644 index 00000000..1df1a622 --- /dev/null +++ b/scripts/harness/lib/fake-ledger.ts @@ -0,0 +1,182 @@ +import { Address, Networks, StrKey, xdr } from "@stellar/stellar-sdk" + +import { type Rpc } from "./cli.js" +import { type Entry, type Runner } from "./ttl.js" + +export const RPC: Rpc = { + rpcUrl: "http://localhost:8000/rpc", + passphrase: Networks.STANDALONE, +} + +export const CONTRACT_A = StrKey.encodeContract(Buffer.alloc(32, 1)) +export const CONTRACT_B = StrKey.encodeContract(Buffer.alloc(32, 2)) +export const CONTRACT_C = StrKey.encodeContract(Buffer.alloc(32, 3)) + +export const WASM_HASH = Buffer.alloc(32, 0x11).toString("hex") + +function contractDataXdr( + contractId: string, + key: xdr.ScVal, + val: xdr.ScVal, +): string { + const entry = new xdr.ContractDataEntry({ + ext: new xdr.ExtensionPoint(0), + contract: new Address(contractId).toScAddress(), + key, + durability: xdr.ContractDataDurability.persistent(), + val, + }) + return xdr.LedgerEntryData.contractData(entry).toXDR("base64") +} + +/** A base64 `LedgerEntryData` for a contract instance, Wasm or a Stellar asset. */ +export function instanceData( + contractId: string, + opts: { wasmHash?: string; storage?: xdr.ScMapEntry[] }, +): string { + const executable = + opts.wasmHash !== undefined + ? xdr.ContractExecutable.contractExecutableWasm( + Buffer.from(opts.wasmHash, "hex"), + ) + : xdr.ContractExecutable.contractExecutableStellarAsset() + const instance = new xdr.ScContractInstance({ + executable, + storage: opts.storage ?? null, + }) + return contractDataXdr( + contractId, + xdr.ScVal.scvLedgerKeyContractInstance(), + xdr.ScVal.scvContractInstance(instance), + ) +} + +/** A base64 `LedgerEntryData` for a contract's Wasm. */ +export function codeData(wasmHash: string): string { + const entry = new xdr.ContractCodeEntry({ + ext: new xdr.ContractCodeEntryExt(0), + hash: Buffer.from(wasmHash, "hex"), + code: Buffer.alloc(0), + }) + return xdr.LedgerEntryData.contractCode(entry).toXDR("base64") +} + +/** A base64 `LedgerEntryData` for a pending timelock change, under its key. */ +export function pendingData(contractId: string, keyXdr: string): string { + return contractDataXdr( + contractId, + xdr.ScVal.fromXDR(keyXdr, "base64"), + xdr.ScVal.scvVoid(), + ) +} + +/** The RPC `getLedgerEntries` JSON shape the CLI's `--output xdr` prints. */ +export function fetchOutput( + latestLedger: number, + entries: Array<{ xdr: string; liveUntilLedgerSeq: number }>, +): string { + return JSON.stringify({ + entries: entries.map((entry) => ({ + key: "", + xdr: entry.xdr, + lastModifiedLedgerSeq: latestLedger, + liveUntilLedgerSeq: entry.liveUntilLedgerSeq, + })), + latestLedger, + }) +} + +export function entryKey(entry: Entry): string { + if ("wasmHash" in entry) return `code:${entry.wasmHash}` + if ("keyXdr" in entry) return `data:${entry.id}:${entry.keyXdr}` + return `instance:${entry.id}` +} + +function flagValue(args: string[], flag: string): string | undefined { + const at = args.indexOf(flag) + return at === -1 ? undefined : args[at + 1] +} + +function entryFromArgs(args: string[]): Entry { + const wasmHash = flagValue(args, "--wasm-hash") + if (wasmHash !== undefined) return { wasmHash } + const id = flagValue(args, "--contract") ?? flagValue(args, "--id") + if (id === undefined) + throw new Error(`unexpected stellar call: ${args.join(" ")}`) + const keyXdr = flagValue(args, "--key-xdr") + return keyXdr !== undefined ? { id, keyXdr } : { id } +} + +interface LedgerRecord { + data: string + liveUntil: number + whenArchived: "absent" | "expired" +} + +/** An in-memory ledger a `Runner` reads and writes, standing in for the CLI. */ +export class FakeLedger { + calls: string[][] = [] + restoreDoesNothing = false + run: Runner = (args) => this.handle([...args]) + + private readonly entries = new Map() + + constructor(private readonly latest = 1000) {} + + put( + key: string, + data: string, + liveUntil: number, + whenArchived: "absent" | "expired" = "expired", + ): void { + this.entries.set(key, { data, liveUntil, whenArchived }) + } + + private handle(args: string[]): Promise { + this.calls.push(args) + const [group, action] = args + if (group === "ledger" && action === "latest") + return Promise.resolve(JSON.stringify({ sequence: this.latest })) + if (group === "ledger" && action === "entry") + return Promise.resolve(this.fetch(args)) + if (group === "contract" && action === "restore") + return Promise.resolve(this.restore(args)) + if (group === "contract" && action === "extend") + return Promise.resolve(this.extend(args)) + throw new Error(`unexpected stellar call: ${args.join(" ")}`) + } + + private fetch(args: string[]): string { + const record = this.entries.get(entryKey(entryFromArgs(args))) + if (record === undefined) return fetchOutput(this.latest, []) + if (record.liveUntil < this.latest) { + return record.whenArchived === "absent" + ? fetchOutput(this.latest, []) + : fetchOutput(this.latest, [ + { xdr: record.data, liveUntilLedgerSeq: 0 }, + ]) + } + return fetchOutput(this.latest, [ + { xdr: record.data, liveUntilLedgerSeq: record.liveUntil }, + ]) + } + + private restore(args: string[]): string { + const record = this.entries.get(entryKey(entryFromArgs(args))) + if (record === undefined) { + throw new Error( + "stellar failed (exit 1):\n❌ error: transaction simulation failed: Missing entry to restore", + ) + } + if (!this.restoreDoesNothing) record.liveUntil = this.latest + 20 + return "" + } + + private extend(args: string[]): string { + const record = this.entries.get(entryKey(entryFromArgs(args))) + if (record === undefined || record.liveUntil < this.latest) return "0" + const ledgers = Number(flagValue(args, "--ledgers-to-extend")) + record.liveUntil = Math.max(record.liveUntil, this.latest + ledgers) + return String(record.liveUntil) + } +} diff --git a/scripts/harness/lib/keep-alive.test.ts b/scripts/harness/lib/keep-alive.test.ts new file mode 100644 index 00000000..2b02c550 --- /dev/null +++ b/scripts/harness/lib/keep-alive.test.ts @@ -0,0 +1,279 @@ +import { xdr } from "@stellar/stellar-sdk" +import { describe, expect, it } from "vitest" + +import { + CONTRACT_A, + CONTRACT_B, + CONTRACT_C, + FakeLedger, + RPC, + WASM_HASH, + codeData, + entryKey, + fetchOutput, + instanceData, + pendingData, +} from "./fake-ledger.js" +import { keepAlive, keepDeploymentAlive, type Log } from "./keep-alive.js" +import { openChangesKey, pendingChangeKey } from "./timelock-keys.js" +import { type Runner, wasmHashOf } from "./ttl.js" + +const steps = (ledger: FakeLedger) => + ledger.calls.map((args) => args.slice(0, 2).join(" ")) + +const open = (...ids: number[]): xdr.ScMapEntry => + new xdr.ScMapEntry({ + key: openChangesKey(), + val: xdr.ScVal.scvVec(ids.map((id) => xdr.ScVal.scvU32(id))), + }) + +describe("keepAlive", () => { + it("extends a live entry without restoring it", async () => { + const ledger = new FakeLedger() + const lines: string[] = [] + ledger.put( + entryKey({ id: CONTRACT_A }), + instanceData(CONTRACT_A, { wasmHash: WASM_HASH }), + 2000, + ) + + await keepAlive( + RPC, + { id: CONTRACT_A }, + "vault instance", + ledger.run, + (l) => lines.push(l), + ) + + expect(steps(ledger)).toEqual(["ledger entry", "contract extend"]) + expect(lines).toEqual(["vault instance live until ledger 519400"]) + }) + + it.each(["absent", "expired"] as const)( + "restores an archived entry the RPC answers as %s, then extends it", + async (whenArchived) => { + const ledger = new FakeLedger() + const lines: string[] = [] + ledger.put( + entryKey({ id: CONTRACT_A }), + instanceData(CONTRACT_A, { wasmHash: WASM_HASH }), + 900, + whenArchived, + ) + + const instance = await keepAlive( + RPC, + { id: CONTRACT_A }, + "vault instance", + ledger.run, + (l) => lines.push(l), + ) + + expect(steps(ledger)).toEqual([ + "ledger entry", + "contract restore", + "ledger entry", + "contract extend", + ]) + expect(lines).toEqual([ + "vault instance restored", + "vault instance live until ledger 519400", + ]) + expect(wasmHashOf(instance!)).toBe(WASM_HASH) + }, + ) + + it("stops when the RPC has no entry, live or archived", async () => { + const ledger = new FakeLedger() + const lines: string[] = [] + + let error: unknown + try { + await keepAlive( + RPC, + { id: CONTRACT_A }, + "vault instance", + ledger.run, + (l) => lines.push(l), + ) + } catch (caught) { + error = caught + } + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch( + /no entry for it, live or archived/, + ) + expect((error as Error).message).toContain(RPC.rpcUrl) + }) + + it("stops when an entry is still archived after its restore", async () => { + const ledger = new FakeLedger() + const lines: string[] = [] + ledger.put( + entryKey({ id: CONTRACT_A }), + instanceData(CONTRACT_A, { wasmHash: WASM_HASH }), + 900, + ) + ledger.restoreDoesNothing = true + + await expect( + keepAlive(RPC, { id: CONTRACT_A }, "vault instance", ledger.run, (l) => + lines.push(l), + ), + ).rejects.toThrow(/still archived after the restore/) + }) + + /** A runner whose `contract extend` answer is fixed, independent of the fetch. */ + function scriptedExtend(latest: number, extendsTo: string): Runner { + return (args) => { + if (args[0] === "contract" && args[1] === "extend") + return Promise.resolve(extendsTo) + return Promise.resolve( + fetchOutput(latest, [ + { + xdr: instanceData(CONTRACT_A, { wasmHash: WASM_HASH }), + liveUntilLedgerSeq: latest + 1000, + }, + ]), + ) + } + } + + it("refuses a live-until behind the latest ledger plus the extend length", async () => { + await expect( + keepAlive( + RPC, + { id: CONTRACT_A }, + "vault instance", + scriptedExtend(300, "299"), + ), + ).rejects.toThrow(/vault instance.*299.*300/s) + }) + + it("refuses a live-until ledger of 0 from a lagging extend", async () => { + await expect( + keepAlive( + RPC, + { id: CONTRACT_A }, + "vault instance", + scriptedExtend(300, "0"), + ), + ).rejects.toThrow(/vault instance.*0.*300/s) + }) + + it("accepts a normal extend to latest plus the extend length", async () => { + await expect( + keepAlive( + RPC, + { id: CONTRACT_A }, + "vault instance", + scriptedExtend(300, String(300 + 518_400)), + ), + ).resolves.toBeDefined() + }) + + it("accepts an entry already living longer than the target", async () => { + await expect( + keepAlive( + RPC, + { id: CONTRACT_A }, + "vault instance", + scriptedExtend(300, String(300 + 518_400 + 1_000_000)), + ), + ).resolves.toBeDefined() + }) +}) + +describe("keepDeploymentAlive", () => { + it("keeps alive each instance, the Wasm it runs once per hash, and each open change", async () => { + const ledger = new FakeLedger() + const lines: string[] = [] + const log: Log = (l) => lines.push(l) + + ledger.put( + entryKey({ id: CONTRACT_A }), + instanceData(CONTRACT_A, { wasmHash: WASM_HASH, storage: [open(1, 2)] }), + 2000, + ) + ledger.put( + entryKey({ id: CONTRACT_B }), + instanceData(CONTRACT_B, { wasmHash: WASM_HASH }), + 2000, + ) + ledger.put(entryKey({ id: CONTRACT_C }), instanceData(CONTRACT_C, {}), 2000) + ledger.put(entryKey({ wasmHash: WASM_HASH }), codeData(WASM_HASH), 2000) + ledger.put( + entryKey({ + id: CONTRACT_A, + keyXdr: pendingChangeKey(1).toXDR("base64"), + }), + pendingData(CONTRACT_A, pendingChangeKey(1).toXDR("base64")), + 2000, + ) + ledger.put( + entryKey({ + id: CONTRACT_A, + keyXdr: pendingChangeKey(2).toXDR("base64"), + }), + pendingData(CONTRACT_A, pendingChangeKey(2).toXDR("base64")), + 900, + "absent", + ) + + await keepDeploymentAlive( + RPC, + [ + ["vault", CONTRACT_A], + ["oracle", CONTRACT_B], + ["asset", CONTRACT_C], + ], + ledger.run, + log, + ) + + expect(lines).toEqual([ + "vault instance live until ledger 519400", + "oracle instance live until ledger 519400", + "asset instance live until ledger 519400", + `code ${WASM_HASH.slice(0, 8)} live until ledger 519400`, + "vault pending change 1 live until ledger 519400", + "vault pending change 2 restored", + "vault pending change 2 live until ledger 519400", + ]) + const restored = ledger.calls.find((a) => a[1] === "restore") + expect(restored).toContain(pendingChangeKey(2).toXDR("base64")) + expect( + ledger.calls + .filter((a) => a.includes("--key-xdr")) + .every((a) => a.includes(CONTRACT_A)), + ).toBe(true) + }) + + it("reads open changes from an instance restored from archive", async () => { + const ledger = new FakeLedger() + const lines: string[] = [] + const log: Log = (l) => lines.push(l) + + ledger.put( + entryKey({ id: CONTRACT_A }), + instanceData(CONTRACT_A, { wasmHash: WASM_HASH, storage: [open(3)] }), + 900, + "absent", + ) + ledger.put(entryKey({ wasmHash: WASM_HASH }), codeData(WASM_HASH), 2000) + ledger.put( + entryKey({ + id: CONTRACT_A, + keyXdr: pendingChangeKey(3).toXDR("base64"), + }), + pendingData(CONTRACT_A, pendingChangeKey(3).toXDR("base64")), + 2000, + ) + + await keepDeploymentAlive(RPC, [["vault", CONTRACT_A]], ledger.run, log) + + expect(lines).toContain("vault instance restored") + expect(lines).toContain("vault pending change 3 live until ledger 519400") + }) +}) diff --git a/scripts/harness/lib/keep-alive.ts b/scripts/harness/lib/keep-alive.ts new file mode 100644 index 00000000..ef061898 --- /dev/null +++ b/scripts/harness/lib/keep-alive.ts @@ -0,0 +1,90 @@ +import { type xdr } from "@stellar/stellar-sdk" + +import { stellar, type Rpc } from "./cli.js" +import { openChangeIds, pendingChangeKey } from "./timelock-keys.js" +import { + EXTEND_LEDGERS, + extend, + inspect, + restore, + wasmHashOf, + type Entry, + type Runner, +} from "./ttl.js" + +export type Log = (line: string) => void + +/** Restores `entry` if archived, then extends it, returning its instance if any. */ +export async function keepAlive( + rpc: Rpc, + entry: Entry, + label: string, + run: Runner = stellar, + log: Log = console.log, +): Promise { + let inspection = await inspect(rpc, entry, run) + if (inspection.archived) { + try { + await restore(rpc, entry, run) + } catch (cause) { + if (!inspection.found) { + throw new Error( + `${label}: ${rpc.rpcUrl} has no entry for it, live or archived. Check the network and the contract id. (${String(cause)})`, + ) + } + throw cause + } + log(`${label} restored`) + inspection = await inspect(rpc, entry, run) + if (inspection.archived) + throw new Error(`${label} is still archived after the restore`) + } + const until = await extend(rpc, entry, EXTEND_LEDGERS, run) + if (until < inspection.latestLedger + EXTEND_LEDGERS) { + throw new Error( + `${label}: stellar contract extend reported live until ledger ${until}, but the latest ledger was ${inspection.latestLedger}. The RPC may be lagging; retry.`, + ) + } + log(`${label} live until ledger ${until}`) + return inspection.instance +} + +/** + * Keeps every instance, the Wasm each one runs and every open timelock + * change alive, reading the open ids from instance storage. + */ +export async function keepDeploymentAlive( + rpc: Rpc, + contracts: ReadonlyArray, + run: Runner = stellar, + log: Log = console.log, +): Promise { + const instances = new Map() + for (const [name, id] of contracts) { + const instance = await keepAlive(rpc, { id }, `${name} instance`, run, log) + if (instance !== undefined) instances.set(id, instance) + } + + const wasmHashes = new Set() + for (const instance of instances.values()) { + const wasmHash = wasmHashOf(instance) + if (wasmHash !== undefined) wasmHashes.add(wasmHash) + } + for (const wasmHash of wasmHashes) { + await keepAlive(rpc, { wasmHash }, `code ${wasmHash.slice(0, 8)}`, run, log) + } + + for (const [name, id] of contracts) { + const instance = instances.get(id) + if (instance === undefined) continue + for (const changeId of openChangeIds(instance)) { + await keepAlive( + rpc, + { id, keyXdr: pendingChangeKey(changeId).toXDR("base64") }, + `${name} pending change ${changeId}`, + run, + log, + ) + } + } +} diff --git a/scripts/harness/lib/keys.test.ts b/scripts/harness/lib/keys.test.ts index 776d02b1..ed88cde3 100644 --- a/scripts/harness/lib/keys.test.ts +++ b/scripts/harness/lib/keys.test.ts @@ -1,5 +1,6 @@ import { chmodSync, + existsSync, mkdtempSync, readFileSync, rmSync, @@ -12,8 +13,9 @@ import { join } from "node:path" import { Keypair, StrKey } from "@stellar/stellar-sdk" import { afterEach, beforeEach, describe, expect, it } from "vitest" -import { ACCOUNT_NAMES } from "./accounts.js" -import { ensureKeys, keysFile } from "./keys.js" +import { ACCOUNT_NAMES, type AccountName, type Accounts } from "./accounts.js" +import { publicKeysOf, type Deployment } from "./deployment.js" +import { ensureKeys, keysFile, keysFor } from "./keys.js" const VARIABLES = [ "DEPLOYER_SECRET", @@ -22,6 +24,7 @@ const VARIABLES = [ "TREASURY_SECRET", "GUARDIAN_SECRET", "COMPLIANCE_SECRET", + "COMPLIANCE_BACKUP_SECRET", "ATTESTER_SECRET", "CUSTODIAN_SECRET", "ISSUER_SECRET", @@ -176,3 +179,99 @@ describe("ensureKeys", () => { expect(lines.slice(-own.length)).toEqual(own) }) }) + +describe("keysFor", () => { + const recordOf = ( + accounts: Deployment["accounts"], + ): Pick => ({ + network: "local", + accounts, + }) + + // Names the accounts whose key is not the file's, so a failure prints no secret. + const notFromFile = (file: string, keys: Accounts): string[] => { + const written = new Set(linesOf(file)) + return ACCOUNT_NAMES.filter( + (name, i) => !written.has(`${VARIABLES[i]}=${keys[name].secret()}`), + ) + } + + it("signs with the key file's key for each account the record names", () => { + const { file, accounts } = ensureKeys("local", dir) + + const keys = keysFor(recordOf(publicKeysOf(accounts)), dir) + + expect(notFromFile(file, keys)).toEqual([]) + }) + + it("reads a record written while records still held secret keys", () => { + const { file, accounts } = ensureKeys("local", dir) + const withSecrets = Object.fromEntries( + ACCOUNT_NAMES.map((name) => [ + name, + { public: accounts[name].publicKey(), secret: accounts[name].secret() }, + ]), + ) as Record + + const keys = keysFor(recordOf(withSecrets), dir) + + expect(notFromFile(file, keys)).toEqual([]) + }) + + it("takes an account the record does not name from the key file", () => { + const { accounts } = ensureKeys("local", dir) + const named: Partial = publicKeysOf(accounts) + delete named.outsider + + const keys = keysFor(recordOf(named as Deployment["accounts"]), dir) + + expect(keys.outsider.publicKey()).toBe(accounts.outsider.publicKey()) + }) + + it("stops when there is no key file, and creates none", () => { + const accounts = Object.fromEntries( + ACCOUNT_NAMES.map((name) => [name, Keypair.random()]), + ) as Accounts + + const message = errorOf(() => + keysFor(recordOf(publicKeysOf(accounts)), dir), + ) + + expect(message).toContain(keysFile("local", dir)) + expect(message).toContain("Restore it from your backup") + expect(message).toContain("deploy again") + expect(message).toContain("`secret` fields of an older record") + expect(existsSync(keysFile("local", dir))).toBe(false) + }) + + it("stops when the key file has no line for an account", () => { + const { file, accounts } = ensureKeys("local", dir) + const without = `${linesOf(file) + .filter((line) => !line.startsWith("HOLDER_2_SECRET=")) + .join("\n")}\n` + writeFileSync(file, without) + + const message = errorOf(() => + keysFor(recordOf(publicKeysOf(accounts)), dir), + ) + + expect(message).toContain("has no HOLDER_2_SECRET") + expect(readFileSync(file, "utf8")).toBe(without) + }) + + it("stops when a key is not the one the deployment was made with", () => { + const { accounts } = ensureKeys("local", dir) + const other = Keypair.random().publicKey() + const record = recordOf({ + ...publicKeysOf(accounts), + "holder-1": { public: other }, + }) + + const message = errorOf(() => keysFor(record, dir)) + + expect(message).toContain("HOLDER_1_SECRET") + expect(message).toContain(other) + expect(message).toContain(accounts["holder-1"].publicKey()) + expect(message).not.toContain(accounts["holder-1"].secret()) + }) +}) diff --git a/scripts/harness/lib/keys.ts b/scripts/harness/lib/keys.ts index a9ae0bb7..f8003956 100644 --- a/scripts/harness/lib/keys.ts +++ b/scripts/harness/lib/keys.ts @@ -11,6 +11,7 @@ import { join } from "node:path" import { Keypair, StrKey } from "@stellar/stellar-sdk" import { ACCOUNT_NAMES, type AccountName, type Accounts } from "./accounts.js" +import { type Deployment } from "./deployment.js" import { type NetworkName } from "./network.js" export interface AccountKeys { @@ -96,3 +97,45 @@ export function ensureKeys(network: NetworkName, dir: string): AccountKeys { ) as Accounts return { file, accounts, created } } + +export function readKeys(network: NetworkName, dir: string): Accounts { + const file = keysFile(network, dir) + if (!existsSync(file)) { + throw new Error( + `no key file at ${file}: the harness signs with the keys in it. Restore it from your backup, deploy again to create it, or rebuild it from the \`secret\` fields of an older record.`, + ) + } + const { secrets } = parse(readFileSync(file, "utf8"), file, headerOf(network)) + return Object.fromEntries( + ACCOUNT_NAMES.map((name) => { + const secret = secrets.get(name) + if (secret === undefined) { + throw new Error( + `${file} has no ${variableOf(name)}. Restore it from your backup, or deploy again to create the key.`, + ) + } + return [name, Keypair.fromSecret(secret)] + }), + ) as Accounts +} + +// An account an older record does not name is taken from the file unchecked. +export function keysFor( + deployment: Pick, + dir: string, +): Accounts { + const { network } = deployment + const file = keysFile(network, dir) + const keys = readKeys(network, dir) + const recorded: Partial = deployment.accounts + for (const name of ACCOUNT_NAMES) { + const expected = recorded[name]?.public + const actual = keys[name].publicKey() + if (expected !== undefined && expected !== actual) { + throw new Error( + `${file}: ${variableOf(name)} is the key of ${actual}, but the deployment on ${network} was made with ${expected}`, + ) + } + } + return keys +} diff --git a/scripts/harness/lib/network.test.ts b/scripts/harness/lib/network.test.ts new file mode 100644 index 00000000..71da049e --- /dev/null +++ b/scripts/harness/lib/network.test.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { network } from "./network.js" + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe("network", () => { + it.each(["local", "testnet"] as const)( + "%s sets the vault notice to twice the oracle cooldown", + (name) => { + vi.stubEnv("HARNESS_NETWORK", name) + const net = network() + expect(net.vault.notice_secs).toBe(2 * net.oracle.cooldown_secs) + expect(net.vault.notice_secs).toBeGreaterThan(0) + }, + ) +}) diff --git a/scripts/harness/lib/network.ts b/scripts/harness/lib/network.ts index dae1a93b..9bd015fb 100644 --- a/scripts/harness/lib/network.ts +++ b/scripts/harness/lib/network.ts @@ -8,11 +8,15 @@ export interface OracleConfig { freshness_duration: number cooldown_secs: number max_up_bps: number - max_down_bps: number | null + max_down_bps: number min_answer: string max_answer: string } +export interface VaultConfig { + notice_secs: number +} + export type Funding = { kind: "root"; secret: string } | { kind: "friendbot"; url: string } @@ -22,12 +26,14 @@ export interface Network { passphrase: string funding: Funding oracle: OracleConfig + vault: VaultConfig } // The band is the protocol's; only the two waits are the fixture's choice. +// A fall of any size lands at once, as a real loss must. const band = { max_up_bps: 1000, - max_down_bps: null, + max_down_bps: 10_000, min_answer: (WAD / 2n).toString(), max_answer: (WAD * 100n).toString(), } @@ -36,6 +42,14 @@ const band = { const QUICKSTART_ROOT = "SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L" +const LOCAL_COOLDOWN_SECS = 5 +const TESTNET_COOLDOWN_SECS = 60 + +// Twice the cooldown, so a valuation attested after the close lands before the notice ends. +const noticeFor = (cooldownSecs: number): VaultConfig => ({ + notice_secs: 2 * cooldownSecs, +}) + const NETWORKS: Record = { local: { name: "local", @@ -45,7 +59,12 @@ const NETWORKS: Record = { kind: "root", secret: process.env["HARNESS_ROOT_SECRET"] ?? QUICKSTART_ROOT, }, - oracle: { freshness_duration: 300, cooldown_secs: 5, ...band }, + oracle: { + freshness_duration: 300, + cooldown_secs: LOCAL_COOLDOWN_SECS, + ...band, + }, + vault: noticeFor(LOCAL_COOLDOWN_SECS), }, testnet: { name: "testnet", @@ -53,10 +72,24 @@ const NETWORKS: Record = { process.env["HARNESS_RPC_URL"] ?? "https://soroban-testnet.stellar.org", passphrase: Networks.TESTNET, funding: { kind: "friendbot", url: "https://friendbot.stellar.org" }, - oracle: { freshness_duration: 3600, cooldown_secs: 60, ...band }, + oracle: { + freshness_duration: 3600, + cooldown_secs: TESTNET_COOLDOWN_SECS, + ...band, + }, + vault: noticeFor(TESTNET_COOLDOWN_SECS), }, } +export function networkNamed(name: string): Network { + if (!Object.hasOwn(NETWORKS, name)) { + throw new Error( + `unknown network ${name}; expected ${Object.keys(NETWORKS).join(" or ")}`, + ) + } + return NETWORKS[name as NetworkName] +} + export function network(): Network { const argIdx = process.argv.indexOf("--network") const argVal = argIdx !== -1 ? process.argv[argIdx + 1] : undefined diff --git a/scripts/harness/lib/notice.ts b/scripts/harness/lib/notice.ts new file mode 100644 index 00000000..7da20d64 --- /dev/null +++ b/scripts/harness/lib/notice.ts @@ -0,0 +1,22 @@ +import { setTimeout as sleep } from "node:timers/promises" + +import { type Client as VaultClient } from "../../../app-lib/clients/async_vault/dist/index.js" +import { type Connected } from "./clients.js" +import { outcome } from "./refused.js" + +// Ledger time decides the notice, so poll the contract rather than wait on the clock. +export async function waitForNotice( + c: Connected, + reader: VaultClient, + epochId: bigint, +): Promise { + const deadline = Date.now() + (c.net.vault.notice_secs + 60) * 1000 + for (;;) { + const tx = await reader.fulfill_epoch({ epoch_id: epochId }) + if (outcome(tx, reader) !== "refused: NoticeNotElapsed") return + if (Date.now() > deadline) { + throw new Error(`notice never elapsed for epoch ${epochId}`) + } + await sleep(2_000) + } +} diff --git a/scripts/harness/lib/oracle.ts b/scripts/harness/lib/oracle.ts index 399ac4f8..285bb446 100644 --- a/scripts/harness/lib/oracle.ts +++ b/scripts/harness/lib/oracle.ts @@ -1,19 +1,36 @@ +import { createHash } from "node:crypto" +import { readFileSync } from "node:fs" +import { join } from "node:path" import { setTimeout as sleep } from "node:timers/promises" import { oracle, send, type Connected } from "./clients.js" +import { REPO_ROOT } from "./deployment.js" import { outcome } from "./refused.js" -export interface NavReport { +export interface NavInput { nav_per_share: bigint expires_at: bigint - timestamp: bigint + proof_hash: Buffer + proof_uri: string } -export function navReport(nav: bigint, freshnessSecs: number): NavReport { - const now = BigInt(Math.floor(Date.now() / 1000)) +/** The latest expiry a u64 holds: the oracle cuts it to its own window. */ +const NO_EXPIRY = (1n << 64n) - 1n + +const SAMPLE_PROOF = join(REPO_ROOT, "docs", "evidence", "sample-valuation.md") + +// The release attests against the tag's copy; local runs use main's. +export const SAMPLE_PROOF_URI = + process.env["PROOF_URI"] ?? + "https://raw.githubusercontent.com/BootNodeDev/strata-vault-kit/main/docs/evidence/sample-valuation.md" + +export function navInput(nav: bigint): NavInput { return { nav_per_share: nav, - expires_at: now + BigInt(freshnessSecs), - timestamp: now, + expires_at: NO_EXPIRY, + proof_hash: createHash("sha256") + .update(readFileSync(SAMPLE_PROOF)) + .digest(), + proof_uri: SAMPLE_PROOF_URI, } } @@ -24,7 +41,7 @@ export async function attest(c: Connected, nav: bigint): Promise { const deadline = Date.now() + (c.net.oracle.cooldown_secs + 60) * 1_000 for (;;) { const tx = await client.attest({ - report: navReport(nav, c.net.oracle.freshness_duration), + input: navInput(nav), caller: attester.publicKey(), }) const result = outcome(tx, client) @@ -38,3 +55,14 @@ export async function attest(c: Connected, nav: bigint): Promise { await sleep(2_000) } } + +// Deposits need a valid feed: a lapsed report is posted again at its own value. +export async function freshFeed(c: Connected, fallback: bigint): Promise { + const reader = oracle(c, c.keys.outsider) + if ((await reader.state()).result.tag !== "Stale") return + const latest = await reader.latest() + const stored = outcome(latest, reader) + if (stored === "accepted") return attest(c, latest.result.nav_per_share) + if (stored === "refused: NoRecord") return attest(c, fallback) + throw new Error(stored) +} diff --git a/scripts/harness/lib/provenance.test.ts b/scripts/harness/lib/provenance.test.ts new file mode 100644 index 00000000..0e7e14c3 --- /dev/null +++ b/scripts/harness/lib/provenance.test.ts @@ -0,0 +1,117 @@ +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { CONTRACT_NAMES, type ContractName } from "./deployment.js" +import { readProvenance, sourceCommit, wasmHash } from "./provenance.js" + +let dir = "" + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "harness-provenance-")) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function git(...args: string[]): string { + // Isolate from the machine's global hooks (e.g. gitleaks on commit). + return execFileSync( + "git", + ["-C", dir, "-c", "core.hooksPath=/dev/null", ...args], + { encoding: "utf8" }, + ) +} + +function commitFile(name: string, content: string): void { + writeFileSync(join(dir, name), content) + git("add", name) + git( + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "msg", + ) +} + +describe("wasmHash", () => { + it("hashes a Wasm file with SHA-256", () => { + const file = join(dir, "contract.wasm") + writeFileSync(file, "abc") + + expect(wasmHash(file)).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ) + }) +}) + +describe("sourceCommit", () => { + it("names the checked-out commit", () => { + git("init", "-q") + commitFile("a.txt", "one") + + const result = sourceCommit(dir) + + expect(result.commit).toBe(git("rev-parse", "HEAD").trim()) + expect(result.uncommittedChanges).toBe(false) + }) + + it("flags an edit to a tracked file", () => { + git("init", "-q") + commitFile("a.txt", "one") + writeFileSync(join(dir, "a.txt"), "two") + + expect(sourceCommit(dir).uncommittedChanges).toBe(true) + }) + + it("ignores untracked files", () => { + git("init", "-q") + commitFile("a.txt", "one") + writeFileSync(join(dir, "b.txt"), "new") + + expect(sourceCommit(dir).uncommittedChanges).toBe(false) + }) + + it("stops outside a git checkout", () => { + expect(() => sourceCommit(dir)).toThrow(/git/) + }) +}) + +const wasmsOf = (file: string): Record => + Object.fromEntries(CONTRACT_NAMES.map((name) => [name, file])) as Record< + ContractName, + string + > + +describe("readProvenance", () => { + it("combines the commit and every wasm's hash", () => { + git("init", "-q") + commitFile("a.txt", "one") + const file = join(dir, "contract.wasm") + writeFileSync(file, "abc") + + const result = readProvenance(dir, wasmsOf(file)) + + expect(result.commit).toBe(git("rev-parse", "HEAD").trim()) + expect(result.uncommittedChanges).toBe(false) + for (const name of CONTRACT_NAMES) { + expect(result.wasmHashes[name]).toBe(wasmHash(file)) + } + }) + + it("stops outside a git checkout before hashing any wasm", () => { + const missing = join(dir, "missing.wasm") + + expect(() => readProvenance(dir, wasmsOf(missing))).toThrow(/git/) + }) +}) diff --git a/scripts/harness/lib/provenance.ts b/scripts/harness/lib/provenance.ts new file mode 100644 index 00000000..d9a024fd --- /dev/null +++ b/scripts/harness/lib/provenance.ts @@ -0,0 +1,55 @@ +import { execFileSync } from "node:child_process" +import { createHash } from "node:crypto" +import { readFileSync } from "node:fs" + +import { type ContractName } from "./deployment.js" + +export function wasmHash(file: string): string { + return createHash("sha256").update(readFileSync(file)).digest("hex") +} + +export function sourceCommit(dir: string): { + commit: string + uncommittedChanges: boolean +} { + const git = (...args: string[]): string => { + try { + return execFileSync("git", ["-C", dir, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) + } catch (error) { + // Default stdio forwards the child's stderr to ours; capture it instead. + const stderr = + error instanceof Error && "stderr" in error + ? String((error as { stderr?: unknown }).stderr).trim() + : "" + throw new Error( + `git ${args.join(" ")} failed${stderr ? `: ${stderr}` : ""}`, + ) + } + } + + const commit = git("rev-parse", "HEAD").trim() + const status = git("status", "--porcelain", "--untracked-files=no") + return { commit, uncommittedChanges: status.trim() !== "" } +} + +export interface Provenance { + commit: string + uncommittedChanges: boolean + wasmHashes: Record +} + +// Reads the checkout and the wasm files, nothing else; safe to call before +// any network action, so a deploy outside a checkout fails before it starts. +export function readProvenance( + dir: string, + wasms: Record, +): Provenance { + const { commit, uncommittedChanges } = sourceCommit(dir) + const wasmHashes = Object.fromEntries( + Object.entries(wasms).map(([name, file]) => [name, wasmHash(file)]), + ) as Record + return { commit, uncommittedChanges, wasmHashes } +} diff --git a/scripts/harness/lib/refused.test.ts b/scripts/harness/lib/refused.test.ts new file mode 100644 index 00000000..fdfc62e0 --- /dev/null +++ b/scripts/harness/lib/refused.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" + +import { expectRefused, type HasSpec } from "./refused.js" + +const errorCases = [ + { name: () => ({ toString: () => "NoticeNotElapsed" }), value: () => 6010 }, + { name: () => ({ toString: () => "PriceAvailable" }), value: () => 6011 }, +] + +const clientWith = (): HasSpec => ({ + spec: { errorCases: () => errorCases }, +}) + +const refusedWith = (code: number) => ({ + result: undefined, + simulation: { error: `HostError: Error(Contract, #${code})` }, +}) + +describe("expectRefused", () => { + it("passes when the call is refused with the named error", () => { + expect(() => + expectRefused(refusedWith(6010), clientWith(), "NoticeNotElapsed"), + ).not.toThrow() + }) + + it("fails naming the outcome it got", () => { + expect(() => + expectRefused(refusedWith(6011), clientWith(), "NoticeNotElapsed"), + ).toThrow(/refused: PriceAvailable/) + }) + + it("fails when the call was accepted", () => { + expect(() => + expectRefused({ result: 1 }, clientWith(), "NoticeNotElapsed"), + ).toThrow(/accepted/) + }) + + it("stops on an error name the spec does not have", () => { + expect(() => + expectRefused(refusedWith(6010), clientWith(), "Nope"), + ).toThrow("the contract spec has no error named Nope") + }) +}) diff --git a/scripts/harness/lib/refused.ts b/scripts/harness/lib/refused.ts index 0108d4c8..28cad7e2 100644 --- a/scripts/harness/lib/refused.ts +++ b/scripts/harness/lib/refused.ts @@ -1,4 +1,4 @@ -import { expect } from "vitest" +import assert from "node:assert/strict" interface ErrorCase { name(): { toString(): string } @@ -39,5 +39,5 @@ export function expectRefused(tx: Tx, client: HasSpec, name: string): void { .errorCases() .some((c) => c.name().toString() === name) if (!known) throw new Error(`the contract spec has no error named ${name}`) - expect(outcome(tx, client)).toBe(`refused: ${name}`) + assert.equal(outcome(tx, client), `refused: ${name}`) } diff --git a/scripts/harness/lib/rpc.test.ts b/scripts/harness/lib/rpc.test.ts new file mode 100644 index 00000000..ce2ddb86 --- /dev/null +++ b/scripts/harness/lib/rpc.test.ts @@ -0,0 +1,65 @@ +import { Account, Keypair, type rpc } from "@stellar/stellar-sdk" +import { describe, expect, it } from "vitest" + +import { withAccountRetry } from "./rpc.js" + +function fakeServer(outcomes: (() => Promise)[]) { + let calls = 0 + const server = { + getAccount(): Promise { + const outcome = outcomes[calls] ?? outcomes[outcomes.length - 1] + calls += 1 + return outcome!() + }, + } + return { server: server as unknown as rpc.Server, callCount: () => calls } +} + +const address = Keypair.random().publicKey() +const notFound = () => + Promise.reject(new Error(`Account not found: ${address}`)) +const found = () => Promise.resolve(new Account(address, "1")) + +// Skips the production retry interval so the suite runs in milliseconds. +const noWait = () => Promise.resolve() + +describe("withAccountRetry", () => { + it("returns the account once a retry succeeds", async () => { + const { server, callCount } = fakeServer([notFound, notFound, found]) + + const account = await withAccountRetry(server, noWait).getAccount(address) + + expect(account.accountId()).toBe(address) + expect(callCount()).toBe(3) + }) + + it("rethrows with context once the attempts run out", async () => { + const { server, callCount } = fakeServer([notFound]) + + let caught: unknown + try { + await withAccountRetry(server, noWait).getAccount(address) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + const error = caught as Error + expect(error.message).toBe( + `Account not found: ${address} (after 10 tries; the SDK reports an unreachable RPC the same way)`, + ) + expect(error.cause).toBeInstanceOf(Error) + expect(callCount()).toBe(10) + }) + + it("rethrows any other error at once", async () => { + const { server, callCount } = fakeServer([ + () => Promise.reject(new Error("socket hang up")), + ]) + + await expect( + withAccountRetry(server, noWait).getAccount(address), + ).rejects.toThrow("socket hang up") + expect(callCount()).toBe(1) + }) +}) diff --git a/scripts/harness/lib/rpc.ts b/scripts/harness/lib/rpc.ts new file mode 100644 index 00000000..551c6a41 --- /dev/null +++ b/scripts/harness/lib/rpc.ts @@ -0,0 +1,35 @@ +import { setTimeout as sleep } from "node:timers/promises" + +import { type rpc } from "@stellar/stellar-sdk" + +const ATTEMPTS = 10 +const INTERVAL_MS = 1_000 + +function isAccountNotFound(error: unknown): error is Error { + return error instanceof Error && error.message.startsWith("Account not found") +} + +// Never wrap code that expects "not found" to mean the account is new. +export function withAccountRetry>( + server: S, + wait: (ms: number) => Promise = sleep, +): S { + const getAccount = server.getAccount.bind(server) + server.getAccount = async (address: string) => { + for (let attempt = 1; ; attempt += 1) { + try { + return await getAccount(address) + } catch (error) { + if (!isAccountNotFound(error)) throw error + if (attempt >= ATTEMPTS) { + throw new Error( + `${error.message} (after ${ATTEMPTS} tries; the SDK reports an unreachable RPC the same way)`, + { cause: error }, + ) + } + await wait(INTERVAL_MS) + } + } + } + return server +} diff --git a/scripts/harness/lib/timelock-keys.test.ts b/scripts/harness/lib/timelock-keys.test.ts new file mode 100644 index 00000000..c61bf933 --- /dev/null +++ b/scripts/harness/lib/timelock-keys.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from "node:fs" +import { join } from "node:path" + +import { xdr } from "@stellar/stellar-sdk" +import { describe, expect, it } from "vitest" + +import { REPO_ROOT } from "./deployment.js" +import { + openChangeIds, + openChangesKey, + pendingChangeKey, +} from "./timelock-keys.js" + +const FIXTURE = join(REPO_ROOT, "fixtures", "timelock-storage-keys.txt") + +function fixtureLines(): Array<[string, string]> { + return readFileSync(FIXTURE, "utf8") + .split("\n") + .filter((line) => line.trim() !== "" && !line.startsWith("#")) + .map((line): [string, string] => { + const at = line.indexOf(" ") + if (at === -1) throw new Error(`${FIXTURE}: malformed line ${line}`) + return [line.slice(0, at), line.slice(at + 1)] + }) +} + +function instance(storage: xdr.ScMapEntry[] | null): xdr.ScContractInstance { + return new xdr.ScContractInstance({ + executable: xdr.ContractExecutable.contractExecutableWasm(Buffer.alloc(32)), + storage, + }) +} + +describe("timelock storage keys", () => { + it("builds every key the way the timelock crate encodes it", () => { + const lines = fixtureLines() + expect(lines.some(([name]) => name === "TimelockOpen")).toBe(true) + expect(lines.some(([name]) => name.startsWith("TimelockPending("))).toBe( + true, + ) + for (const [name, hex] of lines) { + const key = + name === "TimelockOpen" + ? openChangesKey() + : pendingChangeKey( + Number(/^TimelockPending\((\d+)\)$/.exec(name)?.[1] ?? NaN), + ) + expect(key.toXDR("hex")).toBe(hex) + } + }) +}) + +describe("openChangeIds", () => { + it("reads the open ids from instance storage", () => { + const storage = [ + new xdr.ScMapEntry({ + key: openChangesKey(), + val: xdr.ScVal.scvVec([xdr.ScVal.scvU32(3), xdr.ScVal.scvU32(7)]), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("Other"), + val: xdr.ScVal.scvU32(1), + }), + ] + + expect(openChangeIds(instance(storage))).toEqual([3, 7]) + }) + + it("finds no open ids in a contract that never queued a change", () => { + const withoutKey = instance([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("Other"), + val: xdr.ScVal.scvU32(1), + }), + ]) + const withNullStorage = instance(null) + + expect(openChangeIds(withoutKey)).toEqual([]) + expect(openChangeIds(withNullStorage)).toEqual([]) + }) + + it("refuses an open-ids entry of another shape", () => { + const badShape = instance([ + new xdr.ScMapEntry({ key: openChangesKey(), val: xdr.ScVal.scvU32(1) }), + ]) + + expect(() => openChangeIds(badShape)).toThrow(/TimelockOpen/) + }) +}) diff --git a/scripts/harness/lib/timelock-keys.ts b/scripts/harness/lib/timelock-keys.ts new file mode 100644 index 00000000..275eaaa3 --- /dev/null +++ b/scripts/harness/lib/timelock-keys.ts @@ -0,0 +1,32 @@ +import { xdr } from "@stellar/stellar-sdk" + +/** `Key::TimelockPending(id)` (`crates/strata-timelock/src/keys.rs`). */ +export function pendingChangeKey(id: number): xdr.ScVal { + return xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("TimelockPending"), + xdr.ScVal.scvU32(id), + ]) +} + +/** `Key::TimelockOpen` (`crates/strata-timelock/src/keys.rs`). */ +export function openChangesKey(): xdr.ScVal { + return xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("TimelockOpen")]) +} + +/** The ids waiting in `instance`'s queue, in stored order, or `[]` with no queue. */ +export function openChangeIds(instance: xdr.ScContractInstance): number[] { + const target = openChangesKey().toXDR("base64") + const entry = instance + .storage() + ?.find((candidate) => candidate.key().toXDR("base64") === target) + if (entry === undefined) return [] + + const val = entry.val() + const vec = val.switch().name === "scvVec" ? val.vec() : null + if (vec === null || vec.some((item) => item.switch().name !== "scvU32")) { + throw new Error( + `instance storage holds TimelockOpen as ${val.switch().name}, not a vec of u32`, + ) + } + return vec.map((item) => item.u32()) +} diff --git a/scripts/harness/lib/ttl.test.ts b/scripts/harness/lib/ttl.test.ts new file mode 100644 index 00000000..959320e2 --- /dev/null +++ b/scripts/harness/lib/ttl.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest" + +import { + CONTRACT_A, + RPC, + WASM_HASH, + codeData, + fetchOutput, + instanceData, +} from "./fake-ledger.js" +import { + extend, + inspect, + latestLedger, + wasmHashOf, + type Entry, + type Runner, +} from "./ttl.js" + +// Captured verbatim from a quickstart RPC (CLI 27, protocol 28). +const SAC_INSTANCE_FETCH = + '{"entries":[{"key":"AAAABgAAAAHYsrFMYP7LZfsqqBy+p50INcr8Dg2FH4YRJXweW0HhwgAAABQAAAAB","xdr":"AAAABgAAAAAAAAAB2LKxTGD+y2X7KqgcvqedCDXK/A4NhR+GESV8HltB4cIAAAAUAAAAAQAAABMAAAABAAAAAQAAAAIAAAAPAAAACE1FVEFEQVRBAAAAEQAAAAEAAAADAAAADwAAAAdkZWNpbWFsAAAAAAMAAAAHAAAADwAAAARuYW1lAAAADgAAAAZuYXRpdmUAAAAAAA8AAAAGc3ltYm9sAAAAAAAOAAAABm5hdGl2ZQAAAAAAEAAAAAEAAAABAAAADwAAAAlBc3NldEluZm8AAAAAAAAQAAAAAQAAAAEAAAAPAAAABk5hdGl2ZQAA","lastModifiedLedgerSeq":173,"liveUntilLedgerSeq":121132}],"latestLedger":174}' + +const run = + (output: string): Runner => + () => + Promise.resolve(output) + +describe("inspect", () => { + it("reads a live Stellar Asset Contract instance from the CLI's XDR output", async () => { + const result = await inspect( + RPC, + { id: CONTRACT_A }, + run(SAC_INSTANCE_FETCH), + ) + + expect(result.archived).toBe(false) + expect(result.found).toBe(true) + expect(wasmHashOf(result.instance!)).toBeUndefined() + }) + + it("counts an entry the RPC does not return as archived", async () => { + const result = await inspect( + RPC, + { id: CONTRACT_A }, + run('{"entries":[],"latestLedger":174}'), + ) + + expect(result).toEqual({ + archived: true, + found: false, + instance: undefined, + latestLedger: 174, + }) + }) + + it("counts an entry whose live-until ledger has passed as archived", async () => { + const expired = SAC_INSTANCE_FETCH.replace( + '"liveUntilLedgerSeq":121132', + '"liveUntilLedgerSeq":0', + ).replace('"latestLedger":174', '"latestLedger":300') + + const result = await inspect(RPC, { id: CONTRACT_A }, run(expired)) + + expect(result.archived).toBe(true) + expect(result.found).toBe(true) + expect(result.instance).toBeDefined() + }) + + it("counts an entry live through the latest ledger as live", async () => { + const output = fetchOutput(500, [ + { + xdr: instanceData(CONTRACT_A, { wasmHash: WASM_HASH }), + liveUntilLedgerSeq: 500, + }, + ]) + + const result = await inspect(RPC, { id: CONTRACT_A }, run(output)) + + expect(result.archived).toBe(false) + expect(wasmHashOf(result.instance!)).toBe(WASM_HASH) + }) + + it("asks the CLI for the RPC's XDR output", async () => { + const calls: string[][] = [] + const capture: Runner = (args) => { + calls.push([...args]) + return Promise.resolve('{"entries":[],"latestLedger":1}') + } + + await inspect(RPC, { id: CONTRACT_A }, capture) + await inspect(RPC, { id: CONTRACT_A, keyXdr: "K" }, capture) + await inspect(RPC, { wasmHash: WASM_HASH }, capture) + + expect(calls[0]).toEqual( + expect.arrayContaining(["--instance", "--output", "xdr"]), + ) + expect(calls[1]).toEqual( + expect.arrayContaining(["--key-xdr", "K", "--output", "xdr"]), + ) + expect(calls[2]).toEqual( + expect.arrayContaining([ + "contract-code", + "--wasm-hash", + WASM_HASH, + "--output", + "xdr", + ]), + ) + }) + + const instanceEntry: Entry = { id: CONTRACT_A } + const badShapes = [ + { name: "not json", output: "not json" }, + { name: "no entries array", output: '{"entries":[]}' }, + { + name: "entries not an array", + output: '{"entries":{},"latestLedger":1}', + }, + { + name: "an entry with unreadable xdr", + output: fetchOutput(1, [{ xdr: "AAAA", liveUntilLedgerSeq: 1 }]), + }, + { + name: "an entry with no live-until ledger", + output: JSON.stringify({ + entries: [{ xdr: instanceData(CONTRACT_A, {}) }], + latestLedger: 1, + }), + }, + { + name: "two entries", + output: fetchOutput(1, [ + { xdr: instanceData(CONTRACT_A, {}), liveUntilLedgerSeq: 1 }, + { xdr: instanceData(CONTRACT_A, {}), liveUntilLedgerSeq: 1 }, + ]), + }, + { + name: "an instance fetch answered with contract code", + output: fetchOutput(1, [ + { xdr: codeData(WASM_HASH), liveUntilLedgerSeq: 1 }, + ]), + }, + ] + + it.each(badShapes)( + "names the command when the fetch output has another shape: $name", + async ({ output }) => { + await expect(inspect(RPC, instanceEntry, run(output))).rejects.toThrow( + /stellar ledger entry fetch/, + ) + }, + ) +}) + +describe("extend", () => { + it("returns the live-until ledger it prints", async () => { + await expect( + extend(RPC, { id: CONTRACT_A }, 100, run("1311")), + ).resolves.toBe(1311) + }) + + it.each(["", "done", "12.5"])( + "names the command when it prints no live-until ledger: %j", + async (output) => { + await expect( + extend(RPC, { id: CONTRACT_A }, 100, run(output)), + ).rejects.toThrow(/stellar contract extend/) + }, + ) + + it("returns 0 as-is, leaving the staleness check to keepAlive", async () => { + await expect(extend(RPC, { id: CONTRACT_A }, 100, run("0"))).resolves.toBe( + 0, + ) + }) +}) + +describe("latestLedger", () => { + it("reads the ledger sequence", async () => { + await expect( + latestLedger(RPC, run('{"id":"x","protocolVersion":28,"sequence":175}')), + ).resolves.toBe(175) + }) + + it("names the command when the sequence is missing", async () => { + await expect(latestLedger(RPC, run('{"id":"x"}'))).rejects.toThrow( + /stellar ledger latest/, + ) + }) +}) diff --git a/scripts/harness/lib/ttl.ts b/scripts/harness/lib/ttl.ts new file mode 100644 index 00000000..6112d272 --- /dev/null +++ b/scripts/harness/lib/ttl.ts @@ -0,0 +1,192 @@ +import { xdr } from "@stellar/stellar-sdk" + +import { rpcArgs, stellar, type Rpc } from "./cli.js" + +/** Thirty days of five-second ledgers: the same length the contracts extend to on use. */ +export const EXTEND_LEDGERS = 30 * 17_280 + +export type Entry = { id: string } | { wasmHash: string } | PendingEntry + +/** A specific persistent entry in a contract's own storage, by its key. */ +export interface PendingEntry { + id: string + keyXdr: string +} + +export type Runner = (args: readonly string[]) => Promise + +/** What a ledger entry fetch found: live or archived, and its instance if any. */ +export interface Inspection { + archived: boolean + found: boolean + instance: xdr.ScContractInstance | undefined + latestLedger: number +} + +const entryArgs = (entry: Entry): string[] => + "keyXdr" in entry + ? ["--id", entry.id, "--key-xdr", entry.keyXdr] + : "id" in entry + ? ["--id", entry.id] + : ["--wasm-hash", entry.wasmHash] + +function fetchError(out: string): Error { + return new Error( + `stellar ledger entry fetch returned unexpected output:\n${out}`, + ) +} + +/** The instance for a plain `{ id }` fetch, or `undefined` for the other entry kinds. */ +function instanceOf( + entry: Entry, + decoded: xdr.LedgerEntryData, + out: string, +): xdr.ScContractInstance | undefined { + if ("wasmHash" in entry) { + if (decoded.switch().name !== "contractCode") throw fetchError(out) + return undefined + } + if (decoded.switch().name !== "contractData") throw fetchError(out) + if ("keyXdr" in entry) return undefined + const val = decoded.contractData().val() + if (val.switch().name !== "scvContractInstance") throw fetchError(out) + return val.instance() +} + +export async function inspect( + rpc: Rpc, + entry: Entry, + run: Runner = stellar, +): Promise { + const args = + "keyXdr" in entry + ? ["contract-data", "--contract", entry.id, "--key-xdr", entry.keyXdr] + : "id" in entry + ? ["contract-data", "--contract", entry.id, "--instance"] + : ["contract-code", "--wasm-hash", entry.wasmHash] + const out = await run([ + "ledger", + "entry", + "fetch", + ...args, + "--output", + "xdr", + ...rpcArgs(rpc), + ]) + + let parsed: unknown + try { + parsed = JSON.parse(out) + } catch { + throw fetchError(out) + } + if (typeof parsed !== "object" || parsed === null) throw fetchError(out) + const { entries, latestLedger: latest } = parsed as Record + if (!Array.isArray(entries) || typeof latest !== "number") + throw fetchError(out) + if (entries.length === 0) + return { + archived: true, + found: false, + instance: undefined, + latestLedger: latest, + } + if (entries.length > 1) throw fetchError(out) + + const [found] = entries as unknown[] + if (typeof found !== "object" || found === null) throw fetchError(out) + const { xdr: entryXdr, liveUntilLedgerSeq } = found as Record + if (typeof entryXdr !== "string" || typeof liveUntilLedgerSeq !== "number") + throw fetchError(out) + + let decoded: xdr.LedgerEntryData + try { + decoded = xdr.LedgerEntryData.fromXDR(entryXdr, "base64") + } catch { + throw fetchError(out) + } + + return { + archived: liveUntilLedgerSeq < latest, + found: true, + instance: instanceOf(entry, decoded, out), + latestLedger: latest, + } +} + +// The CLI pays with the account in STELLAR_ACCOUNT, inherited from the caller. +export async function restore( + rpc: Rpc, + entry: Entry, + run: Runner = stellar, +): Promise { + await run(["contract", "restore", ...entryArgs(entry), ...rpcArgs(rpc)]) +} + +/** Returns the ledger the entry now lives until. */ +export async function extend( + rpc: Rpc, + entry: Entry, + ledgers: number, + run: Runner = stellar, +): Promise { + const out = await run([ + "contract", + "extend", + ...entryArgs(entry), + "--ledgers-to-extend", + String(ledgers), + "--ttl-ledger-only", + ...rpcArgs(rpc), + ]) + const last = out.split("\n").at(-1) ?? "" + if (!/^\d+$/.test(last.trim())) { + throw new Error( + `stellar contract extend printed no live-until ledger:\n${out}`, + ) + } + return Number(last) +} + +export async function latestLedger( + rpc: Rpc, + run: Runner = stellar, +): Promise { + const out = await run([ + "ledger", + "latest", + "--output", + "json", + ...rpcArgs(rpc), + ]) + const badOutput = () => + new Error(`stellar ledger latest returned no ledger sequence:\n${out}`) + + let parsed: unknown + try { + parsed = JSON.parse(out) + } catch { + throw badOutput() + } + const sequence = + typeof parsed === "object" && parsed !== null + ? (parsed as { sequence?: unknown }).sequence + : undefined + if ( + typeof sequence !== "number" || + !Number.isInteger(sequence) || + sequence <= 0 + ) + throw badOutput() + return sequence +} + +/** The hex Wasm hash an instance runs, or `undefined` for a Stellar Asset Contract. */ +export function wasmHashOf( + instance: xdr.ScContractInstance, +): string | undefined { + const executable = instance.executable() + return executable.switch().name === "contractExecutableWasm" + ? executable.wasmHash().toString("hex") + : undefined +} diff --git a/scripts/harness/package.json b/scripts/harness/package.json index f43c7724..a86309af 100644 --- a/scripts/harness/package.json +++ b/scripts/harness/package.json @@ -6,6 +6,7 @@ "description": "Deploys the vault set to a network and runs integration tests.", "scripts": { "deploy": "tsx deploy.ts", + "extend": "tsx extend.ts", "keys": "tsx keys.ts", "build:clients": "tsx build-clients.ts", "lint": "eslint .", @@ -13,10 +14,11 @@ "test:unit": "vitest run lib/ --reporter=default", "test:happy-path": "vitest run happy-path.test.ts", "test:guards": "vitest run guards.test.ts", + "test:wind-down": "vitest run wind-down.test.ts", "seed": "tsx seed.ts" }, "dependencies": { - "@stellar/stellar-sdk": "16.1.0" + "@stellar/stellar-sdk": "16.4.1" }, "devDependencies": { "@theahaco/ts-config": "1.3.2", diff --git a/scripts/harness/seed.ts b/scripts/harness/seed.ts index a6a546d7..71438d9d 100644 --- a/scripts/harness/seed.ts +++ b/scripts/harness/seed.ts @@ -1,7 +1,9 @@ import { type Keypair } from "@stellar/stellar-sdk" +import { accountRow } from "./lib/account-table.js" import { pay, trustline } from "./lib/accounts.js" import { price, units } from "./lib/amounts.js" +import { trustlineBalance } from "./lib/balances.js" import { connect, identityVerifier, @@ -12,7 +14,8 @@ import { vault, type Connected, } from "./lib/clients.js" -import { attest } from "./lib/oracle.js" +import { waitForNotice } from "./lib/notice.js" +import { attest, freshFeed } from "./lib/oracle.js" function formatAmount(amount: bigint, decimals: number): string { const factor = 10n ** BigInt(decimals) @@ -29,18 +32,7 @@ function formatWad(wad: bigint): string { } async function hasTrustline(c: Connected, address: string): Promise { - try { - await c.server.getAssetBalance(address, c.asset, c.net.passphrase) - return true - } catch (error) { - if ( - error instanceof Error && - /Trustline for .* not found for/.test(error.message) - ) { - return false - } - throw error - } + return (await trustlineBalance(c.server, address, c.asset)) !== undefined } async function ensureTrustline( @@ -105,7 +97,6 @@ async function ensureAllowlist( verifier.allow({ account: holder.publicKey(), allowed: true, - caller: c.keys.compliance.publicKey(), }), ) console.log(` ✓ ${name} allowlisted`) @@ -121,7 +112,6 @@ async function main(): Promise { const c: Connected = connect() const { - governance, manager, treasury, custodian, @@ -162,20 +152,13 @@ async function main(): Promise { await ensureAllowlist(c, key, name) } - // Ensure vault custodian is configured const currentCustodian = (await reader.custodian()).result if (!currentCustodian) { - console.log(" + setting vault custodian...") - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), + throw new Error( + "the vault has no custodian; the deploy sets it before setup closes", ) - console.log(` ✓ custodian configured: ${custodian.publicKey()}`) - } else { - console.log(` • vault custodian already configured: ${currentCustodian}`) } + console.log(` • vault custodian configured: ${currentCustodian}`) // 2. Lifecycle Seeding console.log("\nStep 2: Processing Vault Lifecycle...") @@ -191,15 +174,16 @@ async function main(): Promise { console.log( " + requesting deposits in Epoch 1 (holder-1: 5,000 USDC, holder-2: 3,000 USDC)...", ) + await freshFeed(c, price(1)) await send( vault(c, holder1).request_deposit({ - from: holder1.publicKey(), + holder: holder1.publicKey(), amount: usdc(5_000), }), ) await send( vault(c, holder2).request_deposit({ - from: holder2.publicKey(), + holder: holder2.publicKey(), amount: usdc(3_000), }), ) @@ -215,19 +199,20 @@ async function main(): Promise { console.log(" ✓ NAV attested on oracle") console.log(" + fulfilling Epoch 1...") + await waitForNotice(c, reader, 1n) await send(vault(c, outsider).fulfill_epoch({ epoch_id: 1n })) console.log(" ✓ Epoch 1 fulfilled") console.log(" + claiming shares for holder-1 and holder-2...") await send( vault(c, holder1).claim_deposit({ - caller: holder1.publicKey(), + holder: holder1.publicKey(), epoch_id: 1n, }), ) await send( vault(c, holder2).claim_deposit({ - caller: holder2.publicKey(), + holder: holder2.publicKey(), epoch_id: 1n, }), ) @@ -274,14 +259,14 @@ async function main(): Promise { const h1RedeemReq = ( await reader.get_redeem_request({ epoch_id: currentEpoch, - controller: holder1.publicKey(), + holder: holder1.publicKey(), }) ).result if (!h1RedeemReq && h1Shares >= usdc(1_000)) { console.log(" + holder-1 requesting redemption of 1,000 shares...") await send( vault(c, holder1).request_redeem({ - from: holder1.publicKey(), + holder: holder1.publicKey(), shares: usdc(1_000), }), ) @@ -295,14 +280,15 @@ async function main(): Promise { const h3DepositReq = ( await reader.get_deposit_request({ epoch_id: currentEpoch, - controller: holder3.publicKey(), + holder: holder3.publicKey(), }) ).result if (!h3DepositReq) { console.log(" + holder-3 requesting deposit of 2,500 USDC...") + await freshFeed(c, price(1)) await send( vault(c, holder3).request_deposit({ - from: holder3.publicKey(), + holder: holder3.publicKey(), amount: usdc(2_500), }), ) @@ -354,20 +340,16 @@ async function main(): Promise { const pub = keypair.publicKey() const usdcBal = await usdcBalance(c, pub) const shares = (await tokenReader.balance({ account: pub })).result - accountsSummary.push({ - Role: role, - PublicKey: pub, - SecretKey: keypair.secret(), - "USDC Balance": formatAmount(usdcBal, decimals), - "Shares Balance": formatAmount(shares, decimals), - }) + accountsSummary.push( + accountRow( + role, + keypair, + formatAmount(usdcBal, decimals), + formatAmount(shares, decimals), + ), + ) } - console.table(accountsSummary, [ - "Role", - "PublicKey", - "USDC Balance", - "Shares Balance", - ]) + console.table(accountsSummary) console.log("\n--- ACTIVE REQUESTS IN EPOCH #" + finalEpoch + " ---") const activeRequests = [] @@ -379,13 +361,13 @@ async function main(): Promise { const depReq = ( await reader.get_deposit_request({ epoch_id: finalEpoch, - controller: key.publicKey(), + holder: key.publicKey(), }) ).result const redReq = ( await reader.get_redeem_request({ epoch_id: finalEpoch, - controller: key.publicKey(), + holder: key.publicKey(), }) ).result if (depReq) { diff --git a/scripts/harness/tsconfig.json b/scripts/harness/tsconfig.json index eaef5fec..6892162e 100644 --- a/scripts/harness/tsconfig.json +++ b/scripts/harness/tsconfig.json @@ -15,6 +15,7 @@ }, "include": [ "deploy.ts", + "extend.ts", "keys.ts", "build-clients.ts", "seed.ts", diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts new file mode 100644 index 00000000..7675bf6c --- /dev/null +++ b/scripts/harness/wind-down.test.ts @@ -0,0 +1,404 @@ +import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { beforeAll, describe, expect, it } from "vitest" + +import { SHORT_WIND_DOWN_DIR, deployThrowaway, wasmOf } from "./deploy.js" +import { pay, trustline } from "./lib/accounts.js" +import { units } from "./lib/amounts.js" +import { + connectTo, + identityVerifier, + send, + shareToken, + usdcBalance, + vault, + type Connected, +} from "./lib/clients.js" +import { network } from "./lib/network.js" +import { waitForNotice } from "./lib/notice.js" +import { attest, freshFeed } from "./lib/oracle.js" +import { outcome } from "./lib/refused.js" + +const net = network() +if (net.name !== "local") { + throw new Error( + "the wind-down run needs the local network: it deploys a vault built without the wind-down minimum", + ) +} + +let c: Connected +let decimals = 7 +const usdc = (whole: number) => units(whole, decimals) +const called = new Set() + +type Signer = + "governance" | "manager" | "guardian" | "treasury" | "holder-1" | "outsider" + +type VaultClient = ReturnType +type VaultMethod = { + [K in keyof VaultClient]-?: VaultClient[K] extends ( + ...args: never[] + ) => Promise> + ? K + : never +}[keyof VaultClient] +type ArgsOf = Parameters +type NoArgMethod = { + [M in VaultMethod]: ArgsOf extends [options?: unknown] ? M : never +}[VaultMethod] + +async function simulate( + name: M, + as: Signer, + args: ArgsOf, +): Promise<{ tx: AssembledTransaction; result: string }> { + const client = vault(c, c.keys[as]) + const method = client[name] as ( + ...a: ArgsOf + ) => Promise> + const tx = await method.call(client, ...args) + called.add(name) + return { tx, result: outcome(tx, client) } +} + +async function call( + name: M, + as: Signer, + ...args: ArgsOf +): Promise { + return (await simulate(name, as, args)).result +} + +// Sends the call too, once the simulation says the contract would accept it. +async function submit( + name: M, + as: Signer, + ...args: ArgsOf +): Promise { + const { tx, result } = await simulate(name, as, args) + if (result === "accepted") await send(Promise.resolve(tx)) + return result +} + +beforeAll(async () => { + const { deployment, keys } = await deployThrowaway( + net, + wasmOf("async_vault", SHORT_WIND_DOWN_DIR), + ) + c = connectTo(net, deployment, keys) + decimals = (await shareToken(c, c.keys.outsider).decimals()).result + const holder = c.keys["holder-1"] + await trustline(c.net, c.server, holder, c.asset) + await trustline(c.net, c.server, c.keys.custodian, c.asset) + await pay(c.net, c.server, c.keys.issuer, holder.publicKey(), c.asset, "1000") + await send( + identityVerifier(c, c.keys.compliance).allow({ + account: holder.publicKey(), + allowed: true, + }), + ) +}) + +describe("a wind-down on a throwaway vault", () => { + const holder = () => c.keys["holder-1"].publicKey() + + it("a holder subscribes, is priced and claims shares", async () => { + await freshFeed(c, 2n * 10n ** 18n) + expect( + await submit("request_deposit", "holder-1", { + holder: holder(), + amount: usdc(200), + }), + ).toBe("accepted") + expect( + await submit("close_epoch", "manager", { + caller: c.keys.manager.publicKey(), + }), + ).toBe("accepted") + await attest(c, 2n * 10n ** 18n) + await waitForNotice(c, vault(c, c.keys.outsider), 1n) + expect(await submit("fulfill_epoch", "outsider", { epoch_id: 1n })).toBe( + "accepted", + ) + expect( + await submit("claim_deposit", "holder-1", { + holder: holder(), + epoch_id: 1n, + }), + ).toBe("accepted") + }) + + it("the holder asks to redeem part of it", async () => { + expect( + await submit("request_redeem", "holder-1", { + holder: holder(), + shares: usdc(10), + }), + ).toBe("accepted") + }) + + it("governance proposes the wind-down and anyone activates it", async () => { + expect(await submit("propose_wind_down", "governance")).toBe("accepted") + expect( + await call("request_deposit", "holder-1", { + holder: holder(), + amount: usdc(1), + }), + ).toBe("refused: WindDownAlreadyProposed") + expect(await submit("activate_wind_down", "outsider")).toBe("accepted") + }) + + it("anyone finalises a round and the holder claims", async () => { + expect(await submit("finalize_wind_down_round", "outsider")).toBe( + "accepted", + ) + + const reader = vault(c, c.keys.outsider) + const owedBefore = (await reader.wind_down_owed()).result + const claimableBefore = ( + await reader.wind_down_claimable({ holder: holder() }) + ).result + const balanceBefore = await usdcBalance(c, holder()) + + called.add("claim_wind_down") + const paid = await send( + vault(c, c.keys["holder-1"]).claim_wind_down({ holder: holder() }), + ) + + expect(paid).toBeGreaterThan(0n) + expect(paid).toBe(claimableBefore) + expect(await usdcBalance(c, holder())).toBe(balanceBefore + paid) + expect((await reader.wind_down_owed()).result).toBe(owedBefore - paid) + }) + + it("reads the economic supply after the claim", async () => { + expect(await call("total_economic_supply", "outsider")).toBe("accepted") + }) + + it("every other vault function answers through its binding", async () => { + const gov = c.keys.governance.publicKey() + const someone = c.keys.outsider.publicKey() + const views: readonly NoArgMethod[] = [ + "asset", + "share_token", + "oracle", + "governance", + "manager", + "treasury", + "guardian", + "custodian", + "net_deployed", + "free_reserve", + "committed", + "uncovered", + "liquid_reserve", + "cancellable_escrow", + "pending_mint_shares", + "pending_burn_shares", + "deposit_cap", + "wind_down_delay", + "wind_down", + "wind_down_owed", + "wind_down_supply", + "wind_down_distributed", + "notice", + "current_epoch", + "epoch_opened_at", + "min_epoch_duration", + "upgrade_delay", + "schema_version", + "pending_changes", + "setup_open", + "health", + "timelock_clock", + "get_admin", + "get_existing_roles", + "paused", + ] + for (const name of views) + expect({ [name]: await call(name, "outsider") }).toEqual({ + [name]: "accepted", + }) + expect( + await call("wind_down_claimable", "outsider", { holder: holder() }), + "wind_down_claimable", + ).toBe("accepted") + expect( + await call("get_epoch", "outsider", { epoch_id: 1n }), + "get_epoch", + ).toBe("accepted") + expect( + await call("get_epoch_settlement", "outsider", { epoch_id: 1n }), + "get_epoch_settlement", + ).toBe("accepted") + expect( + await call("get_deposit_request", "outsider", { + epoch_id: 1n, + holder: holder(), + }), + "get_deposit_request", + ).toBe("accepted") + expect( + await call("get_redeem_request", "outsider", { + epoch_id: 2n, + holder: holder(), + }), + "get_redeem_request", + ).toBe("accepted") + expect( + await call("has_role", "outsider", { account: gov, role: "manager" }), + "has_role", + ).toBe("accepted") + expect( + await call("get_role_member_count", "outsider", { role: "manager" }), + "get_role_member_count", + ).toBe("accepted") + expect( + await call("get_role_member", "outsider", { + role: "manager", + index: 0, + }), + "get_role_member", + ).toBe("accepted") + expect( + await call("get_role_admin", "outsider", { role: "manager" }), + "get_role_admin", + ).toBe("accepted") + + // Simulated, never sent — each still reaches the contract for its + // wind-down answer. + expect( + await call("set_deposit_cap", "governance", { cap: 1n }), + "set_deposit_cap", + ).toBe("accepted") + expect( + await call("set_custodian", "governance", { custodian: someone }), + "set_custodian", + ).toBe("refused: UseTimelock") + expect( + await call("set_wind_down_delay", "governance", { secs: 1n }), + "set_wind_down_delay", + ).toBe("accepted") + expect( + await call("cancel_wind_down_proposal", "governance"), + "cancel_wind_down_proposal", + ).toBe("refused: WindDownActive") + expect( + await call("set_notice", "governance", { secs: 1n }), + "set_notice", + ).toBe("refused: UseTimelock") + expect( + await call("set_min_epoch_duration", "governance", { secs: 86_400n }), + "set_min_epoch_duration", + ).toBe("refused: UseTimelock") + expect(await call("finish_setup", "governance"), "finish_setup").toBe( + "refused: SetupClosed", + ) + expect( + await call("propose_change", "governance", { + change: { tag: "Custodian", values: [someone] }, + }), + "propose_change", + ).toBe("refused: WindDownActive") + expect( + await call("cancel_change", "governance", { id: 1 }), + "cancel_change", + ).toBe("refused: ChangeNotFound") + expect( + await call("apply_change", "governance", { id: 1 }), + "apply_change", + ).toBe("refused: ChangeNotFound") + expect( + await call("deploy_to_custodian", "treasury", { + caller: c.keys.treasury.publicKey(), + assets: 1n, + }), + "deploy_to_custodian", + ).toBe("refused: WindDownActive") + expect( + await call("fund", "holder-1", { caller: holder(), assets: 1n }), + "fund", + ).toBe("accepted") + expect( + await call("cancel_deposit", "holder-1", { + holder: holder(), + epoch_id: 1n, + }), + "cancel_deposit", + ).toBe("refused: AlreadyPriced") + expect( + await call("cancel_redeem", "holder-1", { + holder: holder(), + epoch_id: 2n, + }), + "cancel_redeem", + ).toBe("accepted") + expect( + await call("claim_redeem", "holder-1", { + holder: holder(), + epoch_id: 2n, + }), + "claim_redeem", + ).toBe("refused: EpochNotFulfilled") + expect( + await call("grant_role", "governance", { + account: someone, + role: "manager", + caller: gov, + }), + "grant_role", + ).toBe("refused: UseTimelock") + expect( + await call("revoke_role", "governance", { + account: someone, + role: "manager", + caller: gov, + }), + "revoke_role", + ).toBe("refused: UseTimelock") + expect( + await call("renounce_role", "outsider", { + role: "manager", + caller: someone, + }), + "renounce_role", + ).toBe("refused: RoleNotHeld") + expect( + await call("transfer_admin_role", "governance", { + new_admin: someone, + live_until_ledger: 1, + }), + "transfer_admin_role", + ).toBe("refused: UseTimelock") + expect( + await call("accept_admin_transfer", "outsider"), + "accept_admin_transfer", + ).toBe("refused: WindDownActive") + expect( + await call("set_role_admin", "governance", { + role: "manager", + admin_role: "guardian", + }), + "set_role_admin", + ).toBe("refused: UseTimelock") + expect(await call("renounce_admin", "governance"), "renounce_admin").toBe( + "refused: AdminRequired", + ) + expect( + await call("pause", "guardian", { + caller: c.keys.guardian.publicKey(), + }), + "pause", + ).toBe("accepted") + expect( + await call("unpause", "governance", { caller: gov }), + "unpause", + ).toBe("refused: ExpectedPause") + + const spec = vault(c, c.keys.outsider).spec + const every = spec + .funcs() + .map((f) => f.name().toString()) + .filter((name) => name !== "__constructor") + expect(every.filter((name) => !called.has(name))).toEqual([]) + }) +}) diff --git a/scripts/lib/environments.d.mts b/scripts/lib/environments.d.mts new file mode 100644 index 00000000..bd0bf9b7 --- /dev/null +++ b/scripts/lib/environments.d.mts @@ -0,0 +1,13 @@ +type Network = { rpcUrl: string; networkPassphrase: string; allowHttp: boolean } + +export function selectedEnvironment( + env: Record, +): string + +export function contractIds( + toml: string, + environment: string, + resolveAlias?: (alias: string, network: Network) => string, +): Array<{ name: string; id: string }> + +export function networkOf(toml: string, environment: string): Network diff --git a/scripts/lib/environments.mjs b/scripts/lib/environments.mjs index 75cb46dd..c2eb133c 100644 --- a/scripts/lib/environments.mjs +++ b/scripts/lib/environments.mjs @@ -1,4 +1,10 @@ -// Reads one environment's network and deployed contract ids from environments.toml. +// Reads one environment's network and contract ids from environments.toml; +// a local environment declares none, so its ids come from the CLI's aliases. +import { execFileSync } from "node:child_process" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..") export const selectedEnvironment = (env) => env.ADDRESSES_ENV ?? "staging" @@ -13,20 +19,6 @@ const section = (toml, header) => { return body } -export const contractIds = (toml, environment) => { - const body = section(toml, `${environment}.contracts`) - const ids = [...body.matchAll(/^(\w+)\s*=\s*\{[^}]*id\s*=\s*"(C\w+)"/gm)].map( - ([, name, id]) => ({ name, id }), - ) - if (ids.length === 0) { - throw new Error( - `[${environment}.contracts] declares no deployed addresses. ` + - `Deploy first, or set ADDRESSES_ENV to an environment that has them.`, - ) - } - return ids -} - const value = (body, key, environment) => { const found = body.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, "m"))?.[1] if (found === undefined) { @@ -44,3 +36,57 @@ export const networkOf = (toml, environment) => { allowHttp: rpcUrl.startsWith("http://"), } } + +const cliAlias = (alias, { rpcUrl, networkPassphrase }) => { + const args = ["contract", "alias", "show", alias] + args.push("--rpc-url", rpcUrl, "--network-passphrase", networkPassphrase) + try { + return execFileSync("stellar", args, { + cwd: repoRoot, + stdio: "pipe", + encoding: "utf8", + }).trim() + } catch (error) { + if (error.code === "ENOENT") { + throw new Error("stellar CLI not found on PATH") + } + throw new Error(error.stderr?.trim().split("\n").at(-1) ?? error.message) + } +} + +// The console also needs the vault's asset, a SAC with no block of its own. +const deployedAliases = (toml, environment) => { + const headers = new RegExp(`^\\[${environment}\\.contracts\\.(\\w+)\\]`, "gm") + const names = [...toml.matchAll(headers)].map(([, name]) => name) + return names.length === 0 ? [] : [...names, "asset"] +} + +export const contractIds = (toml, environment, resolveAlias = cliAlias) => { + const body = section(toml, `${environment}.contracts`) + const declared = [ + ...body.matchAll(/^(\w+)\s*=\s*\{[^}]*id\s*=\s*"(C\w+)"/gm), + ].map(([, name, id]) => ({ name, id })) + if (declared.length > 0) { + return declared + } + const deployed = deployedAliases(toml, environment) + if (deployed.length === 0) { + throw new Error( + `[${environment}.contracts] declares no deployed addresses. ` + + `Deploy first, or set ADDRESSES_ENV to an environment that has them.`, + ) + } + const network = networkOf(toml, environment) + return deployed.map((name) => { + try { + return { name, id: resolveAlias(name, network) } + } catch (error) { + throw new Error( + `Cannot resolve contract alias '${name}' for [${environment}.contracts]: ` + + `${error.message}. Register it first: the scaffold build deploys ` + + `the contract blocks, and \`stellar contract asset deploy --asset ` + + `native --alias asset\` deploys the asset.`, + ) + } + }) +} diff --git a/scripts/lib/environments.test.mjs b/scripts/lib/environments.test.mjs index 77ebd8a0..7bec2730 100644 --- a/scripts/lib/environments.test.mjs +++ b/scripts/lib/environments.test.mjs @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { contractIds, networkOf, selectedEnvironment } from "./environments.mjs" const toml = ` @@ -21,6 +21,20 @@ network-passphrase = "Local Passphrase" [development.contracts.compliance] client = true +constructor_args = "--admin me" + +[development.contracts.share_token] +client = true + +[development.contracts.async_vault] +client = true +constructor_args = "--asset share_token" + +[testing.network] +rpc-url = "http://localhost:8000/rpc" +network-passphrase = "Local Passphrase" + +[testing.contracts] ` describe("selectedEnvironment", () => { @@ -49,9 +63,36 @@ describe("contractIds", () => { ) }) - it("fails when the section declares no deployed ids", () => { - expect(() => contractIds(toml, "development")).toThrow( - /\[development\.contracts\] declares no deployed addresses/, + it("fails when the section declares no ids and no contracts to deploy", () => { + expect(() => contractIds(toml, "testing")).toThrow( + /\[testing\.contracts\] declares no deployed addresses/, + ) + }) + + it("resolves scaffold-deployed blocks plus the asset through aliases", () => { + const resolveAlias = vi.fn((alias) => `C${alias.toUpperCase()}`) + expect(contractIds(toml, "development", resolveAlias)).toEqual([ + { name: "compliance", id: "CCOMPLIANCE" }, + { name: "share_token", id: "CSHARE_TOKEN" }, + { name: "async_vault", id: "CASYNC_VAULT" }, + { name: "asset", id: "CASSET" }, + ]) + expect(resolveAlias).toHaveBeenCalledWith("compliance", { + rpcUrl: "http://localhost:8000/rpc", + networkPassphrase: "Local Passphrase", + allowHttp: true, + }) + }) + + it("names the alias and the environment when one cannot be resolved", () => { + const resolveAlias = (alias) => { + if (alias === "asset") { + throw new Error("error: no contract found with alias 'asset'") + } + return "CAAA" + } + expect(() => contractIds(toml, "development", resolveAlias)).toThrow( + /alias 'asset' for \[development\.contracts\].*no contract found with alias 'asset'/, ) }) }) diff --git a/scripts/lib/tool-pins.test.mjs b/scripts/lib/tool-pins.test.mjs new file mode 100644 index 00000000..35feb48a --- /dev/null +++ b/scripts/lib/tool-pins.test.mjs @@ -0,0 +1,92 @@ +import { readFileSync } from "node:fs" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { describe, expect, it } from "vitest" + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..") +const read = (path) => readFileSync(resolve(root, path), "utf8") + +const occurrences = (text, substring) => text.split(substring).length - 1 + +const pins = Object.fromEntries( + [ + ...read("scripts/ci/install-tools.sh").matchAll(/^([A-Z0-9_]+)=(\S+)$/gm), + ].map(([, name, value]) => [name, value]), +) + +describe("the installer's pins", () => { + it("CI installs its tools through the checked installer", () => { + const workflow = read(".github/workflows/build.yml") + + expect(workflow).not.toMatch(/binstall/) + expect(workflow).toContain( + "scripts/ci/install-tools.sh stellar stellar-scaffold", + ) + expect(workflow).toContain("scripts/ci/install-tools.sh gitleaks") + }) + + it("the README runs the quickstart image CI runs", () => { + const image = read(".github/workflows/build.yml").match( + /IMAGE:\s*(\S+)/, + )?.[1] + + expect(image).toMatch(/@sha256:[0-9a-f]{64}$/) + expect(read("README.md")).toContain(image) + }) + + it("the README installs the stellar CLI version CI installs", () => { + const readme = read("README.md") + + expect(readme).toContain(`stellar-cli@${pins.STELLAR_CLI_VERSION}`) + expect(readme).not.toMatch(/brew install/) + }) + + it("the dev guard suggests the CLI versions CI installs", () => { + const devGuard = read("scripts/dev-guard.mjs") + + expect( + occurrences(devGuard, `stellar-cli@${pins.STELLAR_CLI_VERSION}"`), + ).toBe(2) + expect( + occurrences( + devGuard, + `stellar-scaffold-cli@${pins.STELLAR_SCAFFOLD_VERSION}"`, + ), + ).toBe(2) + }) + + it("the commit hook runs the gitleaks version CI runs", () => { + const rev = read(".pre-commit-config.yaml").match( + /repo: https:\/\/github\.com\/gitleaks\/gitleaks\s+rev: (\S+)/, + )?.[1] + + expect(rev).toBe(`v${pins.GITLEAKS_VERSION}`) + }) + + it("the devshell's stellar CLI is the tarball CI checks", () => { + const flake = read("flake.nix") + + expect(flake).toContain(`cliVersion = "${pins.STELLAR_CLI_VERSION}"`) + + const hash = flake.match( + /x86_64-linux = \{\s*target = "x86_64-unknown-linux-gnu";\s*hash = "sha256-([^"]+)"/, + )?.[1] + + expect(Buffer.from(hash ?? "", "base64").toString("hex")).toBe( + pins.STELLAR_CLI_SHA256, + ) + }) + + it("the root package pins its tools and overrides exactly", () => { + const pkg = JSON.parse(read("package.json")) + const versions = [ + ...Object.values(pkg.devDependencies), + ...Object.values(pkg.overrides), + ] + + for (const version of versions) { + expect(version).toMatch(/^\d+\.\d+\.\d+$/) + } + }) +})
    {row.when}