From 1fcbbff4fbfa3c8f1288a85e1916cc94e2dc404f Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:25:14 -0700 Subject: [PATCH] feat: M008 CosmWasm attestation-bonding contract with unit tests Implements the Data Attestation Bonding mechanism (M008) as a CosmWasm smart contract per the SPEC and phase-3 architecture. Attesters bond REGEN tokens to back ecological claims; challengers can dispute with a deposit; arbiter DAO or admin resolves disputes with slash/reward flows. Contract features: - Attestation lifecycle: Bonded -> Active -> Released (happy path) - 48h activation delay with permissionless crank - Challenge/dispute with 10% deposit requirement - Resolution: attester wins (bond+deposit-fee) or challenger wins (50/50 slash) - Bond conservation invariant enforced in all flows - Arbiter neutrality check (resolver cannot be attester or challenger) - Configurable attestation types with per-type min bond, lock period, challenge window - Admin functions: UpdateConfig, AddAttestationType - Query endpoints: attestation, by-attester, challenge, bond info, types 42 unit tests covering all 20 SPEC acceptance test scenarios plus edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + contracts/attestation-bonding/Cargo.lock | 1099 +++++++++ contracts/attestation-bonding/Cargo.toml | 25 + contracts/attestation-bonding/src/contract.rs | 2149 +++++++++++++++++ contracts/attestation-bonding/src/error.rs | 62 + contracts/attestation-bonding/src/lib.rs | 4 + contracts/attestation-bonding/src/msg.rs | 179 ++ contracts/attestation-bonding/src/state.rs | 126 + 8 files changed, 3645 insertions(+) create mode 100644 contracts/attestation-bonding/Cargo.lock create mode 100644 contracts/attestation-bonding/Cargo.toml create mode 100644 contracts/attestation-bonding/src/contract.rs create mode 100644 contracts/attestation-bonding/src/error.rs create mode 100644 contracts/attestation-bonding/src/lib.rs create mode 100644 contracts/attestation-bonding/src/msg.rs create mode 100644 contracts/attestation-bonding/src/state.rs diff --git a/.gitignore b/.gitignore index 82eedf7..6ab02e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.zip .DS_Store node_modules/ +target/ # Local-only context (LLM analyses, workspace archives, private notes) .local/ diff --git a/contracts/attestation-bonding/Cargo.lock b/contracts/attestation-bonding/Cargo.lock new file mode 100644 index 0000000..bcfeeba --- /dev/null +++ b/contracts/attestation-bonding/Cargo.lock @@ -0,0 +1,1099 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ark-bls12-381" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rayon", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", + "rayon", +] + +[[package]] +name = "attestation-bonding" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "cw2", + "schemars", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bnum" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e31ea183f6ee62ac8b8a8cf7feddd766317adfb13ff469de57ce033efd6a790" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cosmwasm-core" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f96ba2dd3c228d9e04a901307502096f56e5a547980a30656118503773a25c7c" + +[[package]] +name = "cosmwasm-crypto" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2040bedb37918da4701f83dd8635749d99d7baa6c0129f290f503dcde90e5c" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "cosmwasm-core", + "curve25519-dalek", + "digest", + "ecdsa", + "ed25519-zebra", + "k256", + "num-traits", + "p256", + "rand_core", + "rayon", + "sha2", + "thiserror 1.0.69", +] + +[[package]] +name = "cosmwasm-derive" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e8c609b837282a093d87f45099f648b5c2f36203c6223f67883f13c3c1453a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "cosmwasm-schema" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d8debcf3b9cb23ed4c42a8c17fa224a422312d09f123816cc844c18c59246c" +dependencies = [ + "cosmwasm-schema-derive", + "schemars", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cosmwasm-schema-derive" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efbbf2d78ec255fdae93ae363cc67490878fe5682f1266e4951321cd20888ef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "cosmwasm-std" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d40d789c6d5d9b560927712c836237a3f29ea75fb09ef8718d4036bf2b8965" +dependencies = [ + "base64", + "bech32", + "bnum", + "cosmwasm-core", + "cosmwasm-crypto", + "cosmwasm-derive", + "derive_more", + "hex", + "rand_core", + "rmp-serde", + "schemars", + "serde", + "serde-json-wasm", + "sha2", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "cw-storage-plus" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f13360e9007f51998d42b1bc6b7fa0141f74feae61ed5fd1e5b0a89eec7b5de1" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "cw2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04852cd38f044c0751259d5f78255d07590d136b8a86d4e09efdd7666bd6d27" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "semver", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-zebra" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d9ce6874da5d4415896cd45ffbc4d1cfc0c4f9c079427bd870742c30f2f65a9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "hashbrown 0.14.5", + "hex", + "rand_core", + "sha2", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-json-wasm" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05da0d153dd4595bdffd5099dc0e9ce425b205ee648eb93437ff7302af8c9a5" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/contracts/attestation-bonding/Cargo.toml b/contracts/attestation-bonding/Cargo.toml new file mode 100644 index 0000000..36a6728 --- /dev/null +++ b/contracts/attestation-bonding/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "attestation-bonding" +version = "0.1.0" +edition = "2021" +description = "M008 Data Attestation Bonding — CosmWasm contract for bonded ecological attestations with challenge/dispute resolution" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# Use library feature to allow importing from other contracts +library = [] + +[dependencies] +cosmwasm-schema = "2.2" +cosmwasm-std = { version = "2.2", features = ["staking"] } +cw-storage-plus = "2.0" +cw2 = "2.0" +schemars = "0.8" +serde = { version = "1", default-features = false, features = ["derive"] } +thiserror = "2" + +[dev-dependencies] +cosmwasm-std = { version = "2.2", features = ["staking"] } diff --git a/contracts/attestation-bonding/src/contract.rs b/contracts/attestation-bonding/src/contract.rs new file mode 100644 index 0000000..0131dda --- /dev/null +++ b/contracts/attestation-bonding/src/contract.rs @@ -0,0 +1,2149 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut, Env, + MessageInfo, Order, Response, StdResult, Uint128, +}; +use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{ + AllAttestationTypesResponse, AttestationResponse, AttestationTypeInput, + AttestationTypeResponse, AttestationsResponse, BondInfoResponse, ChallengeResolutionInput, + ChallengeResponse, ChallengesResponse, ConfigResponse, ExecuteMsg, InstantiateMsg, QueryMsg, +}; +use crate::state::{ + Attestation, AttestationStatus, AttestationType, Challenge, ChallengeResolution, + ChallengeStatus, Config, ACTIVATION_DELAY_SECS, ATTESTATIONS, ATTESTATION_CHALLENGE, + ATTESTATION_TYPES, BASIS_POINTS_DIVISOR, CHALLENGES, CONFIG, NEXT_ATTESTATION_ID, + NEXT_CHALLENGE_ID, +}; + +const CONTRACT_NAME: &str = "crates.io:attestation-bonding"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// ============================================================================ +// Instantiate +// ============================================================================ + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let arbiter_dao = deps.api.addr_validate(&msg.arbiter_dao)?; + + let config = Config { + admin: info.sender.clone(), + arbiter_dao, + min_challenge_deposit_ratio: msg.min_challenge_deposit_ratio, + arbiter_fee_ratio: msg.arbiter_fee_ratio, + bond_denom: msg.bond_denom, + }; + CONFIG.save(deps.storage, &config)?; + + NEXT_ATTESTATION_ID.save(deps.storage, &1u64)?; + NEXT_CHALLENGE_ID.save(deps.storage, &1u64)?; + + // Register initial attestation types + for at in msg.attestation_types { + let att_type = AttestationType { + name: at.name.clone(), + min_bond: at.min_bond, + lock_period_days: at.lock_period_days, + challenge_window_days: at.challenge_window_days, + }; + ATTESTATION_TYPES.save(deps.storage, &at.name, &att_type)?; + } + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", info.sender) + .add_attribute("contract", CONTRACT_NAME)) +} + +// ============================================================================ +// Execute +// ============================================================================ + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::CreateAttestation { + attestation_type, + attestation_iri, + beneficiary, + } => execute_create_attestation(deps, env, info, attestation_type, attestation_iri, beneficiary), + ExecuteMsg::ActivateAttestation { attestation_id } => { + execute_activate_attestation(deps, env, attestation_id) + } + ExecuteMsg::ChallengeAttestation { + attestation_id, + evidence_iri, + } => execute_challenge_attestation(deps, env, info, attestation_id, evidence_iri), + ExecuteMsg::ResolveChallenge { + challenge_id, + resolution, + } => execute_resolve_challenge(deps, env, info, challenge_id, resolution), + ExecuteMsg::ReleaseBond { attestation_id } => { + execute_release_bond(deps, env, info, attestation_id) + } + ExecuteMsg::UpdateConfig { + arbiter_dao, + min_challenge_deposit_ratio, + arbiter_fee_ratio, + } => execute_update_config(deps, info, arbiter_dao, min_challenge_deposit_ratio, arbiter_fee_ratio), + ExecuteMsg::AddAttestationType { attestation_type } => { + execute_add_attestation_type(deps, info, attestation_type) + } + } +} + +// --------------------------------------------------------------------------- +// CreateAttestation +// --------------------------------------------------------------------------- + +fn execute_create_attestation( + deps: DepsMut, + env: Env, + info: MessageInfo, + attestation_type: String, + attestation_iri: String, + beneficiary: Option, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Load attestation type config + let att_type = ATTESTATION_TYPES + .load(deps.storage, &attestation_type) + .map_err(|_| ContractError::UnknownAttestationType {})?; + + // Validate bond amount — must send at least min_bond in the required denom + let bond = info + .funds + .iter() + .find(|c| c.denom == config.bond_denom) + .ok_or(ContractError::NoBondProvided {})?; + + if bond.amount < att_type.min_bond { + return Err(ContractError::InsufficientBond { + required: att_type.min_bond, + provided: bond.amount, + }); + } + + // Calculate timestamps + let lock_expires_at = env.block.time.plus_days(att_type.lock_period_days); + let challenge_window_closes_at = env.block.time.plus_days(att_type.challenge_window_days); + let activation_eligible_at = env.block.time.plus_seconds(ACTIVATION_DELAY_SECS); + + // Validate beneficiary if provided + let beneficiary_addr = beneficiary + .map(|b| deps.api.addr_validate(&b)) + .transpose()?; + + // Allocate ID and save + let id = NEXT_ATTESTATION_ID.load(deps.storage)?; + let attestation = Attestation { + id, + attester: info.sender.clone(), + attestation_type: attestation_type.clone(), + attestation_iri: attestation_iri.clone(), + beneficiary: beneficiary_addr, + bond: bond.clone(), + status: AttestationStatus::Bonded, + bonded_at: env.block.time, + activated_at: None, + lock_expires_at, + challenge_window_closes_at, + activation_eligible_at, + }; + + ATTESTATIONS.save(deps.storage, id, &attestation)?; + NEXT_ATTESTATION_ID.save(deps.storage, &(id + 1))?; + + Ok(Response::new() + .add_attribute("action", "create_attestation") + .add_attribute("attestation_id", id.to_string()) + .add_attribute("attester", info.sender) + .add_attribute("attestation_type", attestation_type) + .add_attribute("bond_amount", bond.amount)) +} + +// --------------------------------------------------------------------------- +// ActivateAttestation — permissionless crank after 48h delay +// --------------------------------------------------------------------------- + +fn execute_activate_attestation( + deps: DepsMut, + env: Env, + attestation_id: u64, +) -> Result { + let mut attestation = ATTESTATIONS + .load(deps.storage, attestation_id) + .map_err(|_| ContractError::AttestationNotFound {})?; + + // Must be in Bonded state + if attestation.status != AttestationStatus::Bonded { + return Err(ContractError::AttestationNotReleasable {}); + } + + // 48h activation delay must have passed + if env.block.time < attestation.activation_eligible_at { + return Err(ContractError::LockPeriodNotExpired {}); + } + + attestation.status = AttestationStatus::Active; + attestation.activated_at = Some(env.block.time); + ATTESTATIONS.save(deps.storage, attestation_id, &attestation)?; + + Ok(Response::new() + .add_attribute("action", "activate_attestation") + .add_attribute("attestation_id", attestation_id.to_string())) +} + +// --------------------------------------------------------------------------- +// ChallengeAttestation +// --------------------------------------------------------------------------- + +fn execute_challenge_attestation( + deps: DepsMut, + env: Env, + info: MessageInfo, + attestation_id: u64, + evidence_iri: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut attestation = ATTESTATIONS + .load(deps.storage, attestation_id) + .map_err(|_| ContractError::AttestationNotFound {})?; + + // Must be in Bonded or Active state + match attestation.status { + AttestationStatus::Bonded | AttestationStatus::Active => {} + _ => return Err(ContractError::AttestationNotChallengeable {}), + } + + // Check challenge window + if env.block.time > attestation.challenge_window_closes_at { + return Err(ContractError::ChallengeWindowClosed {}); + } + + // Only one active challenge at a time + if ATTESTATION_CHALLENGE.has(deps.storage, attestation_id) { + return Err(ContractError::AlreadyChallenged {}); + } + + // Validate deposit: must be >= bond * challenge_deposit_ratio / BASIS_POINTS_DIVISOR + let min_deposit = attestation + .bond + .amount + .multiply_ratio(config.min_challenge_deposit_ratio, Uint128::from(BASIS_POINTS_DIVISOR)); + + let deposit = info + .funds + .iter() + .find(|c| c.denom == config.bond_denom) + .ok_or(ContractError::NoDepositProvided {})?; + + if deposit.amount < min_deposit { + return Err(ContractError::InsufficientChallengeDeposit { + required: min_deposit, + provided: deposit.amount, + }); + } + + // Create challenge + let challenge_id = NEXT_CHALLENGE_ID.load(deps.storage)?; + let challenge = Challenge { + id: challenge_id, + attestation_id, + challenger: info.sender.clone(), + evidence_iri, + deposit: deposit.clone(), + status: ChallengeStatus::Pending, + created_at: env.block.time, + resolved_at: None, + resolution: None, + }; + + CHALLENGES.save(deps.storage, challenge_id, &challenge)?; + ATTESTATION_CHALLENGE.save(deps.storage, attestation_id, &challenge_id)?; + NEXT_CHALLENGE_ID.save(deps.storage, &(challenge_id + 1))?; + + // Update attestation status + attestation.status = AttestationStatus::Challenged; + ATTESTATIONS.save(deps.storage, attestation_id, &attestation)?; + + Ok(Response::new() + .add_attribute("action", "challenge_attestation") + .add_attribute("challenge_id", challenge_id.to_string()) + .add_attribute("attestation_id", attestation_id.to_string()) + .add_attribute("challenger", info.sender) + .add_attribute("deposit_amount", deposit.amount)) +} + +// --------------------------------------------------------------------------- +// ResolveChallenge — only arbiter DAO / admin +// --------------------------------------------------------------------------- + +fn execute_resolve_challenge( + deps: DepsMut, + env: Env, + info: MessageInfo, + challenge_id: u64, + resolution: ChallengeResolutionInput, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Only arbiter DAO or admin can resolve + if info.sender != config.arbiter_dao && info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + let mut challenge = CHALLENGES + .load(deps.storage, challenge_id) + .map_err(|_| ContractError::ChallengeNotFound {})?; + + // Cannot re-resolve + if challenge.status == ChallengeStatus::Resolved { + return Err(ContractError::ChallengeAlreadyResolved {}); + } + + let mut attestation = ATTESTATIONS.load(deps.storage, challenge.attestation_id)?; + + // Arbiter neutrality: resolver cannot be attester or challenger + if info.sender == attestation.attester || info.sender == challenge.challenger { + return Err(ContractError::ArbiterConflict {}); + } + + // Calculate arbiter fee + let arbiter_fee = attestation + .bond + .amount + .multiply_ratio(config.arbiter_fee_ratio, Uint128::from(BASIS_POINTS_DIVISOR)); + + let mut messages: Vec = vec![]; + + match resolution { + ChallengeResolutionInput::AttesterWins => { + // Attester gets: bond + challenge_deposit - arbiter_fee + let attester_amount = attestation + .bond + .amount + .checked_add(challenge.deposit.amount) + .map_err(|_| ContractError::Overflow {})? + .checked_sub(arbiter_fee) + .map_err(|_| ContractError::Overflow {})?; + + messages.push(CosmosMsg::Bank(BankMsg::Send { + to_address: attestation.attester.to_string(), + amount: vec![Coin { + denom: config.bond_denom.clone(), + amount: attester_amount, + }], + })); + + attestation.status = AttestationStatus::ResolvedValid; + challenge.resolution = Some(ChallengeResolution::AttesterWins); + } + ChallengeResolutionInput::ChallengerWins => { + // Challenger gets: 50% bond + deposit - arbiter_fee + let half_bond = attestation + .bond + .amount + .multiply_ratio(50u128, 100u128); + + let challenger_amount = half_bond + .checked_add(challenge.deposit.amount) + .map_err(|_| ContractError::Overflow {})? + .checked_sub(arbiter_fee) + .map_err(|_| ContractError::Overflow {})?; + + // Community pool gets remaining 50% of bond + let community_amount = attestation + .bond + .amount + .checked_sub(half_bond) + .map_err(|_| ContractError::Overflow {})?; + + messages.push(CosmosMsg::Bank(BankMsg::Send { + to_address: challenge.challenger.to_string(), + amount: vec![Coin { + denom: config.bond_denom.clone(), + amount: challenger_amount, + }], + })); + + // Community pool — in production this would use x/distribution FundCommunityPool. + // For v1, we send to the arbiter_dao which acts as community treasury. + if !community_amount.is_zero() { + messages.push(CosmosMsg::Bank(BankMsg::Send { + to_address: config.arbiter_dao.to_string(), + amount: vec![Coin { + denom: config.bond_denom.clone(), + amount: community_amount, + }], + })); + } + + attestation.status = AttestationStatus::Slashed; + challenge.resolution = Some(ChallengeResolution::ChallengerWins); + } + } + + // Send arbiter fee + if !arbiter_fee.is_zero() { + messages.push(CosmosMsg::Bank(BankMsg::Send { + to_address: config.arbiter_dao.to_string(), + amount: vec![Coin { + denom: config.bond_denom.clone(), + amount: arbiter_fee, + }], + })); + } + + challenge.status = ChallengeStatus::Resolved; + challenge.resolved_at = Some(env.block.time); + + CHALLENGES.save(deps.storage, challenge_id, &challenge)?; + ATTESTATIONS.save(deps.storage, challenge.attestation_id, &attestation)?; + + // Remove the active-challenge mapping so re-challenge is possible after resolution + ATTESTATION_CHALLENGE.remove(deps.storage, challenge.attestation_id); + + Ok(Response::new() + .add_messages(messages) + .add_attribute("action", "resolve_challenge") + .add_attribute("challenge_id", challenge_id.to_string()) + .add_attribute("attestation_id", challenge.attestation_id.to_string()) + .add_attribute( + "resolution", + match &challenge.resolution { + Some(ChallengeResolution::AttesterWins) => "attester_wins", + Some(ChallengeResolution::ChallengerWins) => "challenger_wins", + None => "none", + }, + )) +} + +// --------------------------------------------------------------------------- +// ReleaseBond +// --------------------------------------------------------------------------- + +fn execute_release_bond( + deps: DepsMut, + env: Env, + info: MessageInfo, + attestation_id: u64, +) -> Result { + let mut attestation = ATTESTATIONS + .load(deps.storage, attestation_id) + .map_err(|_| ContractError::AttestationNotFound {})?; + + // Only the attester can release + if info.sender != attestation.attester { + return Err(ContractError::NotAttester {}); + } + + // Must be Active (not Bonded, Challenged, or terminal) + if attestation.status != AttestationStatus::Active { + return Err(ContractError::AttestationNotReleasable {}); + } + + // Lock period must have expired + if env.block.time < attestation.lock_expires_at { + return Err(ContractError::LockPeriodNotExpired {}); + } + + // No active challenge + if ATTESTATION_CHALLENGE.has(deps.storage, attestation_id) { + return Err(ContractError::AttestationNotReleasable {}); + } + + // Release bond to attester + attestation.status = AttestationStatus::Released; + ATTESTATIONS.save(deps.storage, attestation_id, &attestation)?; + + let msg = CosmosMsg::Bank(BankMsg::Send { + to_address: attestation.attester.to_string(), + amount: vec![attestation.bond.clone()], + }); + + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "release_bond") + .add_attribute("attestation_id", attestation_id.to_string()) + .add_attribute("amount", attestation.bond.amount)) +} + +// --------------------------------------------------------------------------- +// UpdateConfig — admin only +// --------------------------------------------------------------------------- + +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + arbiter_dao: Option, + min_challenge_deposit_ratio: Option, + arbiter_fee_ratio: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + if let Some(dao) = arbiter_dao { + config.arbiter_dao = deps.api.addr_validate(&dao)?; + } + if let Some(ratio) = min_challenge_deposit_ratio { + config.min_challenge_deposit_ratio = ratio; + } + if let Some(ratio) = arbiter_fee_ratio { + config.arbiter_fee_ratio = ratio; + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new().add_attribute("action", "update_config")) +} + +// --------------------------------------------------------------------------- +// AddAttestationType — admin only +// --------------------------------------------------------------------------- + +fn execute_add_attestation_type( + deps: DepsMut, + info: MessageInfo, + attestation_type: AttestationTypeInput, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + // Reject duplicates + if ATTESTATION_TYPES.has(deps.storage, &attestation_type.name) { + return Err(ContractError::AttestationTypeAlreadyExists { + name: attestation_type.name, + }); + } + + let att_type = AttestationType { + name: attestation_type.name.clone(), + min_bond: attestation_type.min_bond, + lock_period_days: attestation_type.lock_period_days, + challenge_window_days: attestation_type.challenge_window_days, + }; + + ATTESTATION_TYPES.save(deps.storage, &attestation_type.name, &att_type)?; + + Ok(Response::new() + .add_attribute("action", "add_attestation_type") + .add_attribute("name", attestation_type.name)) +} + +// ============================================================================ +// Query +// ============================================================================ + +#[entry_point] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(deps)?), + QueryMsg::Attestation { id } => to_json_binary(&query_attestation(deps, id)?), + QueryMsg::AttestationsByAttester { + attester, + start_after, + limit, + } => to_json_binary(&query_attestations_by_attester(deps, attester, start_after, limit)?), + QueryMsg::Challenge { id } => to_json_binary(&query_challenge(deps, id)?), + QueryMsg::ChallengesByAttestation { attestation_id } => { + to_json_binary(&query_challenges_by_attestation(deps, attestation_id)?) + } + QueryMsg::AttestationType { name } => { + to_json_binary(&query_attestation_type(deps, &name)?) + } + QueryMsg::AllAttestationTypes {} => to_json_binary(&query_all_attestation_types(deps)?), + QueryMsg::BondInfo { attestation_id } => { + to_json_binary(&query_bond_info(deps, env, attestation_id)?) + } + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { config }) +} + +fn query_attestation(deps: Deps, id: u64) -> StdResult { + let attestation = ATTESTATIONS.load(deps.storage, id)?; + Ok(AttestationResponse { attestation }) +} + +fn query_attestations_by_attester( + deps: Deps, + attester: String, + start_after: Option, + limit: Option, +) -> StdResult { + let attester_addr = deps.api.addr_validate(&attester)?; + let limit = limit.unwrap_or(30).min(100) as usize; + let start = start_after.unwrap_or(0); + + let attestations: Vec = ATTESTATIONS + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|item| item.ok()) + .filter(|(id, a)| a.attester == attester_addr && *id > start) + .take(limit) + .map(|(_, a)| a) + .collect(); + + Ok(AttestationsResponse { attestations }) +} + +fn query_challenge(deps: Deps, id: u64) -> StdResult { + let challenge = CHALLENGES.load(deps.storage, id)?; + Ok(ChallengeResponse { challenge }) +} + +fn query_challenges_by_attestation( + deps: Deps, + attestation_id: u64, +) -> StdResult { + let challenges: Vec = CHALLENGES + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|item| item.ok()) + .filter(|(_, c)| c.attestation_id == attestation_id) + .map(|(_, c)| c) + .collect(); + + Ok(ChallengesResponse { challenges }) +} + +fn query_attestation_type(deps: Deps, name: &str) -> StdResult { + let attestation_type = ATTESTATION_TYPES.load(deps.storage, name)?; + Ok(AttestationTypeResponse { attestation_type }) +} + +fn query_all_attestation_types(deps: Deps) -> StdResult { + let attestation_types: Vec = ATTESTATION_TYPES + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|item| item.ok()) + .map(|(_, at)| at) + .collect(); + + Ok(AllAttestationTypesResponse { attestation_types }) +} + +fn query_bond_info(deps: Deps, env: Env, attestation_id: u64) -> StdResult { + let attestation = ATTESTATIONS.load(deps.storage, attestation_id)?; + + let is_locked = env.block.time < attestation.lock_expires_at; + let is_challengeable = matches!( + attestation.status, + AttestationStatus::Bonded | AttestationStatus::Active + ) && env.block.time <= attestation.challenge_window_closes_at; + + Ok(BondInfoResponse { + attestation_id, + bond_amount: attestation.bond.amount, + bond_denom: attestation.bond.denom, + status: attestation.status, + lock_expires_at: attestation.lock_expires_at.seconds(), + challenge_window_closes_at: attestation.challenge_window_closes_at.seconds(), + is_locked, + is_challengeable, + }) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; + use cosmwasm_std::Timestamp; + + // ----------------------------------------------------------------------- + // Helpers — all addresses use MockApi::addr_make for valid bech32 + // ----------------------------------------------------------------------- + + const DENOM: &str = "uregen"; + + fn addr(label: &str) -> cosmwasm_std::Addr { + MockApi::default().addr_make(label) + } + + fn default_instantiate_msg() -> InstantiateMsg { + InstantiateMsg { + arbiter_dao: addr("arbiter_dao").to_string(), + min_challenge_deposit_ratio: Uint128::from(1000u128), // 10% + arbiter_fee_ratio: Uint128::from(500u128), // 5% + bond_denom: DENOM.to_string(), + attestation_types: vec![ + AttestationTypeInput { + name: "ProjectBoundary".to_string(), + min_bond: Uint128::from(500u128), + lock_period_days: 90, + challenge_window_days: 60, + }, + AttestationTypeInput { + name: "BaselineMeasurement".to_string(), + min_bond: Uint128::from(1000u128), + lock_period_days: 180, + challenge_window_days: 120, + }, + AttestationTypeInput { + name: "CreditIssuanceClaim".to_string(), + min_bond: Uint128::from(2000u128), + lock_period_days: 365, + challenge_window_days: 300, + }, + AttestationTypeInput { + name: "MethodologyValidation".to_string(), + min_bond: Uint128::from(5000u128), + lock_period_days: 730, + challenge_window_days: 600, + }, + ], + } + } + + fn setup_contract() -> ( + cosmwasm_std::OwnedDeps< + cosmwasm_std::MemoryStorage, + cosmwasm_std::testing::MockApi, + cosmwasm_std::testing::MockQuerier, + >, + Env, + ) { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&addr("admin"), &[]); + + instantiate(deps.as_mut(), env.clone(), info, default_instantiate_msg()).unwrap(); + (deps, env) + } + + fn env_at(secs: u64) -> Env { + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(secs); + env + } + + fn coin(amount: u128) -> Vec { + vec![Coin { + denom: DENOM.to_string(), + amount: Uint128::from(amount), + }] + } + + // ----------------------------------------------------------------------- + // Instantiate tests + // ----------------------------------------------------------------------- + + #[test] + fn test_instantiate_stores_config() { + let (deps, _env) = setup_contract(); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.admin, addr("admin")); + assert_eq!(config.arbiter_dao, addr("arbiter_dao")); + assert_eq!(config.min_challenge_deposit_ratio, Uint128::from(1000u128)); + assert_eq!(config.arbiter_fee_ratio, Uint128::from(500u128)); + assert_eq!(config.bond_denom, DENOM); + } + + #[test] + fn test_instantiate_registers_attestation_types() { + let (deps, _env) = setup_contract(); + let pb = ATTESTATION_TYPES + .load(deps.as_ref().storage, "ProjectBoundary") + .unwrap(); + assert_eq!(pb.min_bond, Uint128::from(500u128)); + assert_eq!(pb.lock_period_days, 90); + assert_eq!(pb.challenge_window_days, 60); + + let mv = ATTESTATION_TYPES + .load(deps.as_ref().storage, "MethodologyValidation") + .unwrap(); + assert_eq!(mv.min_bond, Uint128::from(5000u128)); + assert_eq!(mv.lock_period_days, 730); + } + + // ----------------------------------------------------------------------- + // CreateAttestation tests + // ----------------------------------------------------------------------- + + #[test] + fn test_create_attestation_happy_path() { + let (mut deps, env) = setup_contract(); + let attester = addr("attester1"); + let beneficiary = addr("beneficiary1"); + let info = message_info(&attester, &coin(1000)); + + let res = execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "BaselineMeasurement".to_string(), + attestation_iri: "koi://att/baseline-42".to_string(), + beneficiary: Some(beneficiary.to_string()), + }, + ) + .unwrap(); + + assert_eq!(res.attributes.len(), 5); + assert_eq!(res.attributes[0].value, "create_attestation"); + assert_eq!(res.attributes[1].value, "1"); // id + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.attester, attester); + assert_eq!(att.attestation_type, "BaselineMeasurement"); + assert_eq!(att.status, AttestationStatus::Bonded); + assert_eq!(att.bond.amount, Uint128::from(1000u128)); + assert_eq!(att.beneficiary, Some(beneficiary)); + assert!(att.activated_at.is_none()); + + // Next ID incremented + let next_id = NEXT_ATTESTATION_ID.load(deps.as_ref().storage).unwrap(); + assert_eq!(next_id, 2); + } + + #[test] + fn test_create_attestation_insufficient_bond() { + let (mut deps, env) = setup_contract(); + let info = message_info(&addr("attester1"), &coin(200)); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::CreateAttestation { + attestation_type: "ProjectBoundary".to_string(), + attestation_iri: "koi://att/boundary-1".to_string(), + beneficiary: None, + }, + ) + .unwrap_err(); + + assert_eq!( + err, + ContractError::InsufficientBond { + required: Uint128::from(500u128), + provided: Uint128::from(200u128), + } + ); + } + + #[test] + fn test_create_attestation_unknown_type() { + let (mut deps, env) = setup_contract(); + let info = message_info(&addr("attester1"), &coin(1000)); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::CreateAttestation { + attestation_type: "InvalidType".to_string(), + attestation_iri: "koi://att/invalid".to_string(), + beneficiary: None, + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::UnknownAttestationType {}); + } + + #[test] + fn test_create_attestation_no_bond_provided() { + let (mut deps, env) = setup_contract(); + let info = message_info(&addr("attester1"), &[]); // no funds + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::CreateAttestation { + attestation_type: "ProjectBoundary".to_string(), + attestation_iri: "koi://att/boundary-1".to_string(), + beneficiary: None, + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::NoBondProvided {}); + } + + #[test] + fn test_create_attestation_over_minimum_bond() { + let (mut deps, env) = setup_contract(); + // Bond 2x minimum + let info = message_info(&addr("attester1"), &coin(1000)); + + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::CreateAttestation { + attestation_type: "ProjectBoundary".to_string(), + attestation_iri: "koi://att/boundary-2x".to_string(), + beneficiary: None, + }, + ); + assert!(res.is_ok()); + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.bond.amount, Uint128::from(1000u128)); + } + + // ----------------------------------------------------------------------- + // ActivateAttestation tests + // ----------------------------------------------------------------------- + + #[test] + fn test_activate_attestation_happy_path() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + + // Create attestation + let info = message_info(&addr("attester1"), &coin(500)); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "ProjectBoundary".to_string(), + attestation_iri: "koi://att/pb-1".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + // Attempt activation before 48h — should fail + let env_early = env_at(base_time + 47 * 3600); + let err = execute( + deps.as_mut(), + env_early, + message_info(&addr("anyone"), &[]), + ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::LockPeriodNotExpired {}); + + // Activation after 48h — should succeed + let env_after = env_at(base_time + 49 * 3600); + execute( + deps.as_mut(), + env_after.clone(), + message_info(&addr("anyone"), &[]), + ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + ) + .unwrap(); + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::Active); + assert_eq!(att.activated_at, Some(env_after.block.time)); + } + + // ----------------------------------------------------------------------- + // ChallengeAttestation tests + // ----------------------------------------------------------------------- + + fn create_and_activate( + deps: &mut cosmwasm_std::OwnedDeps< + cosmwasm_std::MemoryStorage, + cosmwasm_std::testing::MockApi, + cosmwasm_std::testing::MockQuerier, + >, + base_time: u64, + ) { + // Create + let env = env_at(base_time); + let info = message_info(&addr("attester1"), &coin(1000)); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::CreateAttestation { + attestation_type: "BaselineMeasurement".to_string(), + attestation_iri: "koi://att/baseline-42".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + // Activate after 48h + let env = env_at(base_time + 49 * 3600); + execute( + deps.as_mut(), + env, + message_info(&addr("crank"), &[]), + ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + ) + .unwrap(); + } + + #[test] + fn test_challenge_active_attestation() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_and_activate(&mut deps, base_time); + + let challenger = addr("challenger1"); + + // Challenge with 10% deposit (100 uregen for 1000 bond) + let challenge_time = base_time + 50 * 3600; + let env = env_at(challenge_time); + let info = message_info(&challenger, &coin(100)); + + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/dispute-42".to_string(), + }, + ) + .unwrap(); + + assert_eq!(res.attributes[0].value, "challenge_attestation"); + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::Challenged); + + let challenge = CHALLENGES.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(challenge.challenger, challenger); + assert_eq!(challenge.deposit.amount, Uint128::from(100u128)); + } + + #[test] + fn test_challenge_during_activation_delay() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + + // Create attestation (stays in Bonded state) + let info = message_info(&addr("attester1"), &coin(1000)); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "BaselineMeasurement".to_string(), + attestation_iri: "koi://att/baseline-early".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + // Challenge during 48h delay — should succeed (Bonded is challengeable) + let env = env_at(base_time + 24 * 3600); + let info = message_info(&addr("challenger1"), &coin(100)); + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/early".to_string(), + }, + ); + assert!(res.is_ok()); + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::Challenged); + } + + #[test] + fn test_challenge_insufficient_deposit() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_and_activate(&mut deps, base_time); + + // Deposit only 50 uregen (need 100 = 10% of 1000 bond) + let env = env_at(base_time + 50 * 3600); + let info = message_info(&addr("challenger1"), &coin(50)); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/weak".to_string(), + }, + ) + .unwrap_err(); + + assert_eq!( + err, + ContractError::InsufficientChallengeDeposit { + required: Uint128::from(100u128), + provided: Uint128::from(50u128), + } + ); + } + + #[test] + fn test_challenge_window_expired() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_and_activate(&mut deps, base_time); + + // BaselineMeasurement challenge window = 120 days + let past_window = base_time + 121 * 24 * 3600; + let env = env_at(past_window); + let info = message_info(&addr("challenger1"), &coin(100)); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/late".to_string(), + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::ChallengeWindowClosed {}); + } + + #[test] + fn test_challenge_released_attestation() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_and_activate(&mut deps, base_time); + + // Release bond after lock period (180 days for BaselineMeasurement) + let env = env_at(base_time + 181 * 24 * 3600); + let info = message_info(&addr("attester1"), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ReleaseBond { attestation_id: 1 }, + ) + .unwrap(); + + // Try to challenge Released attestation + let info = message_info(&addr("challenger1"), &coin(100)); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/too-late".to_string(), + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::AttestationNotChallengeable {}); + } + + #[test] + fn test_single_active_challenge_only() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_and_activate(&mut deps, base_time); + + // First challenge succeeds + let env = env_at(base_time + 50 * 3600); + let info = message_info(&addr("challenger1"), &coin(100)); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/first".to_string(), + }, + ) + .unwrap(); + + // Second challenge fails — attestation is already in Challenged state, + // so it is no longer challengeable (not Bonded or Active). + let info = message_info(&addr("challenger2"), &coin(100)); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/second".to_string(), + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::AttestationNotChallengeable {}); + } + + // ----------------------------------------------------------------------- + // ResolveChallenge tests + // ----------------------------------------------------------------------- + + fn create_activate_and_challenge( + deps: &mut cosmwasm_std::OwnedDeps< + cosmwasm_std::MemoryStorage, + cosmwasm_std::testing::MockApi, + cosmwasm_std::testing::MockQuerier, + >, + base_time: u64, + ) { + create_and_activate(deps, base_time); + + let env = env_at(base_time + 50 * 3600); + let info = message_info(&addr("challenger1"), &coin(100)); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/dispute".to_string(), + }, + ) + .unwrap(); + } + + #[test] + fn test_resolve_attester_wins() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_activate_and_challenge(&mut deps, base_time); + + let attester = addr("attester1"); + + let env = env_at(base_time + 60 * 3600); + let info = message_info(&addr("arbiter_dao"), &[]); + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::AttesterWins, + }, + ) + .unwrap(); + + // Attester gets: 1000 (bond) + 100 (deposit) - 50 (5% fee) = 1050 + assert!(res.messages.len() >= 1); + let bank_msg = &res.messages[0].msg; + match bank_msg { + CosmosMsg::Bank(BankMsg::Send { to_address, amount }) => { + assert_eq!(to_address, &attester.to_string()); + assert_eq!(amount[0].amount, Uint128::from(1050u128)); + } + _ => panic!("expected bank send"), + } + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::ResolvedValid); + + let challenge = CHALLENGES.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(challenge.status, ChallengeStatus::Resolved); + assert_eq!( + challenge.resolution, + Some(ChallengeResolution::AttesterWins) + ); + } + + #[test] + fn test_resolve_challenger_wins() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_activate_and_challenge(&mut deps, base_time); + + let challenger = addr("challenger1"); + let arbiter = addr("arbiter_dao"); + + let env = env_at(base_time + 60 * 3600); + let info = message_info(&arbiter, &[]); + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::ChallengerWins, + }, + ) + .unwrap(); + + // Challenger gets: 500 (50% bond) + 100 (deposit) - 50 (5% fee) = 550 + // Community (arbiter_dao in v1) gets: 500 (50% bond) + // Arbiter fee: 50 + let msgs = &res.messages; + assert!(msgs.len() >= 2); + + // First message: challenger payment + match &msgs[0].msg { + CosmosMsg::Bank(BankMsg::Send { to_address, amount }) => { + assert_eq!(to_address, &challenger.to_string()); + assert_eq!(amount[0].amount, Uint128::from(550u128)); + } + _ => panic!("expected bank send to challenger"), + } + + // Second message: community pool (sent to arbiter_dao in v1) + match &msgs[1].msg { + CosmosMsg::Bank(BankMsg::Send { to_address, amount }) => { + assert_eq!(to_address, &arbiter.to_string()); + assert_eq!(amount[0].amount, Uint128::from(500u128)); + } + _ => panic!("expected bank send to community"), + } + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::Slashed); + } + + #[test] + fn test_resolve_unauthorized() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_activate_and_challenge(&mut deps, base_time); + + let env = env_at(base_time + 60 * 3600); + let info = message_info(&addr("random_user"), &[]); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::AttesterWins, + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::Unauthorized {}); + } + + #[test] + fn test_resolve_arbiter_is_attester() { + let (mut deps, _env) = setup_contract(); + + // Create attestation where attester IS the admin (who can also resolve) + let admin = addr("admin"); + let base_time = 1_000_000u64; + let env = env_at(base_time); + let info = message_info(&admin, &coin(1000)); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::CreateAttestation { + attestation_type: "BaselineMeasurement".to_string(), + attestation_iri: "koi://att/self-attest".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + // Activate + let env = env_at(base_time + 49 * 3600); + execute( + deps.as_mut(), + env, + message_info(&addr("crank"), &[]), + ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + ) + .unwrap(); + + // Challenge + let env = env_at(base_time + 50 * 3600); + let info = message_info(&addr("challenger1"), &coin(100)); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/conflict".to_string(), + }, + ) + .unwrap(); + + // Admin tries to resolve own attestation — arbiter conflict + let env = env_at(base_time + 60 * 3600); + let info = message_info(&admin, &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::AttesterWins, + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::ArbiterConflict {}); + } + + #[test] + fn test_resolve_already_resolved() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_activate_and_challenge(&mut deps, base_time); + + let arbiter = addr("arbiter_dao"); + + // Resolve first time + let env = env_at(base_time + 60 * 3600); + let info = message_info(&arbiter, &[]); + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::AttesterWins, + }, + ) + .unwrap(); + + // Attempt to re-resolve + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::ChallengerWins, + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::ChallengeAlreadyResolved {}); + } + + // ----------------------------------------------------------------------- + // ReleaseBond tests + // ----------------------------------------------------------------------- + + #[test] + fn test_release_bond_happy_path() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_and_activate(&mut deps, base_time); + + let attester = addr("attester1"); + + // BaselineMeasurement lock = 180 days + let env = env_at(base_time + 181 * 24 * 3600); + let info = message_info(&attester, &[]); + + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ReleaseBond { attestation_id: 1 }, + ) + .unwrap(); + + assert_eq!(res.attributes[0].value, "release_bond"); + + // Bond returned to attester + assert_eq!(res.messages.len(), 1); + match &res.messages[0].msg { + CosmosMsg::Bank(BankMsg::Send { to_address, amount }) => { + assert_eq!(to_address, &attester.to_string()); + assert_eq!(amount[0].amount, Uint128::from(1000u128)); + } + _ => panic!("expected bank send"), + } + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::Released); + } + + #[test] + fn test_release_bond_before_lock_expires() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_and_activate(&mut deps, base_time); + + // Only 90 days passed (need 180 for BaselineMeasurement) + let env = env_at(base_time + 90 * 24 * 3600); + let info = message_info(&addr("attester1"), &[]); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ReleaseBond { attestation_id: 1 }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::LockPeriodNotExpired {}); + } + + #[test] + fn test_release_bond_not_attester() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_and_activate(&mut deps, base_time); + + let env = env_at(base_time + 181 * 24 * 3600); + let info = message_info(&addr("random_user"), &[]); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ReleaseBond { attestation_id: 1 }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::NotAttester {}); + } + + #[test] + fn test_release_bond_during_challenge() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_activate_and_challenge(&mut deps, base_time); + + // Even after lock period, should fail because challenged + let env = env_at(base_time + 181 * 24 * 3600); + let info = message_info(&addr("attester1"), &[]); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ReleaseBond { attestation_id: 1 }, + ) + .unwrap_err(); + + // Attestation is in Challenged state, not Active + assert_eq!(err, ContractError::AttestationNotReleasable {}); + } + + // ----------------------------------------------------------------------- + // Full lifecycle test (SPEC acceptance test #1) + // ----------------------------------------------------------------------- + + #[test] + fn test_full_lifecycle_happy_path() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + let attester = addr("attester1"); + + // 1. Submit BaselineMeasurement with 1000 uregen bond + let info = message_info(&attester, &coin(1000)); + execute( + deps.as_mut(), + env_at(base_time), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "BaselineMeasurement".to_string(), + attestation_iri: "koi://att/baseline-lifecycle".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + // Status: Bonded + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::Bonded); + + // 2. 48h activation delay passes — activate + execute( + deps.as_mut(), + env_at(base_time + 49 * 3600), + message_info(&addr("crank"), &[]), + ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + ) + .unwrap(); + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::Active); + + // 3. No challenge filed within 120-day window (time passes) + + // 4. Lock period (180 days) expires — release bond + execute( + deps.as_mut(), + env_at(base_time + 181 * 24 * 3600), + message_info(&attester, &[]), + ExecuteMsg::ReleaseBond { attestation_id: 1 }, + ) + .unwrap(); + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::Released); + } + + // ----------------------------------------------------------------------- + // Admin tests + // ----------------------------------------------------------------------- + + #[test] + fn test_update_config() { + let (mut deps, env) = setup_contract(); + let admin = addr("admin"); + let new_arbiter = addr("new_arbiter"); + let info = message_info(&admin, &[]); + + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::UpdateConfig { + arbiter_dao: Some(new_arbiter.to_string()), + min_challenge_deposit_ratio: Some(Uint128::from(2000u128)), // 20% + arbiter_fee_ratio: None, + }, + ) + .unwrap(); + + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.arbiter_dao, new_arbiter); + assert_eq!(config.min_challenge_deposit_ratio, Uint128::from(2000u128)); + assert_eq!(config.arbiter_fee_ratio, Uint128::from(500u128)); // unchanged + } + + #[test] + fn test_update_config_unauthorized() { + let (mut deps, env) = setup_contract(); + let info = message_info(&addr("random_user"), &[]); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::UpdateConfig { + arbiter_dao: None, + min_challenge_deposit_ratio: None, + arbiter_fee_ratio: None, + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::Unauthorized {}); + } + + #[test] + fn test_add_attestation_type() { + let (mut deps, env) = setup_contract(); + let info = message_info(&addr("admin"), &[]); + + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::AddAttestationType { + attestation_type: AttestationTypeInput { + name: "CustomType".to_string(), + min_bond: Uint128::from(3000u128), + lock_period_days: 200, + challenge_window_days: 150, + }, + }, + ) + .unwrap(); + + let at = ATTESTATION_TYPES + .load(deps.as_ref().storage, "CustomType") + .unwrap(); + assert_eq!(at.min_bond, Uint128::from(3000u128)); + } + + #[test] + fn test_add_attestation_type_duplicate() { + let (mut deps, env) = setup_contract(); + let info = message_info(&addr("admin"), &[]); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::AddAttestationType { + attestation_type: AttestationTypeInput { + name: "ProjectBoundary".to_string(), // already exists + min_bond: Uint128::from(999u128), + lock_period_days: 10, + challenge_window_days: 5, + }, + }, + ) + .unwrap_err(); + + assert_eq!( + err, + ContractError::AttestationTypeAlreadyExists { + name: "ProjectBoundary".to_string() + } + ); + } + + // ----------------------------------------------------------------------- + // Query tests + // ----------------------------------------------------------------------- + + #[test] + fn test_query_config() { + let (deps, env) = setup_contract(); + let bin = query(deps.as_ref(), env, QueryMsg::Config {}).unwrap(); + let res: ConfigResponse = cosmwasm_std::from_json(bin).unwrap(); + assert_eq!(res.config.admin, addr("admin")); + } + + #[test] + fn test_query_attestation() { + let (mut deps, env) = setup_contract(); + let attester = addr("attester1"); + let info = message_info(&attester, &coin(500)); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "ProjectBoundary".to_string(), + attestation_iri: "koi://att/query-test".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + let bin = query(deps.as_ref(), env, QueryMsg::Attestation { id: 1 }).unwrap(); + let res: AttestationResponse = cosmwasm_std::from_json(bin).unwrap(); + assert_eq!(res.attestation.attestation_iri, "koi://att/query-test"); + assert_eq!(res.attestation.attester, attester); + } + + #[test] + fn test_query_attestations_by_attester() { + let (mut deps, env) = setup_contract(); + let attester1 = addr("attester1"); + let attester2 = addr("attester2"); + + // Create 2 attestations from attester1 + for i in 0..2 { + let info = message_info(&attester1, &coin(500)); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "ProjectBoundary".to_string(), + attestation_iri: format!("koi://att/by-attester-{}", i), + beneficiary: None, + }, + ) + .unwrap(); + } + + // Create 1 from attester2 + let info = message_info(&attester2, &coin(500)); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "ProjectBoundary".to_string(), + attestation_iri: "koi://att/by-attester2".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + let bin = query( + deps.as_ref(), + env, + QueryMsg::AttestationsByAttester { + attester: attester1.to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + let res: AttestationsResponse = cosmwasm_std::from_json(bin).unwrap(); + assert_eq!(res.attestations.len(), 2); + } + + #[test] + fn test_query_challenge() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_activate_and_challenge(&mut deps, base_time); + + let bin = query(deps.as_ref(), env, QueryMsg::Challenge { id: 1 }).unwrap(); + let res: ChallengeResponse = cosmwasm_std::from_json(bin).unwrap(); + assert_eq!(res.challenge.challenger, addr("challenger1")); + assert_eq!(res.challenge.attestation_id, 1); + } + + #[test] + fn test_query_attestation_type() { + let (deps, env) = setup_contract(); + let bin = query( + deps.as_ref(), + env, + QueryMsg::AttestationType { + name: "CreditIssuanceClaim".to_string(), + }, + ) + .unwrap(); + let res: AttestationTypeResponse = cosmwasm_std::from_json(bin).unwrap(); + assert_eq!(res.attestation_type.min_bond, Uint128::from(2000u128)); + assert_eq!(res.attestation_type.lock_period_days, 365); + } + + #[test] + fn test_query_all_attestation_types() { + let (deps, env) = setup_contract(); + let bin = query(deps.as_ref(), env, QueryMsg::AllAttestationTypes {}).unwrap(); + let res: AllAttestationTypesResponse = cosmwasm_std::from_json(bin).unwrap(); + assert_eq!(res.attestation_types.len(), 4); + } + + #[test] + fn test_query_bond_info() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + + let info = message_info(&addr("attester1"), &coin(1000)); + execute( + deps.as_mut(), + env_at(base_time), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "BaselineMeasurement".to_string(), + attestation_iri: "koi://att/bond-info-test".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + // Query while bond is active + let bin = query( + deps.as_ref(), + env_at(base_time + 100), + QueryMsg::BondInfo { attestation_id: 1 }, + ) + .unwrap(); + let res: BondInfoResponse = cosmwasm_std::from_json(bin).unwrap(); + assert_eq!(res.bond_amount, Uint128::from(1000u128)); + assert_eq!(res.bond_denom, DENOM); + assert!(res.is_locked); + assert!(res.is_challengeable); + } + + #[test] + fn test_query_bond_info_after_lock_expires() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_and_activate(&mut deps, base_time); + + // After lock period + let bin = query( + deps.as_ref(), + env_at(base_time + 181 * 24 * 3600), + QueryMsg::BondInfo { attestation_id: 1 }, + ) + .unwrap(); + let res: BondInfoResponse = cosmwasm_std::from_json(bin).unwrap(); + assert!(!res.is_locked); + assert!(!res.is_challengeable); // past challenge window too + } + + // ----------------------------------------------------------------------- + // Bond conservation invariant test + // ----------------------------------------------------------------------- + + #[test] + fn test_bond_conservation_on_slash() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_activate_and_challenge(&mut deps, base_time); + + // Bond: 1000, Deposit: 100, Fee: 50 (5% of 1000) + // Slash: + // challenger = 500 (50% bond) + 100 (deposit) - 50 (fee) = 550 + // community = 500 (50% bond) + // arbiter = 50 (fee) + // total out = 550 + 500 + 50 = 1100 + // total in = 1000 (bond) + 100 (deposit) = 1100 + + let env = env_at(base_time + 60 * 3600); + let info = message_info(&addr("arbiter_dao"), &[]); + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::ChallengerWins, + }, + ) + .unwrap(); + + // Verify all outflows sum to total inflows + let mut total_out = Uint128::zero(); + for sub in &res.messages { + match &sub.msg { + CosmosMsg::Bank(BankMsg::Send { amount, .. }) => { + for c in amount { + total_out = total_out.checked_add(c.amount).unwrap(); + } + } + _ => {} + } + } + + let total_in = Uint128::from(1000u128) + Uint128::from(100u128); // bond + deposit + assert_eq!(total_out, total_in); + } + + #[test] + fn test_bond_conservation_on_attester_wins() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_activate_and_challenge(&mut deps, base_time); + + // Bond: 1000, Deposit: 100, Fee: 50 + // Attester wins: + // attester = 1000 + 100 - 50 = 1050 + // arbiter = 50 + // total = 1100 = bond + deposit + + let env = env_at(base_time + 60 * 3600); + let info = message_info(&addr("arbiter_dao"), &[]); + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::AttesterWins, + }, + ) + .unwrap(); + + let mut total_out = Uint128::zero(); + for sub in &res.messages { + match &sub.msg { + CosmosMsg::Bank(BankMsg::Send { amount, .. }) => { + for c in amount { + total_out = total_out.checked_add(c.amount).unwrap(); + } + } + _ => {} + } + } + + let total_in = Uint128::from(1100u128); + assert_eq!(total_out, total_in); + } + + // ----------------------------------------------------------------------- + // Multiple attestation IDs + // ----------------------------------------------------------------------- + + #[test] + fn test_attestation_id_auto_increments() { + let (mut deps, env) = setup_contract(); + + for i in 0..3 { + let info = message_info(&addr("attester1"), &coin(500)); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "ProjectBoundary".to_string(), + attestation_iri: format!("koi://att/auto-{}", i), + beneficiary: None, + }, + ) + .unwrap(); + } + + assert!(ATTESTATIONS.load(deps.as_ref().storage, 1).is_ok()); + assert!(ATTESTATIONS.load(deps.as_ref().storage, 2).is_ok()); + assert!(ATTESTATIONS.load(deps.as_ref().storage, 3).is_ok()); + assert!(ATTESTATIONS.load(deps.as_ref().storage, 4).is_err()); + } + + // ----------------------------------------------------------------------- + // MethodologyValidation with high bond + // ----------------------------------------------------------------------- + + #[test] + fn test_methodology_validation_full_cycle() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + + // Bond 10000 uregen for MethodologyValidation (min 5000) + let info = message_info(&addr("validator1"), &coin(10_000)); + execute( + deps.as_mut(), + env_at(base_time), + info, + ExecuteMsg::CreateAttestation { + attestation_type: "MethodologyValidation".to_string(), + attestation_iri: "koi://att/methodology-v4".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + // Activate after 48h + execute( + deps.as_mut(), + env_at(base_time + 49 * 3600), + message_info(&addr("crank"), &[]), + ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + ) + .unwrap(); + + // Challenge with 10% deposit = 1000 + let env = env_at(base_time + 100 * 24 * 3600); + let info = message_info(&addr("challenger1"), &coin(1000)); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/methodology-flaw".to_string(), + }, + ) + .unwrap(); + + // Resolve: challenger wins + let env = env_at(base_time + 200 * 24 * 3600); + let info = message_info(&addr("arbiter_dao"), &[]); + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::ChallengerWins, + }, + ) + .unwrap(); + + // Verify distributions: + // arbiter_fee = 10000 * 500 / 10000 = 500 + // challenger = 5000 (50%) + 1000 (deposit) - 500 (fee) = 5500 + // community = 5000 (50%) + // arbiter = 500 + let mut total_out = Uint128::zero(); + for sub in &res.messages { + if let CosmosMsg::Bank(BankMsg::Send { amount, .. }) = &sub.msg { + for c in amount { + total_out = total_out.checked_add(c.amount).unwrap(); + } + } + } + assert_eq!(total_out, Uint128::from(11000u128)); // 10000 bond + 1000 deposit + + let att = ATTESTATIONS.load(deps.as_ref().storage, 1).unwrap(); + assert_eq!(att.status, AttestationStatus::Slashed); + } + + // ----------------------------------------------------------------------- + // Query challenges by attestation + // ----------------------------------------------------------------------- + + #[test] + fn test_query_challenges_by_attestation() { + let (mut deps, env) = setup_contract(); + let base_time = env.block.time.seconds(); + create_activate_and_challenge(&mut deps, base_time); + + let bin = query( + deps.as_ref(), + env, + QueryMsg::ChallengesByAttestation { attestation_id: 1 }, + ) + .unwrap(); + let res: ChallengesResponse = cosmwasm_std::from_json(bin).unwrap(); + assert_eq!(res.challenges.len(), 1); + assert_eq!(res.challenges[0].attestation_id, 1); + } + + // ----------------------------------------------------------------------- + // Admin resolve (v0 compatibility) + // ----------------------------------------------------------------------- + + #[test] + fn test_admin_can_resolve_challenge() { + let (mut deps, _env) = setup_contract(); + let base_time = 1_000_000u64; + let attester = addr("attester1"); + + // Create attestation from non-admin + let env = env_at(base_time); + let info = message_info(&attester, &coin(1000)); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::CreateAttestation { + attestation_type: "BaselineMeasurement".to_string(), + attestation_iri: "koi://att/admin-resolve".to_string(), + beneficiary: None, + }, + ) + .unwrap(); + + // Activate + execute( + deps.as_mut(), + env_at(base_time + 49 * 3600), + message_info(&addr("crank"), &[]), + ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + ) + .unwrap(); + + // Challenge + let info = message_info(&addr("challenger1"), &coin(100)); + execute( + deps.as_mut(), + env_at(base_time + 50 * 3600), + info, + ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "koi://evidence/admin-test".to_string(), + }, + ) + .unwrap(); + + // Admin resolves (v0 admin acts as arbiter) + let info = message_info(&addr("admin"), &[]); + let res = execute( + deps.as_mut(), + env_at(base_time + 60 * 3600), + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolutionInput::AttesterWins, + }, + ); + assert!(res.is_ok()); + } +} diff --git a/contracts/attestation-bonding/src/error.rs b/contracts/attestation-bonding/src/error.rs new file mode 100644 index 0000000..e564502 --- /dev/null +++ b/contracts/attestation-bonding/src/error.rs @@ -0,0 +1,62 @@ +use cosmwasm_std::{StdError, Uint128}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("unauthorized")] + Unauthorized {}, + + #[error("unknown attestation type")] + UnknownAttestationType {}, + + #[error("no bond provided: must send uregen funds")] + NoBondProvided {}, + + #[error("insufficient bond: required {required}, provided {provided}")] + InsufficientBond { required: Uint128, provided: Uint128 }, + + #[error("attestation not challengeable: must be in Active or Bonded state")] + AttestationNotChallengeable {}, + + #[error("challenge window closed")] + ChallengeWindowClosed {}, + + #[error("no challenge deposit provided")] + NoDepositProvided {}, + + #[error("insufficient challenge deposit: required {required}, provided {provided}")] + InsufficientChallengeDeposit { required: Uint128, provided: Uint128 }, + + #[error("attestation already has an active challenge")] + AlreadyChallenged {}, + + #[error("challenge not found")] + ChallengeNotFound {}, + + #[error("challenge already resolved")] + ChallengeAlreadyResolved {}, + + #[error("attestation not found")] + AttestationNotFound {}, + + #[error("lock period not expired")] + LockPeriodNotExpired {}, + + #[error("attestation not releasable: must be in Active state with no active challenge")] + AttestationNotReleasable {}, + + #[error("only the attester can release the bond")] + NotAttester {}, + + #[error("attestation type already exists: {name}")] + AttestationTypeAlreadyExists { name: String }, + + #[error("arbiter cannot be the attester or challenger")] + ArbiterConflict {}, + + #[error("overflow error")] + Overflow {}, +} diff --git a/contracts/attestation-bonding/src/lib.rs b/contracts/attestation-bonding/src/lib.rs new file mode 100644 index 0000000..a5abdbb --- /dev/null +++ b/contracts/attestation-bonding/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; diff --git a/contracts/attestation-bonding/src/msg.rs b/contracts/attestation-bonding/src/msg.rs new file mode 100644 index 0000000..25068e1 --- /dev/null +++ b/contracts/attestation-bonding/src/msg.rs @@ -0,0 +1,179 @@ +use cosmwasm_std::Uint128; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::state::{ + Attestation, AttestationType, Challenge, ChallengeResolution, Config, +}; + +// --------------------------------------------------------------------------- +// Instantiate +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct InstantiateMsg { + /// Arbiter DAO address (bech32). + pub arbiter_dao: String, + /// Minimum challenge deposit ratio in basis points (e.g. 1000 = 10%). + pub min_challenge_deposit_ratio: Uint128, + /// Arbiter fee ratio in basis points (e.g. 500 = 5%). + pub arbiter_fee_ratio: Uint128, + /// Bond denom (e.g. "uregen"). + pub bond_denom: String, + /// Initial attestation type configurations. + pub attestation_types: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct AttestationTypeInput { + pub name: String, + pub min_bond: Uint128, + pub lock_period_days: u64, + pub challenge_window_days: u64, +} + +// --------------------------------------------------------------------------- +// Execute +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + /// Submit a new bonded attestation. Must attach funds >= min_bond for the type. + CreateAttestation { + attestation_type: String, + attestation_iri: String, + beneficiary: Option, + }, + + /// Activate a bonded attestation after the 48h activation delay. + /// Can be called by anyone (permissionless crank). + ActivateAttestation { + attestation_id: u64, + }, + + /// Challenge an active or bonded attestation. Must attach deposit >= bond * challenge_deposit_ratio. + ChallengeAttestation { + attestation_id: u64, + evidence_iri: String, + }, + + /// Resolve a challenge. Only callable by the arbiter DAO address. + ResolveChallenge { + challenge_id: u64, + resolution: ChallengeResolutionInput, + }, + + /// Release bond after lock period expires. Only callable by the attester. + ReleaseBond { + attestation_id: u64, + }, + + /// Update contract configuration. Only callable by admin. + UpdateConfig { + arbiter_dao: Option, + min_challenge_deposit_ratio: Option, + arbiter_fee_ratio: Option, + }, + + /// Add a new attestation type. Only callable by admin. + AddAttestationType { + attestation_type: AttestationTypeInput, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ChallengeResolutionInput { + AttesterWins, + ChallengerWins, +} + +// --------------------------------------------------------------------------- +// Query +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + /// Returns the contract configuration. + Config {}, + /// Returns a single attestation by ID. + Attestation { id: u64 }, + /// Returns attestations submitted by a given attester address (paginated). + AttestationsByAttester { + attester: String, + start_after: Option, + limit: Option, + }, + /// Returns a single challenge by ID. + Challenge { id: u64 }, + /// Returns challenges filed against a given attestation. + ChallengesByAttestation { attestation_id: u64 }, + /// Returns a single attestation type definition. + AttestationType { name: String }, + /// Returns all registered attestation types. + AllAttestationTypes {}, + /// Returns bond info for an attestation (bond amount, status, lock expiry). + BondInfo { attestation_id: u64 }, +} + +// --------------------------------------------------------------------------- +// Query responses +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct ConfigResponse { + pub config: Config, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct AttestationResponse { + pub attestation: Attestation, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct AttestationsResponse { + pub attestations: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct ChallengeResponse { + pub challenge: Challenge, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct ChallengesResponse { + pub challenges: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct AttestationTypeResponse { + pub attestation_type: AttestationType, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct AllAttestationTypesResponse { + pub attestation_types: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct BondInfoResponse { + pub attestation_id: u64, + pub bond_amount: Uint128, + pub bond_denom: String, + pub status: crate::state::AttestationStatus, + pub lock_expires_at: u64, + pub challenge_window_closes_at: u64, + pub is_locked: bool, + pub is_challengeable: bool, +} + +impl From for ChallengeResolution { + fn from(input: ChallengeResolutionInput) -> Self { + match input { + ChallengeResolutionInput::AttesterWins => ChallengeResolution::AttesterWins, + ChallengeResolutionInput::ChallengerWins => ChallengeResolution::ChallengerWins, + } + } +} diff --git a/contracts/attestation-bonding/src/state.rs b/contracts/attestation-bonding/src/state.rs new file mode 100644 index 0000000..950a14b --- /dev/null +++ b/contracts/attestation-bonding/src/state.rs @@ -0,0 +1,126 @@ +use cosmwasm_std::{Addr, Coin, Timestamp, Uint128}; +use cw_storage_plus::{Item, Map}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Basis points divisor for ratio calculations (10_000 = 100%). +pub const BASIS_POINTS_DIVISOR: u128 = 10_000; + +/// Activation delay in seconds (48 hours). +pub const ACTIVATION_DELAY_SECS: u64 = 48 * 60 * 60; + +// --------------------------------------------------------------------------- +// Storage items +// --------------------------------------------------------------------------- + +pub const CONFIG: Item = Item::new("config"); +pub const ATTESTATION_TYPES: Map<&str, AttestationType> = Map::new("attestation_types"); +pub const ATTESTATIONS: Map = Map::new("attestations"); +pub const CHALLENGES: Map = Map::new("challenges"); +pub const ATTESTATION_CHALLENGE: Map = Map::new("attestation_challenge"); +pub const NEXT_ATTESTATION_ID: Item = Item::new("next_attestation_id"); +pub const NEXT_CHALLENGE_ID: Item = Item::new("next_challenge_id"); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Config { + /// Admin address (config authority in v0/v1). + pub admin: Addr, + /// Arbiter DAO address — the only address allowed to resolve challenges. + pub arbiter_dao: Addr, + /// Minimum challenge deposit as basis points of the bond (e.g. 1000 = 10%). + pub min_challenge_deposit_ratio: Uint128, + /// Arbiter fee as basis points of the bond (e.g. 500 = 5%). + pub arbiter_fee_ratio: Uint128, + /// Required bond denom. + pub bond_denom: String, +} + +// --------------------------------------------------------------------------- +// Attestation type configuration +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct AttestationType { + pub name: String, + /// Minimum bond in micro-denom units. + pub min_bond: Uint128, + /// Lock period in days — bond cannot be released before this expires. + pub lock_period_days: u64, + /// Challenge window in days — challenges accepted within this window. + pub challenge_window_days: u64, +} + +// --------------------------------------------------------------------------- +// Attestation +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum AttestationStatus { + /// Submitted and bond received, in 48h activation delay. + Bonded, + /// Activation delay passed, no challenge — fully active. + Active, + /// Under active challenge. + Challenged, + /// Challenge resolved in attester's favour. + ResolvedValid, + /// Challenge resolved against attester — bond slashed. + Slashed, + /// Lock period expired, bond released to attester. + Released, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Attestation { + pub id: u64, + pub attester: Addr, + pub attestation_type: String, + pub attestation_iri: String, + pub beneficiary: Option, + pub bond: Coin, + pub status: AttestationStatus, + pub bonded_at: Timestamp, + /// Set when activation delay passes and status moves to Active. + pub activated_at: Option, + /// Block time after which the bond can be released. + pub lock_expires_at: Timestamp, + /// Block time after which no new challenges are accepted. + pub challenge_window_closes_at: Timestamp, + /// Earliest time the attestation can transition from Bonded to Active. + pub activation_eligible_at: Timestamp, +} + +// --------------------------------------------------------------------------- +// Challenge +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum ChallengeStatus { + /// Challenge submitted, awaiting resolution. + Pending, + /// Resolved by arbiter DAO / admin. + Resolved, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum ChallengeResolution { + AttesterWins, + ChallengerWins, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Challenge { + pub id: u64, + pub attestation_id: u64, + pub challenger: Addr, + pub evidence_iri: String, + pub deposit: Coin, + pub status: ChallengeStatus, + pub created_at: Timestamp, + pub resolved_at: Option, + pub resolution: Option, +}