From f85ef0e51b0c44a667d45f9318a87804f0b02288 Mon Sep 17 00:00:00 2001 From: knytcomics-ui Date: Thu, 22 Oct 2026 09:08:39 +0000 Subject: [PATCH] feat: arbiter panel, directory, oracle wiring, client-computable intent ids Closes #565 Closes #555 Closes #554 Closes #549 - intent_settlement: timestamp-free compute_intent_id, get_user_nonce, preview_intent_id (#565) - arbiter_panel contract; set_arbiter_panel + dispute_parties; resolve_dispute gated by configured arbiter; design doc corrected (#555) - directory address-book contract + design note (#554) - declare mod oracle; weight cross-token exposure by oracle price with fail-closed fallback (#549) --- CHANGELOG.md | 21 + Cargo.lock | 303 +++---- arbiter_panel/Cargo.toml | 26 + arbiter_panel/src/lib.rs | 412 ++++++++++ arbiter_panel/src/test.rs | 186 +++++ directory/Cargo.toml | 26 + directory/src/lib.rs | 160 ++++ directory/src/test.rs | 70 ++ docs/CLIENT_COMPUTABLE_INTENT_IDS.md | 10 + docs/directory-design.md | 23 + docs/dispute-resolution-design.md | 11 +- intent_settlement/src/lib.rs | 1128 +++++++++++++++++++++++++- intent_settlement/src/oracle.rs | 42 +- intent_settlement/src/test.rs | 43 + intent_settlement/src/test_oracle.rs | 77 ++ 15 files changed, 2366 insertions(+), 172 deletions(-) create mode 100644 arbiter_panel/Cargo.toml create mode 100644 arbiter_panel/src/lib.rs create mode 100644 arbiter_panel/src/test.rs create mode 100644 directory/Cargo.toml create mode 100644 directory/src/lib.rs create mode 100644 directory/src/test.rs create mode 100644 docs/directory-design.md create mode 100644 intent_settlement/src/test_oracle.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fd00c35..eb5c1c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ first deploys to mainnet. ### Added +- **`arbiter_panel` contract (issue #555).** m-of-n bonded arbiter panel + (register/deregister with bond, PRNG panel selection, `open_vote`/`vote`/`tally`, + slashing of dissenting/absent arbiters, `VOTE_WINDOW` timeout). `intent_settlement` + gains `set_arbiter_panel` (admin) and `dispute_parties`; `resolve_dispute` is + gated by the configured arbiter (the panel once set). `docs/dispute-resolution-design.md` + now states that the legacy admin-arbiter model applies until `set_arbiter_panel` is called. +- **`directory` contract (issue #554).** On-chain address book for canonical protocol + addresses; see `docs/directory-design.md`. +- **Oracle wiring (issue #549).** `intent_settlement` now compiles `oracle.rs`; cross-token + exposure in `accept_intent`/`fill_intent`/`slash_solver` is weighted by oracle prices, + falling back to a conservative 1.0× on stale/missing/zero prices or no oracle. + `OracleAdapterClient` calls use `try_` so a faulty oracle never blocks slashing. + +### Changed + +- **Intent id derivation (issue #565, ABI/storage-relevant).** `compute_intent_id` no longer + hashes the ledger timestamp; ids are now + `sha256("vortex-intent-v1" || contract_id || network_id || user || nonce || params_hash)` + so clients can precompute them. Ids differ from those produced by earlier builds. New views: + `get_user_nonce`, `preview_intent_id`. + - **CI: Enforce CHANGELOG.md updates (issue #294).** A new CI job verifies that every PR changing `intent_settlement/src/` or `proof_registry/src/` includes a corresponding update to `CHANGELOG.md`. An escape hatch (`no-changelog-needed` diff --git a/Cargo.lock b/Cargo.lock index 583bfdb..c2ed128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,6 +86,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -107,15 +122,6 @@ 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" @@ -192,15 +198,6 @@ 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" @@ -234,16 +231,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", - "rand_core 0.10.1", -] - [[package]] name = "ctor" version = "0.2.9" @@ -261,27 +248,10 @@ 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", + "cpufeatures", "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", - "rand_core 0.10.1", + "digest", + "fiat-crypto", "rustc_version", "subtle", "zeroize", @@ -434,22 +404,12 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", + "block-buffer", "const-oid", - "crypto-common 0.1.6", + "crypto-common", "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" @@ -469,10 +429,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest 0.10.7", + "digest", "elliptic-curve", "rfc6979", - "signature 2.2.0", + "signature", ] [[package]] @@ -482,16 +442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", - "signature 2.2.0", -] - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "signature 3.0.0", + "signature", ] [[package]] @@ -500,26 +451,11 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", + "curve25519-dalek", + "ed25519", "rand_core 0.6.4", "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" -dependencies = [ - "curve25519-dalek 5.0.0", - "ed25519 3.0.0", - "rand_core 0.10.1", - "sha2 0.11.0", - "signature 3.0.0", + "sha2", "subtle", "zeroize", ] @@ -538,7 +474,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest 0.10.7", + "digest", "ff", "generic-array", "group", @@ -554,6 +490,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -566,6 +512,19 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fee_router" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "ff" version = "0.13.1" @@ -582,12 +541,6 @@ 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.14" @@ -710,16 +663,7 @@ 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", + "digest", ] [[package]] @@ -870,7 +814,7 @@ dependencies = [ "cfg-if", "ecdsa", "elliptic-curve", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -879,7 +823,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -894,6 +838,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.34" @@ -984,7 +934,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -1073,15 +1023,25 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ + "bit-set", + "bit-vec", "bitflags 2.13.2", "num-traits", "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", + "rusty-fork", + "tempfile", "unarray", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -1156,12 +1116,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "rand_xorshift" version = "0.4.0" @@ -1222,12 +1176,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "schemars" version = "0.9.0" @@ -1354,19 +1333,8 @@ 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 = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -1375,7 +1343,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ - "digest 0.10.7", + "digest", "keccak", ] @@ -1391,19 +1359,10 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", + "digest", "rand_core 0.6.4", ] -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "slab" version = "0.4.12" @@ -1464,9 +1423,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b7a32c28f281c423189f1298960194f0e0fc4eeb72378028171e556d8cd6160" dependencies = [ "backtrace", - "curve25519-dalek 5.0.0", + "curve25519-dalek", "ecdsa", - "ed25519-dalek 3.0.0", + "ed25519-dalek", "elliptic-curve", "generic-array", "getrandom 0.2.17", @@ -1480,7 +1439,7 @@ dependencies = [ "rand 0.8.8", "rand_chacha 0.3.1", "sec1", - "sha2 0.10.9", + "sha2", "sha3", "soroban-builtin-sdk-macros", "soroban-env-common", @@ -1529,7 +1488,7 @@ dependencies = [ "bytes-lit", "ctor", "derive_arbitrary", - "ed25519-dalek 2.2.0", + "ed25519-dalek", "rand 0.8.8", "rustc_version", "serde", @@ -1553,7 +1512,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "sha2 0.10.9", + "sha2", "soroban-env-common", "soroban-spec", "soroban-spec-rust", @@ -1582,7 +1541,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "sha2 0.10.9", + "sha2", "soroban-spec", "stellar-xdr", "syn 2.0.119", @@ -1685,6 +1644,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1761,6 +1733,13 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "treasury_streams" +version = "0.1.0" +dependencies = [ + "proptest", +] + [[package]] name = "typenum" version = "1.20.1" @@ -1785,6 +1764,13 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vortex-arbiter-panel" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "vortex-common" version = "0.1.0" @@ -1792,6 +1778,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "vortex-directory" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "vortex-intent-settlement" version = "0.1.0" @@ -1822,6 +1815,15 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1978,6 +1980,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/arbiter_panel/Cargo.toml b/arbiter_panel/Cargo.toml new file mode 100644 index 0000000..228945b --- /dev/null +++ b/arbiter_panel/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "vortex-arbiter-panel" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 + +[dependencies] +soroban-sdk = { version = "21.0.0" } + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/arbiter_panel/src/lib.rs b/arbiter_panel/src/lib.rs new file mode 100644 index 0000000..f8b39ee --- /dev/null +++ b/arbiter_panel/src/lib.rs @@ -0,0 +1,412 @@ +#![no_std] + +//! Vortex Protocol — m-of-n bonded arbiter panel (issues #407 / #555). +//! +//! Implements the specification in `docs/dispute-resolution-design.md` +//! ("Arbiter panel"): bonded arbiter registration, per-dispute panel +//! selection via `env.prng()`, public votes, m-of-n tally, bond slashing for +//! dissenting / absent arbiters, and the `VOTE_WINDOW` timeout fallback. +//! +//! `intent_settlement::resolve_dispute` accepts a resolution only from this +//! contract's address once configured via `set_arbiter_panel`. + +use soroban_sdk::{ + contract, contractclient, contracterror, contractimpl, contracttype, panic_with_error, token, + Address, Bytes, BytesN, Env, Map, Symbol, Vec, +}; + +#[cfg(test)] +mod test; + +/// Voting window after `open_vote` (24 h). +pub const VOTE_WINDOW: u64 = 86_400; +/// Settlement's resolution window after a dispute is raised (24 h); mirrors +/// `intent_settlement::ARBITER_WINDOW`. +pub const ARBITER_WINDOW: u64 = 86_400; +/// Share of an arbiter's bond slashed when voting against the outcome or not +/// voting at all, in basis points (10%). +pub const SLASH_BPS: i128 = 1_000; + +const TTL_THRESHOLD: u32 = 17_280 * 30; +const TTL_EXTEND_TO: u32 = 17_280 * 90; + +/// Mirror of `intent_settlement::DisputeResolution` (same XDR encoding). +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisputeResolution { + Upheld, + Dismissed, +} + +/// Subset of the settlement contract the panel calls. +#[contractclient(name = "SettlementClient")] +pub trait SettlementTrait { + /// `(user, solver)` of an intent currently in the `Disputed` state. + fn dispute_parties(env: Env, intent_id: BytesN<32>) -> (Address, Address); + fn resolve_dispute( + env: Env, + arbiter: Address, + intent_id: BytesN<32>, + resolution: DisputeResolution, + ); +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + Settlement, + BondToken, + BondAmount, + /// Configured panel size `n`. + PanelSize, + /// Configured threshold `m`. + Threshold, + /// All registered arbiters. + Arbiters, + Arbiter(Address), + Round(BytesN<32>), +} + +#[contracttype] +#[derive(Clone)] +pub struct ArbiterRecord { + pub bond: i128, + /// Number of unsettled rounds this arbiter was selected for. + pub open_votes: u32, +} + +#[contracttype] +#[derive(Clone)] +pub struct Round { + pub panel: Vec
, + /// Effective threshold after scaling for the actual panel size. + pub threshold: u32, + pub opened_at: u64, + pub votes: Map, + pub settled: bool, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + InvalidConfig = 3, + AlreadyRegistered = 4, + NotRegistered = 5, + HasOpenVotes = 6, + RoundExists = 7, + RoundNotFound = 8, + NoEligibleArbiters = 9, + NotOnPanel = 10, + AlreadyVoted = 11, + VotingClosed = 12, + RoundSettled = 13, + QuorumNotReached = 14, +} + +#[contract] +pub struct ArbiterPanel; + +#[contractimpl] +impl ArbiterPanel { + /// `threshold` (m) of `panel_size` (n) votes decide a dispute; `2m > n` + /// is required so two conflicting outcomes cannot both reach quorum. + pub fn initialize( + env: Env, + admin: Address, + settlement: Address, + bond_token: Address, + bond_amount: i128, + panel_size: u32, + threshold: u32, + ) { + if env.storage().instance().has(&DataKey::Admin) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + if bond_amount <= 0 || threshold == 0 || threshold > panel_size || threshold * 2 <= panel_size + { + panic_with_error!(&env, Error::InvalidConfig); + } + let s = env.storage().instance(); + s.set(&DataKey::Admin, &admin); + s.set(&DataKey::Settlement, &settlement); + s.set(&DataKey::BondToken, &bond_token); + s.set(&DataKey::BondAmount, &bond_amount); + s.set(&DataKey::PanelSize, &panel_size); + s.set(&DataKey::Threshold, &threshold); + Self::bump_instance(&env); + } + + /// Stake the bond and join the panel. + pub fn register_arbiter(env: Env, arbiter: Address) { + arbiter.require_auth(); + Self::bump_instance(&env); + if env.storage().persistent().has(&DataKey::Arbiter(arbiter.clone())) { + panic_with_error!(&env, Error::AlreadyRegistered); + } + let bond: i128 = Self::get(&env, &DataKey::BondAmount); + token::Client::new(&env, &Self::get::
(&env, &DataKey::BondToken)).transfer( + &arbiter, + &env.current_contract_address(), + &bond, + ); + Self::save_arbiter(&env, &arbiter, &ArbiterRecord { bond, open_votes: 0 }); + let mut list = Self::arbiters(&env); + list.push_back(arbiter.clone()); + env.storage().instance().set(&DataKey::Arbiters, &list); + env.events().publish((Symbol::new(&env, "arb_reg"),), arbiter); + } + + /// Leave the panel and reclaim the remaining bond. Blocked while the + /// arbiter has unsettled votes. + pub fn deregister_arbiter(env: Env, arbiter: Address) { + arbiter.require_auth(); + Self::bump_instance(&env); + let rec = Self::load_arbiter(&env, &arbiter); + if rec.open_votes > 0 { + panic_with_error!(&env, Error::HasOpenVotes); + } + env.storage().persistent().remove(&DataKey::Arbiter(arbiter.clone())); + let mut kept = Vec::new(&env); + for a in Self::arbiters(&env).iter() { + if a != arbiter { + kept.push_back(a); + } + } + env.storage().instance().set(&DataKey::Arbiters, &kept); + if rec.bond > 0 { + token::Client::new(&env, &Self::get::
(&env, &DataKey::BondToken)).transfer( + &env.current_contract_address(), + &arbiter, + &rec.bond, + ); + } + env.events().publish((Symbol::new(&env, "arb_dereg"),), arbiter); + } + + /// Select the panel for a disputed intent and open the voting window. + /// Permissionless. The intent's user and solver are excluded; the panel + /// is `min(n, eligible)` arbiters and the threshold is scaled up-rounded + /// proportionally (never below 1). + pub fn open_vote(env: Env, intent_id: BytesN<32>) -> Vec
{ + Self::bump_instance(&env); + let key = DataKey::Round(intent_id.clone()); + if env.storage().persistent().has(&key) { + panic_with_error!(&env, Error::RoundExists); + } + let settlement = SettlementClient::new(&env, &Self::get(&env, &DataKey::Settlement)); + let (user, solver) = settlement.dispute_parties(&intent_id); + + let bond_amount: i128 = Self::get(&env, &DataKey::BondAmount); + let mut eligible: Vec
= Vec::new(&env); + for a in Self::arbiters(&env).iter() { + if a != user && a != solver && Self::load_arbiter(&env, &a).bond >= bond_amount { + eligible.push_back(a); + } + } + if eligible.is_empty() { + panic_with_error!(&env, Error::NoEligibleArbiters); + } + + let n: u32 = Self::get(&env, &DataKey::PanelSize); + let m: u32 = Self::get(&env, &DataKey::Threshold); + let k = n.min(eligible.len()); + + // Partial Fisher-Yates driven by the ledger PRNG seeded with the + // intent id. Random-ish only (see design doc: PRNG limitation). + let prng = env.prng(); + prng.seed(Bytes::from_array(&env, &intent_id.to_array())); + let mut panel = Vec::new(&env); + for i in 0..k { + let j = prng.gen_range::(i as u64..=(eligible.len() - 1) as u64) as u32; + let (a, b) = (eligible.get_unchecked(i), eligible.get_unchecked(j)); + eligible.set(i, b.clone()); + eligible.set(j, a); + panel.push_back(b.clone()); + let mut rec = Self::load_arbiter(&env, &b); + rec.open_votes += 1; + Self::save_arbiter(&env, &b, &rec); + } + + let threshold = ((m * k + n - 1) / n).max(1); + let round = Round { + panel: panel.clone(), + threshold, + opened_at: env.ledger().timestamp(), + votes: Map::new(&env), + settled: false, + }; + Self::save_round(&env, &intent_id, &round); + env.events() + .publish((Symbol::new(&env, "vote_open"), intent_id), panel.clone()); + panel + } + + /// Record a public vote. Each selected arbiter votes once, within + /// `VOTE_WINDOW`. + pub fn vote(env: Env, arbiter: Address, intent_id: BytesN<32>, resolution: DisputeResolution) { + arbiter.require_auth(); + Self::bump_instance(&env); + let mut round = Self::load_round(&env, &intent_id); + if round.settled { + panic_with_error!(&env, Error::RoundSettled); + } + if env.ledger().timestamp() >= round.opened_at + VOTE_WINDOW { + panic_with_error!(&env, Error::VotingClosed); + } + if !round.panel.contains(&arbiter) { + panic_with_error!(&env, Error::NotOnPanel); + } + if round.votes.contains_key(arbiter.clone()) { + panic_with_error!(&env, Error::AlreadyVoted); + } + round.votes.set(arbiter.clone(), resolution); + Self::save_round(&env, &intent_id, &round); + env.events() + .publish((Symbol::new(&env, "voted"), intent_id, arbiter), resolution); + } + + /// Count votes. When `m` agree the panel resolves the dispute on the + /// settlement contract, slashes dissenting / absent arbiters and returns + /// the outcome. If the window elapsed without quorum the round is closed + /// without slashing (so arbiters can deregister) and `None` is returned — + /// settlement's `ARBITER_WINDOW` timeout rule then applies. + pub fn tally(env: Env, intent_id: BytesN<32>) -> Option { + Self::bump_instance(&env); + let mut round = Self::load_round(&env, &intent_id); + if round.settled { + panic_with_error!(&env, Error::RoundSettled); + } + let (mut upheld, mut dismissed) = (0u32, 0u32); + for (_, v) in round.votes.iter() { + match v { + DisputeResolution::Upheld => upheld += 1, + DisputeResolution::Dismissed => dismissed += 1, + } + } + let outcome = if upheld >= round.threshold { + Some(DisputeResolution::Upheld) + } else if dismissed >= round.threshold { + Some(DisputeResolution::Dismissed) + } else { + None + }; + if outcome.is_none() && env.ledger().timestamp() < round.opened_at + VOTE_WINDOW { + panic_with_error!(&env, Error::QuorumNotReached); + } + + round.settled = true; + Self::save_round(&env, &intent_id, &round); + + // Release selection locks; slash only when an outcome was reached. + let mut winners: Vec
= Vec::new(&env); + let mut slashed_total: i128 = 0; + for a in round.panel.iter() { + let mut rec = Self::load_arbiter(&env, &a); + rec.open_votes = rec.open_votes.saturating_sub(1); + if let Some(o) = outcome { + if round.votes.get(a.clone()) == Some(o) { + winners.push_back(a.clone()); + } else { + let cut = rec.bond * SLASH_BPS / 10_000; + rec.bond -= cut; + slashed_total += cut; + } + } + Self::save_arbiter(&env, &a, &rec); + } + // Slashed bond is shared equally by the majority; dust goes to the first. + if !winners.is_empty() && slashed_total > 0 { + let share = slashed_total / winners.len() as i128; + let dust = slashed_total - share * winners.len() as i128; + for (i, a) in winners.iter().enumerate() { + let mut rec = Self::load_arbiter(&env, &a); + rec.bond += share + if i == 0 { dust } else { 0 }; + Self::save_arbiter(&env, &a, &rec); + } + } + + match outcome { + Some(o) => { + SettlementClient::new(&env, &Self::get(&env, &DataKey::Settlement)) + .resolve_dispute(&env.current_contract_address(), &intent_id, &o); + env.events().publish((Symbol::new(&env, "tallied"), intent_id), o); + } + None => env + .events() + .publish((Symbol::new(&env, "vote_expired"),), intent_id), + } + outcome + } + + pub fn get_round(env: Env, intent_id: BytesN<32>) -> Option { + env.storage().persistent().get(&DataKey::Round(intent_id)) + } + + pub fn get_arbiter(env: Env, arbiter: Address) -> Option { + env.storage().persistent().get(&DataKey::Arbiter(arbiter)) + } + + pub fn list_arbiters(env: Env) -> Vec
{ + Self::arbiters(&env) + } + + fn get>(env: &Env, key: &DataKey) -> T { + env.storage() + .instance() + .get(key) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) + } + + fn arbiters(env: &Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::Arbiters) + .unwrap_or_else(|| Vec::new(env)) + } + + fn load_arbiter(env: &Env, a: &Address) -> ArbiterRecord { + env.storage() + .persistent() + .get(&DataKey::Arbiter(a.clone())) + .unwrap_or_else(|| panic_with_error!(env, Error::NotRegistered)) + } + + fn save_arbiter(env: &Env, a: &Address, rec: &ArbiterRecord) { + let key = DataKey::Arbiter(a.clone()); + env.storage().persistent().set(&key, rec); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + fn load_round(env: &Env, id: &BytesN<32>) -> Round { + let key = DataKey::Round(id.clone()); + let r = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, Error::RoundNotFound)); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + r + } + + fn save_round(env: &Env, id: &BytesN<32>, r: &Round) { + let key = DataKey::Round(id.clone()); + env.storage().persistent().set(&key, r); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + } +} diff --git a/arbiter_panel/src/test.rs b/arbiter_panel/src/test.rs new file mode 100644 index 0000000..dec1d62 --- /dev/null +++ b/arbiter_panel/src/test.rs @@ -0,0 +1,186 @@ +extern crate std; + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + vec, +}; + +const BOND: i128 = 1_000; + +// Minimal settlement stand-in recording the resolution it receives. +#[contract] +struct MockSettlement; + +#[contracttype] +enum MockKey { + Parties, + Resolved, + Caller, +} + +#[contractimpl] +impl MockSettlement { + pub fn set_parties(env: Env, user: Address, solver: Address) { + env.storage().instance().set(&MockKey::Parties, &(user, solver)); + } + pub fn dispute_parties(env: Env, _id: BytesN<32>) -> (Address, Address) { + env.storage().instance().get(&MockKey::Parties).unwrap() + } + pub fn resolve_dispute(env: Env, arbiter: Address, _id: BytesN<32>, r: DisputeResolution) { + arbiter.require_auth(); + env.storage().instance().set(&MockKey::Resolved, &r); + env.storage().instance().set(&MockKey::Caller, &arbiter); + } + pub fn resolved(env: Env) -> Option { + env.storage().instance().get(&MockKey::Resolved) + } + pub fn caller(env: Env) -> Option
{ + env.storage().instance().get(&MockKey::Caller) + } +} + +struct Ctx { + env: Env, + panel: ArbiterPanelClient<'static>, + settle: MockSettlementClient<'static>, + token: token::Client<'static>, + arbiters: std::vec::Vec
, + id: BytesN<32>, +} + +fn setup(n_arbiters: usize, n: u32, m: u32) -> Ctx { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + let admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let settle_id = env.register_contract(None, MockSettlement); + let panel_id = env.register_contract(None, ArbiterPanel); + let panel = ArbiterPanelClient::new(&env, &panel_id); + panel.initialize(&admin, &settle_id, &tok, &BOND, &n, &m); + let mut arbiters = std::vec::Vec::new(); + for _ in 0..n_arbiters { + let a = Address::generate(&env); + StellarAssetClient::new(&env, &tok).mint(&a, &BOND); + panel.register_arbiter(&a); + arbiters.push(a); + } + let settle = MockSettlementClient::new(&env, &settle_id); + settle.set_parties(&Address::generate(&env), &Address::generate(&env)); + Ctx { + token: token::Client::new(&env, &tok), + id: BytesN::from_array(&env, &[7; 32]), + env, + panel, + settle, + arbiters, + } +} + +#[test] +fn bonding_and_deregistration() { + let c = setup(2, 3, 2); + let a = &c.arbiters[0]; + assert_eq!(c.token.balance(a), 0); + assert_eq!(c.panel.get_arbiter(a).unwrap().bond, BOND); + assert!(c.panel.try_register_arbiter(a).is_err()); + c.panel.deregister_arbiter(a); + assert_eq!(c.token.balance(a), BOND); + assert_eq!(c.panel.list_arbiters().len(), 1); +} + +#[test] +fn invalid_config_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, ArbiterPanel); + let p = ArbiterPanelClient::new(&env, &id); + let a = Address::generate(&env); + // 2m must exceed n + assert!(p.try_initialize(&a, &a, &a, &BOND, &4, &2).is_err()); + assert!(p.try_initialize(&a, &a, &a, &0, &3, &2).is_err()); +} + +#[test] +fn majority_upholds_and_slashes_dissent_and_absent() { + let c = setup(3, 3, 2); + let panel = c.panel.open_vote(&c.id); + assert_eq!(panel.len(), 3); + assert_eq!(c.panel.get_round(&c.id).unwrap().threshold, 2); + assert!(c.panel.try_tally(&c.id).is_err()); // no quorum yet, window open + let (a, b, d) = (panel.get(0).unwrap(), panel.get(1).unwrap(), panel.get(2).unwrap()); + c.panel.vote(&a, &c.id, &DisputeResolution::Upheld); + c.panel.vote(&b, &c.id, &DisputeResolution::Upheld); + assert!(c.panel.try_vote(&a, &c.id, &DisputeResolution::Upheld).is_err()); + assert_eq!(c.panel.tally(&c.id), Some(DisputeResolution::Upheld)); + assert_eq!(c.settle.resolved(), Some(DisputeResolution::Upheld)); + assert_eq!(c.settle.caller(), Some(c.panel.address.clone())); + // absent arbiter loses 10%, shared by the two majority voters + assert_eq!(c.panel.get_arbiter(&d).unwrap().bond, 900); + assert_eq!(c.panel.get_arbiter(&a).unwrap().bond, 1_050); + assert_eq!(c.panel.get_arbiter(&b).unwrap().bond, 1_050); + assert_eq!(c.panel.get_arbiter(&a).unwrap().open_votes, 0); + assert!(c.panel.try_tally(&c.id).is_err()); // settled +} + +#[test] +fn dissenter_is_slashed() { + let c = setup(3, 3, 2); + let panel = c.panel.open_vote(&c.id); + let (a, b, d) = (panel.get(0).unwrap(), panel.get(1).unwrap(), panel.get(2).unwrap()); + c.panel.vote(&a, &c.id, &DisputeResolution::Dismissed); + c.panel.vote(&d, &c.id, &DisputeResolution::Upheld); + c.panel.vote(&b, &c.id, &DisputeResolution::Dismissed); + assert_eq!(c.panel.tally(&c.id), Some(DisputeResolution::Dismissed)); + assert_eq!(c.panel.get_arbiter(&d).unwrap().bond, 900); +} + +#[test] +fn user_and_solver_are_excluded() { + let c = setup(3, 3, 2); + c.settle.set_parties(&c.arbiters[0], &c.arbiters[1]); + let panel = c.panel.open_vote(&c.id); + assert_eq!(panel, vec![&c.env, c.arbiters[2].clone()]); + // threshold scaled: ceil(2*1/3) = 1 + assert_eq!(c.panel.get_round(&c.id).unwrap().threshold, 1); +} + +#[test] +fn no_eligible_arbiters_fails() { + let c = setup(1, 3, 2); + c.settle.set_parties(&c.arbiters[0], &Address::generate(&c.env)); + assert!(c.panel.try_open_vote(&c.id).is_err()); +} + +#[test] +fn round_cannot_be_opened_twice() { + let c = setup(3, 3, 2); + c.panel.open_vote(&c.id); + assert!(c.panel.try_open_vote(&c.id).is_err()); +} + +#[test] +fn non_panel_member_cannot_vote_and_deregister_blocked() { + let c = setup(3, 3, 2); + let outsider = Address::generate(&c.env); + let panel = c.panel.open_vote(&c.id); + assert!(c.panel.try_vote(&outsider, &c.id, &DisputeResolution::Upheld).is_err()); + assert!(c.panel.try_deregister_arbiter(&panel.get(0).unwrap()).is_err()); +} + +#[test] +fn timeout_without_quorum_closes_round_without_slashing() { + let c = setup(3, 3, 2); + let panel = c.panel.open_vote(&c.id); + c.panel.vote(&panel.get(0).unwrap(), &c.id, &DisputeResolution::Upheld); + c.env.ledger().set_timestamp(1_000 + VOTE_WINDOW); + assert!(c.panel.try_vote(&panel.get(1).unwrap(), &c.id, &DisputeResolution::Upheld).is_err()); + assert_eq!(c.panel.tally(&c.id), None); + assert_eq!(c.settle.resolved(), None); + for a in panel.iter() { + let r = c.panel.get_arbiter(&a).unwrap(); + assert_eq!((r.bond, r.open_votes), (BOND, 0)); + } +} diff --git a/directory/Cargo.toml b/directory/Cargo.toml new file mode 100644 index 0000000..9777d86 --- /dev/null +++ b/directory/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "vortex-directory" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 + +[dependencies] +soroban-sdk = { version = "21.0.0" } + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/directory/src/lib.rs b/directory/src/lib.rs new file mode 100644 index 0000000..f7024c2 --- /dev/null +++ b/directory/src/lib.rs @@ -0,0 +1,160 @@ +#![no_std] + +//! Vortex Protocol — on-chain address book (issues #399 / #554). +//! +//! Canonical, admin-updatable registry mapping protocol component names +//! (e.g. `intent_settlement`) to contract addresses. See +//! `docs/directory-design.md`. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, Symbol, + Vec, +}; + +#[cfg(test)] +mod test; + +const TTL_THRESHOLD: u32 = 17_280 * 30; +const TTL_EXTEND_TO: u32 = 17_280 * 90; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + PendingAdmin, + /// Ordered list of registered names. + Names, + Entry(Symbol), +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + NotFound = 3, + NoPendingAdmin = 4, +} + +#[contract] +pub struct Directory; + +#[contractimpl] +impl Directory { + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + Self::bump_instance(&env); + } + + /// Register or update the address stored under `name`. Admin-only. + pub fn set_address(env: Env, name: Symbol, addr: Address) { + Self::require_admin(&env); + Self::bump_instance(&env); + let key = DataKey::Entry(name.clone()); + if !env.storage().persistent().has(&key) { + let mut names = Self::names(&env); + names.push_back(name.clone()); + env.storage().instance().set(&DataKey::Names, &names); + } + env.storage().persistent().set(&key, &addr); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + env.events().publish((Symbol::new(&env, "addr_set"), name), addr); + } + + /// Remove the entry stored under `name`. Admin-only. + pub fn remove_address(env: Env, name: Symbol) { + Self::require_admin(&env); + Self::bump_instance(&env); + let key = DataKey::Entry(name.clone()); + if !env.storage().persistent().has(&key) { + panic_with_error!(&env, Error::NotFound); + } + env.storage().persistent().remove(&key); + let names = Self::names(&env); + let mut kept = Vec::new(&env); + for n in names.iter() { + if n != name { + kept.push_back(n); + } + } + env.storage().instance().set(&DataKey::Names, &kept); + env.events().publish((Symbol::new(&env, "addr_rm"),), name); + } + + /// Look up `name`; traps with `NotFound` if absent. + pub fn get_address(env: Env, name: Symbol) -> Address { + Self::find_address(env.clone(), name) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotFound)) + } + + /// Look up `name`, returning `None` if absent. + pub fn find_address(env: Env, name: Symbol) -> Option
{ + let key = DataKey::Entry(name); + let v: Option
= env.storage().persistent().get(&key); + if v.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + v + } + + /// All registered names, in registration order. + pub fn list_names(env: Env) -> Vec { + Self::names(&env) + } + + pub fn admin(env: Env) -> Address { + Self::load_admin(&env) + } + + /// Step 1 of two-step admin rotation. Admin-only. + pub fn propose_admin(env: Env, new_admin: Address) { + Self::require_admin(&env); + Self::bump_instance(&env); + env.storage().instance().set(&DataKey::PendingAdmin, &new_admin); + } + + /// Step 2: the proposed admin accepts. + pub fn accept_admin(env: Env) { + Self::bump_instance(&env); + let pending: Address = env + .storage() + .instance() + .get(&DataKey::PendingAdmin) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingAdmin)); + pending.require_auth(); + env.storage().instance().set(&DataKey::Admin, &pending); + env.storage().instance().remove(&DataKey::PendingAdmin); + } + + fn names(env: &Env) -> Vec { + env.storage() + .instance() + .get(&DataKey::Names) + .unwrap_or_else(|| Vec::new(env)) + } + + fn load_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) + } + + fn require_admin(env: &Env) { + Self::load_admin(env).require_auth(); + } + + fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + } +} diff --git a/directory/src/test.rs b/directory/src/test.rs new file mode 100644 index 0000000..cffb8c9 --- /dev/null +++ b/directory/src/test.rs @@ -0,0 +1,70 @@ +extern crate std; + +use super::*; +use soroban_sdk::{symbol_short, testutils::Address as _}; + +fn setup() -> (Env, DirectoryClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, Directory); + let client = DirectoryClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin); + (env, client, admin) +} + +#[test] +fn set_get_list_remove() { + let (env, c, _) = setup(); + let a = Address::generate(&env); + let b = Address::generate(&env); + c.set_address(&symbol_short!("settle"), &a); + c.set_address(&symbol_short!("proof"), &b); + assert_eq!(c.get_address(&symbol_short!("settle")), a); + assert_eq!(c.list_names().len(), 2); + // update does not duplicate the name + c.set_address(&symbol_short!("settle"), &b); + assert_eq!(c.get_address(&symbol_short!("settle")), b); + assert_eq!(c.list_names().len(), 2); + c.remove_address(&symbol_short!("settle")); + assert_eq!(c.find_address(&symbol_short!("settle")), None); + assert_eq!(c.list_names().len(), 1); +} + +#[test] +fn get_missing_fails() { + let (_, c, _) = setup(); + assert!(c.find_address(&symbol_short!("nope")).is_none()); + assert!(c.try_get_address(&symbol_short!("nope")).is_err()); + assert!(c.try_remove_address(&symbol_short!("nope")).is_err()); +} + +#[test] +fn double_initialize_fails() { + let (env, c, _) = setup(); + assert!(c.try_initialize(&Address::generate(&env)).is_err()); +} + +#[test] +fn set_requires_admin_auth() { + let env = Env::default(); + let id = env.register_contract(None, Directory); + let c = DirectoryClient::new(&env, &id); + env.mock_all_auths(); + c.initialize(&Address::generate(&env)); + env.set_auths(&[]); + assert!(c + .try_set_address(&symbol_short!("a"), &Address::generate(&env)) + .is_err()); +} + +#[test] +fn two_step_admin_rotation() { + let (env, c, _) = setup(); + assert!(c.try_accept_admin().is_err()); + let new_admin = Address::generate(&env); + c.propose_admin(&new_admin); + c.accept_admin(); + assert_eq!(c.admin(), new_admin); + assert!(c.try_accept_admin().is_err()); +} diff --git a/docs/CLIENT_COMPUTABLE_INTENT_IDS.md b/docs/CLIENT_COMPUTABLE_INTENT_IDS.md index dbcaff0..0d6cb15 100644 --- a/docs/CLIENT_COMPUTABLE_INTENT_IDS.md +++ b/docs/CLIENT_COMPUTABLE_INTENT_IDS.md @@ -1,5 +1,15 @@ # Client-Computable, Timestamp-Free Intent IDs (Issue #354) +> **Implemented (issue #565).** `intent_settlement::compute_intent_id` now +> uses this scheme with the arguments `submit_intent` actually takes: +> `params_hash = sha256(xdr(src_chain) || be16(src_amount) || xdr(dst_token) || be16(min_dst_amount))` +> and `intent_id = sha256("vortex-intent-v1" || xdr(contract_id) || network_id || xdr(user) || be8(nonce) || params_hash)`, +> where `network_id = sha256(network_passphrase)`. The nonce is exposed via +> `get_user_nonce(user)`; `preview_intent_id(...)` returns the id the next +> submission will receive. The optional `client_supplied_id` argument and the +> legacy timestamp scheme described below were **not** adopted: the legacy +> scheme is replaced outright. The sections below are the original design. + ## Problem Current intent ID scheme: diff --git a/docs/directory-design.md b/docs/directory-design.md new file mode 100644 index 0000000..5095f71 --- /dev/null +++ b/docs/directory-design.md @@ -0,0 +1,23 @@ +# On-chain Address Book (`directory`) — Design Note (Issue #399 / #554) + +## Problem +Contracts such as `intent_periphery` and `governor` hardcode peer addresses at +`initialize`, so there is no canonical, updatable lookup point for the +protocol's own contract addresses. + +## Design +`directory` is a minimal key/value registry: `Symbol` name -> `Address` +(e.g. `intent_settlement`, `proof_registry`, `solver_registry`, `governor`, +`timelock`, `backstop_vault`, `fee_router`, `treasury_streams`, +`reputation_badge`, `intent_periphery`). + +- **Writes** (`set_address`, `remove_address`) are admin-only. The admin is + expected to be the `timelock`, so updates are governance-controlled. +- **Reads** (`get_address`, `find_address`, `list_names`) are permissionless. +- **Admin rotation** is two-step: `propose_admin` then `accept_admin`. +- **Storage**: `Admin`/`PendingAdmin`/`Names` in instance storage; each entry in + persistent storage with the repo-standard TTL bump on write and read. +- **Events**: `addr_set` (name, address) and `addr_rm` (name). + +## Out of scope +Migrating existing contracts to consult the directory; that lands as follow-ups. diff --git a/docs/dispute-resolution-design.md b/docs/dispute-resolution-design.md index 7fb7b9b..05e023c 100644 --- a/docs/dispute-resolution-design.md +++ b/docs/dispute-resolution-design.md @@ -200,10 +200,13 @@ resolution only from the registered panel contract. simplest safe option for testnet — it required no new storage key or governance mechanism. -**v2 (current):** The `arbiter_panel` contract holds the arbiter role. The -settlement contract stores the panel contract address (settable by admin via -`set_arbiter_panel(env, panel: Address)`) and accepts `resolve_dispute` only -from it. The panel is an m-of-n bonded committee; see +**v2 (implemented in `arbiter_panel/`, issue #555):** The `arbiter_panel` +contract holds the arbiter role once the admin calls +`set_arbiter_panel(env, panel: Address)` on `intent_settlement`; from then on +`resolve_dispute` accepts a resolution only from that address (otherwise it +panics with `NotArbiter`). **Until `set_arbiter_panel` is called, the legacy v1 +behaviour remains in effect:** the arbiter is the address set via `set_arbiter` +or, by default, the `Admin`. The panel is an m-of-n bonded committee; see [Arbiter panel](#arbiter-panel-m-of-n-bonded-arbiters). **Arbiter governance:** See [`docs/arbiter-code-of-conduct.md`](./arbiter-code-of-conduct.md) diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index b0770e0..6b53bcc 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -18,6 +18,9 @@ use vortex_proof_registry::ProofRegistryClient; #[cfg(test)] mod test; +#[cfg(test)] +mod test_oracle; + #[cfg(test)] mod proptest_bond; @@ -27,6 +30,9 @@ mod bench; // Issue #415: Pure arithmetic helpers (Env-free, Kani-provable). pub mod math; +// Issue #368/#549: SEP-40 oracle adapter used for cross-token exposure weighting. +mod oracle; + // Issue #415: Kani model-checker harnesses. Only compiled under `kani`. #[cfg(kani)] mod kani_proofs; @@ -3137,8 +3143,17 @@ impl IntentSettlement { .instance() .set(&DataKey::UserNonce(user.clone()), &(nonce + 1)); - // Deterministic intent_id = hash(user, src_chain, src_token, src_amount, now, nonce) - let intent_id = Self::compute_intent_id(&env, &user, &src_chain, src_amount, now, nonce); + // Issue #354/#565: timestamp-free, client-computable intent id + // (docs/CLIENT_COMPUTABLE_INTENT_IDS.md). + let intent_id = Self::compute_intent_id( + &env, + &user, + &src_chain, + src_amount, + &dst_token, + min_dst_amount, + nonce, + ); // Guard against an extremely unlikely hash collision: if a record with // this id somehow already exists, or a tombstone exists (pruned intent), @@ -3349,15 +3364,10 @@ impl IntentSettlement { solver_record.active_intents += 1; // ── Exposure tracking (#367) ───────────────────────────────────────── - // Use a flat 1.0× weight (10_000 bps) until the oracle (#368) supplies - // real prices. Same-token intents are always measured; cross-token - // intents use the same conservative default until the oracle is live. - let weight: i128 = 10_000; // 1.0× in bps - let notional = intent.min_dst_amount - .checked_mul(weight) - .unwrap_or(intent.min_dst_amount) - .checked_div(BPS_DENOMINATOR) - .unwrap_or(intent.min_dst_amount); + // Cross-token intents are weighted by the oracle (#368); stale / missing + // prices or an unset oracle fall back to a conservative 1.0× weight. + let notional = + Self::exposure_notional(&env, &intent.dst_token, &bond_token, intent.min_dst_amount); let current_exposure: i128 = env .storage() .persistent() @@ -3568,11 +3578,16 @@ impl IntentSettlement { .get(&DataKey::SolverExposure(solver.clone())) .unwrap_or(0); // Release: full notional on complete fill; proportional on partial. - let released = if cumulative >= intent.min_dst_amount { - intent.min_dst_amount - } else { - fill_amount - }; + let released = Self::exposure_notional( + &env, + &intent.dst_token, + &intent.bond_token, + if cumulative >= intent.min_dst_amount { + intent.min_dst_amount + } else { + fill_amount + }, + ); env.storage() .persistent() .set( @@ -3915,8 +3930,11 @@ impl IntentSettlement { arbiter.require_auth(); Self::bump_instance_ttl(&env); - // For now, arbiter is the admin. In v2, this could be a separate arbiter role. - Self::require_admin(&env); + // Issue #555: only the configured arbiter (the `arbiter_panel` contract + // once `set_arbiter_panel` has been called) may resolve a dispute. + if arbiter != Self::load_arbiter(&env) { + panic_with_error!(&env, Error::NotArbiter); + } let mut intent: IntentRecord = env .storage() @@ -4089,7 +4107,13 @@ impl IntentSettlement { .persistent() .get(&DataKey::SolverExposure(solver_addr.clone())) .unwrap_or(0); - let unfilled_notional = intent.min_dst_amount.saturating_sub(intent.total_filled); + // Oracle faults fall back to 1.0×; slashing is never blocked by them. + let unfilled_notional = Self::exposure_notional( + &env, + &intent.dst_token, + &bond_token, + intent.min_dst_amount.saturating_sub(intent.total_filled), + ); env.storage() .persistent() .set( @@ -4440,6 +4464,33 @@ impl IntentSettlement { .publish((Symbol::new(&env, "arbiter_updated"),), arbiter); } + /// Admin-only (issue #555): install the `arbiter_panel` contract as the + /// sole address accepted by `resolve_dispute`. Until this is called the + /// legacy single-arbiter model (`set_arbiter` / `Admin`) is in effect. + pub fn set_arbiter_panel(env: Env, panel: Address) { + Self::require_admin(&env); + env.storage().instance().set(&DataKey::Arbiter, &panel); + env.events() + .publish((Symbol::new(&env, "arbiter_panel_set"),), panel); + } + + /// `(user, solver)` of an intent in the `Disputed` state. Used by the + /// arbiter panel to exclude conflicted arbiters from panel selection. + pub fn dispute_parties(env: Env, intent_id: BytesN<32>) -> (Address, Address) { + let intent: IntentRecord = env + .storage() + .persistent() + .get(&DataKey::Intent(intent_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); + if intent.state != IntentState::Disputed { + panic_with_error!(&env, Error::IntentNotDisputed); + } + let solver = intent + .solver + .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotDisputed)); + (intent.user, solver) + } + /// The current arbiter (explicit `set_arbiter` value, else the `Admin`). pub fn get_arbiter(env: Env) -> Address { Self::load_arbiter(&env) @@ -6735,6 +6786,1045 @@ impl IntentSettlement { } } + /// Issue #368/#549: `amount` of `dst_token` valued in `bond_token` units + /// for exposure accounting. Uses the configured oracle's cross-rate for + /// cross-token intents; on a missing / stale / invalid price (or no oracle) + /// it applies the conservative 1.0× weight, so the check still runs. + fn exposure_notional(env: &Env, dst_token: &Address, bond_token: &Address, amount: i128) -> i128 { + let oracle_addr = env + .storage() + .instance() + .get::<_, Address>(&DataKey::OracleAddr); + let weight = oracle::weight_bps(env, oracle_addr.as_ref(), dst_token, bond_token); + amount + .checked_mul(weight) + .unwrap_or(amount) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(amount) + } + + fn get_adjusted_min_bond(env: &Env, dst_token: &Address) -> i128 { + let base_bond = Self::load_config(env).min_bond; + let multiplier = env + .storage() + .persistent() + .get::<_, i128>(&DataKey::MinBondMultiplier(dst_token.clone())) + .unwrap_or(10); + (base_bond * multiplier) / 10 + } + + /// #197: resolve `solver`'s registry tier for perk calculation. + /// + /// Makes a single cross-contract call — `solver_registry.get_tier(solver)` + /// — via `try_invoke_contract` (rather than a generated `#[contractclient]`, + /// to keep the settlement wasm small). Returns `0` (Unranked) — the + /// pre-integration behaviour — whenever the registry address is unset, or + /// the call reverts, or the return value doesn't decode as a `u32`. The + /// result is clamped to a known tier so the perk tables index safely. + fn solver_tier(env: &Env, solver: &Address) -> u32 { + let Some(registry) = env + .storage() + .instance() + .get::<_, Address>(&DataKey::SolverRegistry) + else { + return 0; + }; + let args: Vec = (solver.clone(),).into_val(env); + let result: Result, Result> = + env.try_invoke_contract(®istry, &Symbol::new(env, "get_tier"), args); + match result { + Ok(Ok(tier)) => tier.min(TIER_SLASH_BPS.len() as u32 - 1), + _ => 0, + } + } + + /// Fill-window seconds a solver on `tier` gets when accepting: the base + /// `fill_window` plus the tier's `TIER_FILL_WINDOW_BONUS_BPS` extension. + fn tier_fill_window(tier: u32, base_fill_window: u64) -> u64 { + let bonus_bps = TIER_FILL_WINDOW_BONUS_BPS + .get(tier as usize) + .copied() + .unwrap_or(0); + base_fill_window.saturating_mul(10_000 + bonus_bps) / 10_000 + } + + /// Load the protocol config from storage, falling back to defaults for + /// contracts that pre-date this upgrade (upgrade-safe). + fn load_config(env: &Env) -> ProtocolConfig { + env.storage() + .instance() + .get(&DataKey::Config) + .unwrap_or(ProtocolConfig { + min_bond: DEFAULT_MIN_BOND, + fill_window: DEFAULT_FILL_WINDOW, + intent_expiry: DEFAULT_INTENT_EXPIRY, + protocol_fee_bps: DEFAULT_PROTOCOL_FEE_BPS, + max_active_intents_per_solver: DEFAULT_MAX_ACTIVE_INTENTS_PER_SOLVER, + max_slash_cycles: 3, + referral_share_bps: 0, + backstop_vault_share_bps: 0, + coverage_multiplier: 10, + }) + } + + fn load_bond_token(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::BondToken) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) + } + + /// Returns `true` when bid-window mode is active. + /// + /// Issue #191: this now reads a dedicated `DataKey::BidWindowEnabled` flag + /// set via `set_bid_window_enabled`. It previously reused + /// `DataKey::DstAllowlistEnabled` "as a placeholder", which meant toggling + /// the destination-token allowlist would silently also toggle bidding mode — + /// a storage-key collision that is now closed. Defaults to `false` so + /// first-accept-wins behaviour is preserved on every deployment that + /// pre-dates this feature. + /// + /// Bid-window mode changes `submit_intent` so newly created intents start + /// in the `Bidding` state instead of `Open`, giving solvers a fixed + /// `BID_WINDOW`-second window to submit competing quotes via `bid_intent` + /// before `settle_bids` assigns the winner. + pub fn is_bid_window_enabled(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::BidWindowEnabled) + .unwrap_or(false) + } + + /// Issue #187 — the minimum bond required for a given bond token. + /// + /// * Legacy default token → the effective `min_bond` from `ProtocolConfig` + /// (so pre-#187 behaviour and any admin `set_config` override are + /// preserved). + /// * Any other approved token → an admin-set `DataKey::MinBond(token)` + /// entry, falling back to the compile-time `MIN_BOND` constant when the + /// admin has not set one. + fn min_bond_for_token(env: &Env, token: &Address) -> i128 { + if *token == Self::load_bond_token(env) { + return Self::load_config(env).min_bond; + } + env.storage() + .instance() + .get(&DataKey::MinBond(token.clone())) + .unwrap_or(MIN_BOND) + } + + /// Issue #187 — `true` if `token` may be used as a solver bond: either it + /// is the legacy default token (always allowed) or it has an explicit + /// `DataKey::AllowedBondToken` entry. + fn is_bond_token_allowed(env: &Env, token: &Address) -> bool { + *token == Self::load_bond_token(env) + || env + .storage() + .instance() + .has(&DataKey::AllowedBondToken(token.clone())) + } + + /// Issue #187 — read a solver's bond in a specific token. + /// + /// For the legacy default token the source of truth is + /// `SolverRecord.bond_amount` (kept mirrored for pre-#187 readers); for + /// every other token it is the `DataKey::SolverBond(solver, token)` entry. + fn get_solver_bond_amount(env: &Env, record: &SolverRecord, token: &Address) -> i128 { + if *token == Self::load_bond_token(env) { + record.bond_amount + } else { + env.storage() + .persistent() + .get(&DataKey::SolverBond(record.address.clone(), token.clone())) + .unwrap_or(0) + } + } + + /// Issue #187 — write a solver's bond in a specific token, keeping the + /// legacy `bond_amount` mirror and the `bond_tokens` enumeration in sync. + /// A zero balance drops the token from `bond_tokens` (and, for non-default + /// tokens, removes the storage entry entirely). + fn set_solver_bond_amount( + env: &Env, + record: &mut SolverRecord, + token: &Address, + amount: i128, + ) { + let default_token = Self::load_bond_token(env); + if *token == default_token { + record.bond_amount = amount; + } else if amount > 0 { + env.storage().persistent().set( + &DataKey::SolverBond(record.address.clone(), token.clone()), + &amount, + ); + } else { + env.storage() + .persistent() + .remove(&DataKey::SolverBond(record.address.clone(), token.clone())); + } + + let mut present = false; + for i in 0..record.bond_tokens.len() { + if record.bond_tokens.get(i).unwrap() == *token { + present = true; + break; + } + } + if amount > 0 && !present { + record.bond_tokens.push_back(token.clone()); + } else if amount == 0 && present { + // Rebuild without `token`, mirroring `remove_from_dst_token_list`. + let mut rebuilt: Vec
= Vec::new(env); + for i in 0..record.bond_tokens.len() { + let t = record.bond_tokens.get(i).unwrap(); + if t != *token { + rebuilt.push_back(t); + } + } + record.bond_tokens = rebuilt; + } + } + + /// Issue #193 — proportional bond slash. + /// + /// Returns the amount to slash from `bond` for a solver that failed to + /// deliver an intent whose outstanding output is `unfilled_amount` + /// (`min_dst_amount - total_filled`, floored at 0): + /// + /// ```text + /// exposure = min(unfilled_amount, bond) // same-token comparability + /// proportional = exposure / 10 // 10% of what was at stake + /// cap = bond * SLASH_BPS / 10_000 // never worse than flat 10% + /// slash = clamp(proportional, 1, min(cap, bond)) + /// ``` + /// + /// Properties (mirroring `compute_reputation_score`'s edge-case discipline): + /// * Integer-only, cannot panic (all operands ≥ 0, no division by zero). + /// * Floor of 1 stroop preserves issue #32's "non-zero bond is always + /// punished" guarantee. + /// * Cap at `bond * 10%` means a well-matched bond is never slashed harder + /// than the old flat rate; a solver who over-bonds relative to the intent + /// is slashed *less*, and a solver who under-bonds is still capped at + /// 100% of bond (via `exposure ≤ bond`) and never panics. + /// * `unfilled_amount == 0` (shouldn't happen for an Accepted intent, but + /// guarded) still yields the floor of 1. + fn compute_slash_amount(bond: i128, unfilled_amount: i128) -> i128 { + if bond <= 0 { + return 0; + } + let exposure = unfilled_amount.max(0).min(bond); + let proportional = exposure / 10; + let cap = (bond / 10_000) * SLASH_BPS; + let cap = cap.min(bond).max(1); + proportional.max(1).min(cap) + } + + /// Issue #409 — balance-delta pull helper. + /// + /// Transfers `amount` of `token` from `from` into the contract and returns the + /// **actual** amount received, measured as `balance_after - balance_before`. + /// + /// Fee-on-transfer tokens silently reduce the amount that lands in the + /// contract; rebasing tokens can change balances between calls. By recording + /// the delta rather than the requested `amount`, every inbound accounting + /// entry reflects reality. + /// + /// **Decision per path:** + /// * Bond registration / top-up (`register_solver_inner`): fee-on-transfer bonds + /// would mean the solver is recorded as holding more bond than the contract + /// actually has — accept the delta and record only what arrived. + /// Rebasing-up bonds would credit the solver extra without a transfer; they + /// are not supported as bond tokens (admin must not allowlist them). + /// * `begin_fill` escrow: same reasoning — record only the escrowed amount that + /// actually landed, which is what the user will receive on `release_fill`. + /// * Dispute bond: same — record only what arrived as the dispute collateral. + /// + /// **Fills paid solver→user directly** (`fill_intent`) are exempt: the + /// contract is not the receiver, so there is no balance to measure. The user + /// receives whatever the token delivers; that is outside this contract's + /// accounting responsibility. + /// + /// **CEI note:** callers must write all state changes that depend on the + /// returned `received` value *after* this call returns, not before. The helper + /// itself is read-balance → transfer → read-balance, which is the minimal + /// reentrancy surface; the two balance reads bracket a single external call. + fn pull_exact( + env: &Env, + token: &Address, + from: &Address, + amount: i128, + ) -> i128 { + let client = token::Client::new(env, token); + let contract = env.current_contract_address(); + let before: i128 = client.balance(&contract); + client.transfer(from, &contract, &amount); + let after: i128 = client.balance(&contract); + after - before + } + + /// Issue #188 — the address allowed to call `resolve_dispute`: the + /// `DataKey::Arbiter` entry if set, otherwise the `Admin` (the design + /// doc's v1 default). + fn load_arbiter(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Arbiter) + .unwrap_or_else(|| { + env.storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) + }) + } + + /// Returns the effective protocol fee in basis points from the stored + /// `ProtocolConfig`. + /// + /// Future work (tiered-fee feature): this can be extended to take a solver + /// address and apply volume-tier discounts from the solver's historical + /// `total_volume`. It is retained as the single intended lookup point for + /// that logic; `#[allow(dead_code)]` because `fill_intent` still reads the + /// flat `PROTOCOL_FEE_BPS` constant directly today. + #[allow(dead_code)] + fn get_tiered_fee_bps(env: &Env) -> i128 { + Self::load_config(env).protocol_fee_bps + } + + fn bump_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); + } + + fn bump_intent_ttl(env: &Env, intent_id: &BytesN<32>) { + env.storage().persistent().extend_ttl( + &DataKey::Intent(intent_id.clone()), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + + /// Load an intent record, or panic `IntentNotFound`. + fn load_intent(env: &Env, intent_id: &BytesN<32>) -> IntentRecord { + env.storage() + .persistent() + .get(&DataKey::Intent(intent_id.clone())) + .unwrap_or_else(|| panic_with_error!(env, Error::IntentNotFound)) + } + + /// Persist an intent record and bump its TTL. + fn save_intent(env: &Env, intent_id: &BytesN<32>, intent: &IntentRecord) { + env.storage() + .persistent() + .set(&DataKey::Intent(intent_id.clone()), intent); + Self::bump_intent_ttl(env, intent_id); + } + + fn bump_solver_ttl(env: &Env, solver: &Address) { + env.storage().persistent().extend_ttl( + &DataKey::Solver(solver.clone()), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + + /// Seconds remaining until a `SLASH_COOLDOWN` starting at `last_slash_time` + /// clears, given the current ledger time `now`. Returns `0` for a solver + /// that has never been slashed (`last_slash_time == 0`) or whose cooldown + /// has already elapsed. Shared by `accept_intent` and + /// `get_slash_cooldown_remaining` so both can never disagree (issue #256). + fn slash_cooldown_remaining(last_slash_time: u64, now: u64) -> u64 { + if last_slash_time == 0 { + return 0; + } + let cooldown_end = last_slash_time + SLASH_COOLDOWN; + cooldown_end.saturating_sub(now) + } + + /// Appends `intent_id` to `solver`'s `SolverIntents` list (issue #245, #374). + /// O(1): pushes to the back and records a `SolverIntentIdx` entry for swap-remove. + /// Duplicate adds are guarded: if the index already exists, the add is a no-op. + fn solver_intents_add(env: &Env, solver: &Address, intent_id: &BytesN<32>) { + let idx_key = DataKey::SolverIntentIdx(solver.clone(), intent_id.clone()); + if env.storage().persistent().has(&idx_key) { + return; // already present (e.g. re-accepted after a slash reset) + } + + let list_key = DataKey::SolverIntents(solver.clone()); + let mut list: Vec> = env + .storage() + .persistent() + .get(&list_key) + .unwrap_or_else(|| Vec::new(env)); + + let pos = list.len(); + list.push_back(intent_id.clone()); + + env.storage().persistent().set(&list_key, &list); + env.storage().persistent().extend_ttl( + &list_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + + env.storage().persistent().set(&idx_key, &pos); + env.storage().persistent().extend_ttl( + &idx_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + + /// Removes `intent_id` from `solver`'s `SolverIntents` list (issue #245, #374). + /// O(1) via swap-remove: swaps the target with the last element, pops the tail, + /// and updates the displaced element's index entry. A no-op if the index is absent. + fn solver_intents_remove(env: &Env, solver: &Address, intent_id: &BytesN<32>) { + let idx_key = DataKey::SolverIntentIdx(solver.clone(), intent_id.clone()); + let pos: u32 = match env.storage().persistent().get(&idx_key) { + Some(p) => p, + None => return, // not present + }; + + let list_key = DataKey::SolverIntents(solver.clone()); + let mut list: Vec> = match env.storage().persistent().get(&list_key) { + Some(l) => l, + None => return, + }; + + let last = list.len().saturating_sub(1); + if pos != last { + // Swap target with last element. + let tail_id = list.get(last).unwrap(); + list.set(pos, tail_id.clone()); + // Update the tail element's index to its new position. + let tail_idx_key = DataKey::SolverIntentIdx(solver.clone(), tail_id); + env.storage().persistent().set(&tail_idx_key, &pos); + env.storage().persistent().extend_ttl( + &tail_idx_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + list.remove(last); + + env.storage().persistent().set(&list_key, &list); + env.storage().persistent().extend_ttl( + &list_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + + env.storage().persistent().remove(&idx_key); + } + + // ── Issue #153: Bucketed UserIntents helpers ────────────────────────────── + + /// Append `intent_id` to `user`'s bucketed intent list (issue #153). + /// + /// Reads `UserIntentCount(user)` to determine the current bucket index and + /// slot, writes `Some(intent_id)` into that slot, increments the count, + /// and bumps the bucket's TTL. + fn user_intents_append(env: &Env, user: &Address, intent_id: &BytesN<32>) { + let count: u32 = env + .storage() + .persistent() + .get(&DataKey::UserIntentCount(user.clone())) + .unwrap_or(0); + + let bucket = count / USER_INTENT_BUCKET_SIZE; + let bucket_key = DataKey::UserIntents(user.clone(), bucket); + + let mut page: Vec>> = env + .storage() + .persistent() + .get(&bucket_key) + .unwrap_or_else(|| Vec::new(env)); + page.push_back(Some(intent_id.clone())); + env.storage().persistent().set(&bucket_key, &page); + env.storage().persistent().extend_ttl( + &bucket_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + + env.storage() + .persistent() + .set(&DataKey::UserIntentCount(user.clone()), &(count + 1)); + env.storage().persistent().extend_ttl( + &DataKey::UserIntentCount(user.clone()), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + + /// Prune `intent_id` from `user`'s bucket by setting its slot to `None` + /// (issue #371). The count is NOT decremented — existing flat indices remain + /// valid and pagination skips `None` slots. + fn user_intents_prune(env: &Env, user: &Address, intent_id: &BytesN<32>, flat_idx: u32) { + let bucket = flat_idx / USER_INTENT_BUCKET_SIZE; + let slot = flat_idx % USER_INTENT_BUCKET_SIZE; + let bucket_key = DataKey::UserIntents(user.clone(), bucket); + if let Some(mut page) = env + .storage() + .persistent() + .get::<_, Vec>>>(&bucket_key) + { + if page.get(slot).unwrap_or(None).as_ref() == Some(intent_id) { + page.set(slot, None); + env.storage().persistent().set(&bucket_key, &page); + env.storage().persistent().extend_ttl( + &bucket_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + } + } + + /// Bump the TTL on the currently-active bucket for `user`. Called from + /// `user_intents_append` implicitly; exposed separately for external bumps. + fn bump_user_intents_ttl(env: &Env, user: &Address) { + let count: u32 = env + .storage() + .persistent() + .get(&DataKey::UserIntentCount(user.clone())) + .unwrap_or(0); + let bucket = count.saturating_sub(1) / USER_INTENT_BUCKET_SIZE; + let bucket_key = DataKey::UserIntents(user.clone(), bucket); + if env.storage().persistent().has(&bucket_key) { + env.storage().persistent().extend_ttl( + &bucket_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + } + + /// Issue #354/#565: client-computable, timestamp-free intent id. + /// + /// ```text + /// params_hash = sha256(xdr(src_chain) || be16(src_amount) + /// || xdr(dst_token) || be16(min_dst_amount)) + /// intent_id = sha256("vortex-intent-v1" || xdr(contract_id) || network_id + /// || xdr(user) || be8(nonce) || params_hash) + /// ``` + /// + /// `network_id` is `sha256(network_passphrase)`. Every input is known to + /// the client before submission (the nonce is `get_user_nonce(user)`), so + /// the id can be embedded in a source-chain transaction first. Uniqueness + /// no longer comes from the ledger timestamp but from the strictly + /// increasing per-user nonce, which is part of the preimage and bumped on + /// every submission; the contract id and network id prevent cross-contract + /// and cross-network replay. + fn compute_intent_id( + env: &Env, + user: &Address, + src_chain: &String, + src_amount: i128, + dst_token: &Address, + min_dst_amount: i128, + nonce: u64, + ) -> BytesN<32> { + let mut params = Bytes::new(env); + params.append(&src_chain.clone().to_xdr(env)); + params.extend_from_array(&src_amount.to_be_bytes()); + params.append(&dst_token.clone().to_xdr(env)); + params.extend_from_array(&min_dst_amount.to_be_bytes()); + let params_hash: BytesN<32> = env.crypto().sha256(¶ms).into(); + + let mut preimage = Bytes::from_slice(env, b"vortex-intent-v1"); + preimage.append(&env.current_contract_address().to_xdr(env)); + preimage.append(&env.ledger().network_id().into()); + preimage.append(&user.clone().to_xdr(env)); + preimage.extend_from_array(&nonce.to_be_bytes()); + preimage.append(¶ms_hash.into()); + env.crypto().sha256(&preimage).into() + } + + /// The nonce the next `submit_intent` from `user` will use. + pub fn get_user_nonce(env: Env, user: Address) -> u64 { + env.storage() + .instance() + .get(&DataKey::UserNonce(user)) + .unwrap_or(0) + } + + /// The id `submit_intent` will assign to a submission with these + /// arguments if it executes at `user`'s current nonce. Pure read; lets + /// clients cross-check their off-chain computation. + pub fn preview_intent_id( + env: Env, + user: Address, + src_chain: String, + src_amount: i128, + dst_token: Address, + min_dst_amount: i128, + ) -> BytesN<32> { + let nonce = Self::get_user_nonce(env.clone(), user.clone()); + Self::compute_intent_id(&env, &user, &src_chain, src_amount, &dst_token, min_dst_amount, nonce) + } + + fn validate_proof(env: &Env, intent: &IntentRecord, intent_id: &BytesN<32>) { + let registry_addr: Address = env + .storage() + .instance() + .get(&DataKey::ProofRegistry) + .unwrap_or_else(|| panic_with_error!(env, Error::ProofRegistryNotSet)); + + // One extra cross-contract read per gated fill (docs/124 §4.3). This is + // the resource-fee cost the issue calls out; it is only paid when the + // caller opts in via `require_proof = true`. + let registry = ProofRegistryClient::new(env, ®istry_addr); + let proof = registry + .get_proof(intent_id) + .unwrap_or_else(|| panic_with_error!(env, Error::ProofNotFound)); + + let want_chain_id = Self::wormhole_chain_id(env, &intent.src_chain); + if proof.src_chain_id != want_chain_id { + panic_with_error!(env, Error::ProofChainMismatch); + } + + if proof.src_amount < intent.src_amount { + panic_with_error!(env, Error::ProofAmountInsufficient); + } + } + + /// Map a canonical `src_chain` name to its Wormhole chain ID + /// (docs/129 §4, kept in sync with docs/132-supported-chains.md). Panics + /// with `SrcChainNotSupported` for any name not in the table. + fn wormhole_chain_id(env: &Env, src_chain: &String) -> u32 { + let len = src_chain.len() as usize; + // Longest supported name ("avalanche") is 9 bytes. + if len == 0 || len > 16 { + panic_with_error!(env, Error::SrcChainNotSupported); + } + let mut buf = [0u8; 16]; + src_chain.copy_into_slice(&mut buf[..len]); + match &buf[..len] { + b"solana" => 1, + b"ethereum" => 2, + b"bsc" => 4, + b"polygon" => 5, + b"avalanche" => 6, + b"arbitrum" => 23, + b"optimism" => 24, + b"base" => 30, + _ => panic_with_error!(env, Error::SrcChainNotSupported), + } + } + + /// Translates a canonical `src_chain` string (per + /// `docs/132-supported-chains.md` §2) to its numeric Wormhole chain ID, + /// for comparison against `proof.src_chain_id` once proof-gated fills + /// (issue #5) are wired up. Single source of truth for this mapping — + /// kept in sync with `docs/129-proof-mismatch-fallback.md` §4 (issue #253). + /// + /// Fails closed: an unmapped/future `src_chain` string panics with + /// `Error::SrcChainNotSupported` rather than defaulting to chain ID 0. + pub fn src_chain_to_wormhole_id(env: Env, src_chain: String) -> u32 { + let chain_len = src_chain.len(); + let chain_is = |literal: &[u8]| -> bool { + if chain_len as usize != literal.len() { + return false; + } + let mut i = 0u32; + while i < chain_len { + if src_chain.get(i) != literal[i as usize] as u32 { + return false; + } + i += 1; + } + true + }; + + if chain_is(b"ethereum") { + 2 + } else if chain_is(b"base") { + 30 + } else if chain_is(b"polygon") { + 5 + } else if chain_is(b"arbitrum") { + 23 + } else if chain_is(b"optimism") { + 24 + } else if chain_is(b"avalanche") { + 6 + } else if chain_is(b"bsc") { + 4 + } else if chain_is(b"solana") { + 1 + } else { + panic_with_error!(&env, Error::SrcChainNotSupported) + } + } + + fn require_admin(env: &Env) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + // Auth audit: require_auth() is correct. All callers of require_admin + // are admin-only functions (unpause, set_pauser, + // add/remove_allowed_dst_token, set_dst_allowlist_enabled). The admin + // is a single address with uniform authority over these functions; + // require_auth_for_args would add no meaningful scope reduction. + admin.require_auth(); + } + + /// Issue #120: `pause` accepts either the admin or the address set via + /// `set_pauser`. `caller` is an explicit argument (rather than looked up + /// implicitly, as `require_admin` does for the single-admin case) + /// because there are now two addresses that could legitimately be the + /// signer, so the contract needs to know which one is authorizing this + /// call before it can require that specific address's auth. + fn require_admin_or_pauser(env: &Env, caller: &Address) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + let is_pauser = env + .storage() + .instance() + .get::<_, Address>(&DataKey::Pauser) + .map(|pauser| pauser == *caller) + .unwrap_or(false); + if *caller != admin && !is_pauser { + panic_with_error!(env, Error::Unauthorized); + } + caller.require_auth(); + } + + fn require_not_paused(env: &Env) { + if Self::is_paused(env.clone()) { + panic_with_error!(env, Error::ContractPaused); + } + } + + /// Issue #420 — named boundary: fill window is still open. + /// Exclusive upper bound: `now < deadline` is the last moment fills are valid. + /// This is the "EXCLUSIVE fill deadline" convention from issue #26. + #[inline] + fn fill_window_open(now: u64, deadline: u64) -> bool { + now < deadline + } + + /// Issue #420 — named boundary: slash is eligible. + /// A slash may only be executed once `now >= deadline + SLASH_GRACE_SECS`, + /// giving the solver a `SLASH_GRACE_SECS`-second buffer to absorb ledger + /// close-time drift (see `docs/420-ledger-time-skew-analysis.md`). + /// The fill window uses a DIFFERENT boundary (`fill_window_open`) so fills + /// remain valid right up to `deadline` while slashing requires `deadline + grace`. + #[inline] + fn slash_eligible(now: u64, deadline: u64) -> bool { + now >= deadline.saturating_add(SLASH_GRACE_SECS) + } + + /// Issue #420 — named boundary: dispute window is still open. + /// Exclusive: `now < dispute_deadline`. + #[inline] + fn dispute_window_open(now: u64, dispute_deadline: u64) -> bool { + now < dispute_deadline + } + + /// Issue #420 — named boundary: arbiter window has elapsed (timeout). + /// Inclusive: timeout is reachable at `now == raised_at + ARBITER_WINDOW`. + #[inline] + fn arbiter_timeout_reached(now: u64, raised_at: u64) -> bool { + now >= raised_at.saturating_add(ARBITER_WINDOW) + } + + /// The pre-transfer guard sequence shared between `fill_intent` and + /// `is_intent_fillable` (issue #259): intent state is `Accepted`, `solver` + /// matches `intent.solver`, and `now` is before the fill-window deadline. + /// Extracted so the two call sites can never silently drift apart. + fn check_fill_guards(intent: &IntentRecord, solver: &Address, now: u64) -> Result<(), Error> { + // Boundary semantics: the fill-window deadline is EXCLUSIVE for filling + // (issue #26) — `now >= intent.deadline` rejects at the boundary second. + if now >= intent.deadline { + return Err(Error::FillWindowExpired); + } + match &intent.state { + IntentState::Accepted => {} + IntentState::Filled => return Err(Error::IntentAlreadyFilled), + _ => return Err(Error::IntentNotAccepted), + } + if intent.solver.as_ref() != Some(solver) { + return Err(Error::Unauthorized); + } + Ok(()) + } + + /// Add `token` to the paged persistent allowlist (#117, #373). + /// O(1): appends to the current tail page and writes an index entry. + /// No-op if `token` is already in the set (index entry present). + fn add_to_dst_token_list(env: &Env, token: &Address) { + // Idempotency: skip if already indexed. + if env + .storage() + .persistent() + .has(&DataKey::DstTokenListIdx(token.clone())) + { + return; + } + + let total: u32 = env + .storage() + .persistent() + .get(&DataKey::DstTokenListCount) + .unwrap_or(0); + let pg = total / PAGED_SET_PAGE_SIZE; + let slot = total % PAGED_SET_PAGE_SIZE; + let page_key = DataKey::DstTokenListPage(pg); + + let mut page: Vec> = env + .storage() + .persistent() + .get(&page_key) + .unwrap_or_else(|| Vec::new(env)); + page.push_back(Some(token.clone())); + env.storage().persistent().set(&page_key, &page); + env.storage().persistent().extend_ttl( + &page_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + + // Record the flat index for O(1) removal. + let flat_idx = pg * PAGED_SET_PAGE_SIZE + slot; + env.storage() + .persistent() + .set(&DataKey::DstTokenListIdx(token.clone()), &flat_idx); + env.storage().persistent().extend_ttl( + &DataKey::DstTokenListIdx(token.clone()), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + + env.storage() + .persistent() + .set(&DataKey::DstTokenListCount, &(total + 1)); + } + + /// Remove `token` from the paged persistent allowlist (#373) via swap-remove. + /// O(1): swaps `token` with the last element, then clears the last slot. + fn remove_from_dst_token_list(env: &Env, token: &Address) { + let idx_key = DataKey::DstTokenListIdx(token.clone()); + let flat_idx: u32 = match env.storage().persistent().get(&idx_key) { + Some(v) => v, + None => return, // not in the set + }; + + let total: u32 = env + .storage() + .persistent() + .get(&DataKey::DstTokenListCount) + .unwrap_or(0); + let last_flat = total.saturating_sub(1); + + if flat_idx != last_flat { + // Swap the last element into `flat_idx`. + let last_pg = last_flat / PAGED_SET_PAGE_SIZE; + let last_slot = last_flat % PAGED_SET_PAGE_SIZE; + let last_page_key = DataKey::DstTokenListPage(last_pg); + let mut last_page: Vec> = env + .storage() + .persistent() + .get(&last_page_key) + .unwrap_or_else(|| Vec::new(env)); + let moved_token = last_page.get(last_slot).unwrap(); + + let target_pg = flat_idx / PAGED_SET_PAGE_SIZE; + let target_slot = flat_idx % PAGED_SET_PAGE_SIZE; + let target_page_key = DataKey::DstTokenListPage(target_pg); + let mut target_page: Vec> = env + .storage() + .persistent() + .get(&target_page_key) + .unwrap_or_else(|| Vec::new(env)); + target_page.set(target_slot, moved_token.clone()); + last_page.set(last_slot, None); + + env.storage().persistent().set(&target_page_key, &target_page); + env.storage().persistent().extend_ttl( + &target_page_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + env.storage().persistent().set(&last_page_key, &last_page); + + // Update the moved token's index. + if let Some(moved_addr) = moved_token { + env.storage() + .persistent() + .set(&DataKey::DstTokenListIdx(moved_addr), &flat_idx); + } + } else { + // Removing the last element — just clear the slot. + let target_pg = flat_idx / PAGED_SET_PAGE_SIZE; + let target_slot = flat_idx % PAGED_SET_PAGE_SIZE; + let target_page_key = DataKey::DstTokenListPage(target_pg); + let mut target_page: Vec> = env + .storage() + .persistent() + .get(&target_page_key) + .unwrap_or_else(|| Vec::new(env)); + target_page.set(target_slot, None); + env.storage().persistent().set(&target_page_key, &target_page); + } + + // Remove the index entry for `token` and decrement count. + env.storage().persistent().remove(&idx_key); + if total > 0 { + env.storage() + .persistent() + .set(&DataKey::DstTokenListCount, &last_flat); + } + } + + /// Append `solver` to the enumerable solver list (#198) if not already + /// present. Called from `register_solver` only on a first registration, so + /// Add `solver` to the paged persistent solver list (#198, #373). + /// O(1): appends to the current tail page and writes an index entry. + /// No-op if `solver` is already in the set (index entry present). + fn add_to_solver_list(env: &Env, solver: &Address) { + // Idempotency: skip if already indexed. + if env + .storage() + .persistent() + .has(&DataKey::SolverListIdx(solver.clone())) + { + return; + } + + let total: u32 = env + .storage() + .persistent() + .get(&DataKey::SolverListCount) + .unwrap_or(0); + let pg = total / PAGED_SET_PAGE_SIZE; + let slot = total % PAGED_SET_PAGE_SIZE; + let page_key = DataKey::SolverListPage(pg); + + let mut page: Vec> = env + .storage() + .persistent() + .get(&page_key) + .unwrap_or_else(|| Vec::new(env)); + page.push_back(Some(solver.clone())); + env.storage().persistent().set(&page_key, &page); + env.storage().persistent().extend_ttl( + &page_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + + let flat_idx = pg * PAGED_SET_PAGE_SIZE + slot; + env.storage() + .persistent() + .set(&DataKey::SolverListIdx(solver.clone()), &flat_idx); + env.storage().persistent().extend_ttl( + &DataKey::SolverListIdx(solver.clone()), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + + env.storage() + .persistent() + .set(&DataKey::SolverListCount, &(total + 1)); + } + + /// Remove `solver` from the paged persistent solver list (#373) via swap-remove. + /// O(1): swaps `solver` with the last element, then clears the last slot. + fn remove_from_solver_list(env: &Env, solver: &Address) { + let idx_key = DataKey::SolverListIdx(solver.clone()); + let flat_idx: u32 = match env.storage().persistent().get(&idx_key) { + Some(v) => v, + None => return, + }; + + let total: u32 = env + .storage() + .persistent() + .get(&DataKey::SolverListCount) + .unwrap_or(0); + let last_flat = total.saturating_sub(1); + + if flat_idx != last_flat { + let last_pg = last_flat / PAGED_SET_PAGE_SIZE; + let last_slot = last_flat % PAGED_SET_PAGE_SIZE; + let last_page_key = DataKey::SolverListPage(last_pg); + let mut last_page: Vec> = env + .storage() + .persistent() + .get(&last_page_key) + .unwrap_or_else(|| Vec::new(env)); + let moved_solver = last_page.get(last_slot).unwrap(); + + let target_pg = flat_idx / PAGED_SET_PAGE_SIZE; + let target_slot = flat_idx % PAGED_SET_PAGE_SIZE; + let target_page_key = DataKey::SolverListPage(target_pg); + let mut target_page: Vec> = env + .storage() + .persistent() + .get(&target_page_key) + .unwrap_or_else(|| Vec::new(env)); + target_page.set(target_slot, moved_solver.clone()); + last_page.set(last_slot, None); + + env.storage().persistent().set(&target_page_key, &target_page); + env.storage().persistent().extend_ttl( + &target_page_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + env.storage().persistent().set(&last_page_key, &last_page); + + if let Some(moved_addr) = moved_solver { + env.storage() + .persistent() + .set(&DataKey::SolverListIdx(moved_addr), &flat_idx); + } + } else { + let target_pg = flat_idx / PAGED_SET_PAGE_SIZE; + let target_slot = flat_idx % PAGED_SET_PAGE_SIZE; + let target_page_key = DataKey::SolverListPage(target_pg); + let mut target_page: Vec> = env + .storage() + .persistent() + .get(&target_page_key) + .unwrap_or_else(|| Vec::new(env)); + target_page.set(target_slot, None); + env.storage().persistent().set(&target_page_key, &target_page); + } + + env.storage().persistent().remove(&idx_key); + if total > 0 { + env.storage() + .persistent() + .set(&DataKey::SolverListCount, &last_flat); + } + } + + /// Issue #368/#549: `amount` of `dst_token` valued in `bond_token` units + /// for exposure accounting. Uses the configured oracle's cross-rate for + /// cross-token intents; on a missing / stale / invalid price (or no oracle) + /// it applies the conservative 1.0× weight, so the check still runs. + fn exposure_notional(env: &Env, dst_token: &Address, bond_token: &Address, amount: i128) -> i128 { + let oracle_addr = env + .storage() + .instance() + .get::<_, Address>(&DataKey::OracleAddr); + let weight = oracle::weight_bps(env, oracle_addr.as_ref(), dst_token, bond_token); + amount + .checked_mul(weight) + .unwrap_or(amount) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(amount) + } + fn get_adjusted_min_bond(env: &Env, dst_token: &Address) -> i128 { let base_bond = Self::load_config(env).min_bond; let multiplier = env diff --git a/intent_settlement/src/oracle.rs b/intent_settlement/src/oracle.rs index d67cd33..ff44987 100644 --- a/intent_settlement/src/oracle.rs +++ b/intent_settlement/src/oracle.rs @@ -17,8 +17,6 @@ //! (the check still runs, so an over-exposed solver is still blocked) //! - `slash_solver` → proceeds regardless (slashing must never be blocked) -#![allow(unused)] - use soroban_sdk::{contractclient, Address, Env}; // ─── SEP-40 compatible oracle client ───────────────────────────────────────── @@ -52,6 +50,41 @@ pub const MAX_PRICE_AGE_SECS: u64 = 300; /// Maximum allowed deviation between two oracle sources, in basis points (2%). pub const MAX_PRICE_DEVIATION_BPS: i128 = 200; +/// Value of `from` expressed in units of `to`, in basis points (10_000 = 1.0×), +/// derived from oracle prices. Fail-closed: returns the conservative 1.0× +/// weight when `from == to`, no oracle is configured, or either price is +/// stale / zero / missing / would overflow. Never traps. +pub fn weight_bps(env: &Env, oracle_addr: Option<&Address>, from: &Address, to: &Address) -> i128 { + const NEUTRAL: i128 = 10_000; + let Some(oracle_addr) = oracle_addr else { + return NEUTRAL; + }; + if from == to { + return NEUTRAL; + } + let (Ok((pf, df)), Ok((pt, dt))) = ( + fetch_price(env, oracle_addr, from), + fetch_price(env, oracle_addr, to), + ) else { + return NEUTRAL; + }; + // (pf / 10^df) / (pt / 10^dt) * 10_000 + let w = 10i128 + .checked_pow(dt) + .and_then(|a| pf.checked_mul(a)) + .and_then(|n| n.checked_mul(NEUTRAL)) + .and_then(|n| { + 10i128 + .checked_pow(df) + .and_then(|b| pt.checked_mul(b)) + .and_then(|d| n.checked_div(d)) + }); + match w { + Some(w) if w > 0 => w, + _ => NEUTRAL, + } +} + // ─── Result type ───────────────────────────────────────────────────────────── #[derive(Debug, PartialEq)] @@ -76,7 +109,9 @@ pub fn fetch_price( asset: &Address, ) -> Result<(i128, u32), OracleFault> { let client = OracleAdapterClient::new(env, oracle_addr); - match client.lastprice(asset.clone()) { + // `try_` so a trapping / non-conforming oracle is a fault, not a revert: + // slashing must never be blocked by the oracle. + match client.try_lastprice(asset).ok().and_then(|r| r.ok()).flatten() { None => Err(OracleFault::NotAvailable), Some(pd) => { let now = env.ledger().timestamp(); @@ -94,6 +129,7 @@ pub fn fetch_price( /// Check that two prices (from different sources) agree within /// `MAX_PRICE_DEVIATION_BPS`. Returns `true` if they agree, `false` /// if the deviation exceeds the threshold or either price is zero. +#[allow(dead_code)] // reserved for dual-source oracle setups pub fn prices_agree(price_a: i128, dec_a: u32, price_b: i128, dec_b: u32) -> bool { if price_a <= 0 || price_b <= 0 { return false; diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index ab5472d..4529450 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -3735,3 +3735,46 @@ fn partial_fill_reopen_clears_the_tier_snapshot() { "snapshot cleared on re-open" ); } + +// #354 / #565 — the intent id is client-computable: it depends only on inputs +// the client has before submission (no ledger timestamp). +#[test] +fn client_can_precompute_intent_id_without_timestamp() { + use soroban_sdk::{xdr::ToXdr, Bytes}; + let ctx = setup(); + let c = ctx.client(); + let src_chain = String::from_str(&ctx.env, "ethereum"); + + // Independent "client-side" reference implementation of the documented scheme. + let reference = |nonce: u64| -> BytesN<32> { + let mut params = Bytes::new(&ctx.env); + params.append(&src_chain.clone().to_xdr(&ctx.env)); + params.extend_from_array(&SRC_AMT.to_be_bytes()); + params.append(&ctx.dst_token.clone().to_xdr(&ctx.env)); + params.extend_from_array(&MIN_DST.to_be_bytes()); + let params_hash: BytesN<32> = ctx.env.crypto().sha256(¶ms).into(); + + let mut pre = Bytes::from_slice(&ctx.env, b"vortex-intent-v1"); + pre.append(&ctx.contract_id.clone().to_xdr(&ctx.env)); + pre.append(&ctx.env.ledger().network_id().into()); + pre.append(&ctx.user.clone().to_xdr(&ctx.env)); + pre.extend_from_array(&nonce.to_be_bytes()); + pre.append(¶ms_hash.into()); + ctx.env.crypto().sha256(&pre).into() + }; + + let expected0 = reference(0); + assert_eq!(c.get_user_nonce(&ctx.user), 0); + assert_eq!( + c.preview_intent_id(&ctx.user, &src_chain, &SRC_AMT, &ctx.dst_token, &MIN_DST), + expected0 + ); + + // Submitting at a different ledger time yields the precomputed id. + ctx.pass_time(12_345); + assert_eq!(ctx.submit(), expected0); + + // The next submission uses nonce 1 and is likewise predictable. + ctx.pass_time(7); + assert_eq!(ctx.submit(), reference(1)); +} diff --git a/intent_settlement/src/test_oracle.rs b/intent_settlement/src/test_oracle.rs new file mode 100644 index 0000000..5e5d5b3 --- /dev/null +++ b/intent_settlement/src/test_oracle.rs @@ -0,0 +1,77 @@ +//! Issue #549: fail-closed behaviour of the oracle adapter and its wiring. +extern crate std; + +use super::oracle::{fetch_price, weight_bps, OracleFault, PriceData}; +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::{Address as _, Ledger}, Address, Env, +}; + +#[contracttype] +enum K { + Price(Address), +} + +#[contract] +struct MockOracle; + +#[contractimpl] +impl MockOracle { + pub fn set(env: Env, asset: Address, price: i128, decimals: u32, timestamp: u64) { + env.storage() + .instance() + .set(&K::Price(asset), &PriceData { price, decimals, timestamp }); + } + pub fn lastprice(env: Env, asset: Address) -> Option { + env.storage().instance().get(&K::Price(asset)) + } +} + +fn setup() -> (Env, Address, MockOracleClient<'static>, Address, Address) { + let env = Env::default(); + env.ledger().set_timestamp(10_000); + let id = env.register_contract(None, MockOracle); + let c = MockOracleClient::new(&env, &id); + let (a, b) = (Address::generate(&env), Address::generate(&env)); + (env, id, c, a, b) +} + +#[test] +fn fetch_price_fail_closed_paths() { + let (env, id, c, a, _) = setup(); + assert_eq!(fetch_price(&env, &id, &a), Err(OracleFault::NotAvailable)); + c.set(&a, &100, &7, &(10_000 - 301)); + assert_eq!(fetch_price(&env, &id, &a), Err(OracleFault::Stale)); + c.set(&a, &0, &7, &10_000); + assert_eq!(fetch_price(&env, &id, &a), Err(OracleFault::ZeroOrNegative)); + c.set(&a, &100, &7, &(10_000 - 300)); + assert_eq!(fetch_price(&env, &id, &a), Ok((100, 7))); +} + +#[test] +fn weight_is_oracle_cross_rate_or_neutral() { + let (env, id, c, a, b) = setup(); + // no oracle configured / same token => 1.0x + assert_eq!(weight_bps(&env, None, &a, &b), 10_000); + assert_eq!(weight_bps(&env, Some(&id), &a, &a), 10_000); + // missing price => 1.0x + assert_eq!(weight_bps(&env, Some(&id), &a, &b), 10_000); + // a = 2.0, b = 1.0 (different decimals) => 2.0x + c.set(&a, &20_000_000, &7, &10_000); + c.set(&b, &100, &2, &10_000); + assert_eq!(weight_bps(&env, Some(&id), &a, &b), 20_000); + // stale `to` price => 1.0x + c.set(&b, &100, &2, &1); + assert_eq!(weight_bps(&env, Some(&id), &a, &b), 10_000); + // zero price => 1.0x + c.set(&b, &0, &2, &10_000); + assert_eq!(weight_bps(&env, Some(&id), &a, &b), 10_000); +} + +#[test] +fn trapping_oracle_is_a_fault_not_a_revert() { + let (env, _, _, a, b) = setup(); + // An address with no contract behind it: lastprice call fails. + let dead = Address::generate(&env); + assert_eq!(fetch_price(&env, &dead, &a), Err(OracleFault::NotAvailable)); + assert_eq!(weight_bps(&env, Some(&dead), &a, &b), 10_000); +}