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 522e73b..00e0efc 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;
@@ -3146,8 +3149,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),
@@ -3358,15 +3370,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()
@@ -3577,11 +3584,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(
@@ -3917,7 +3929,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(
@@ -4268,6 +4286,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)
@@ -6583,6 +6628,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