diff --git a/.gitignore b/.gitignore index 70eb8fd..fe7a273 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,10 @@ *.swp *.DS_Store -/target +target +.local-chain/ /testnet *.vscode /assets /test /types/pkg -/.idea -/.local -/devnet \ No newline at end of file +.idea \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d98d88d..705155b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,9 +120,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "ark-bls12-381" @@ -288,7 +288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -299,9 +299,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" dependencies = [ "zeroize", ] @@ -331,9 +331,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -342,14 +342,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -487,9 +488,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cassowary" @@ -514,9 +515,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -722,7 +723,7 @@ dependencies = [ "cfg-if", "commonware-macros", "paste", - "rand 0.8.6", + "rand 0.8.7", "rand_chacha 0.3.1", "thiserror 2.0.18", ] @@ -742,7 +743,7 @@ dependencies = [ "commonware-storage", "commonware-utils", "num-rational", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "rayon", "reed-solomon-simd", @@ -773,7 +774,7 @@ dependencies = [ "commonware-utils", "futures", "pin-project", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "rand_distr", "rayon", @@ -814,7 +815,7 @@ dependencies = [ "num-rational", "num-traits", "p256", - "rand 0.8.6", + "rand 0.8.7", "rand_chacha 0.3.1", "rand_core 0.6.4", "sha2", @@ -853,7 +854,7 @@ dependencies = [ "commonware-utils", "futures", "prometheus-client", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "thiserror 2.0.18", "tracing", @@ -916,7 +917,7 @@ dependencies = [ "num-integer", "num-rational", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "rand_distr", "thiserror 2.0.18", @@ -950,7 +951,7 @@ dependencies = [ "commonware-stream", "commonware-utils", "futures", - "rand 0.8.6", + "rand 0.8.7", "thiserror 2.0.18", "tracing", ] @@ -981,7 +982,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "prometheus-client", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "rayon", "sha2", @@ -1043,7 +1044,7 @@ dependencies = [ "commonware-runtime", "commonware-utils", "futures", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "thiserror 2.0.18", "x25519-dalek", @@ -1070,7 +1071,7 @@ dependencies = [ "num-traits", "parking_lot", "pin-project", - "rand 0.8.6", + "rand 0.8.7", "thiserror 2.0.18", "tokio", "zeroize", @@ -1203,9 +1204,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1213,27 +1214,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -1495,18 +1496,18 @@ dependencies = [ [[package]] name = "enum-ordinalize" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" dependencies = [ "proc-macro2", "quote", @@ -1777,7 +1778,7 @@ dependencies = [ "parking_lot", "portable-atomic", "quanta", - "rand 0.9.4", + "rand 0.9.5", "smallvec", "spinning_top", "web-time", @@ -1903,9 +1904,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1913,9 +1914,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -2284,11 +2285,11 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -2333,7 +2334,7 @@ dependencies = [ "jsonrpsee-types", "parking_lot", "pin-project", - "rand 0.9.4", + "rand 0.9.5", "rustc-hash", "serde", "serde_json", @@ -2495,9 +2496,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -2513,9 +2514,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -2592,9 +2593,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2682,7 +2683,7 @@ dependencies = [ "nunchi-crypto", "nunchi-dkg", "nunchi-rpc", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "serde", "thiserror 2.0.18", @@ -2717,7 +2718,7 @@ dependencies = [ "nunchi-dkg", "nunchi-mempool", "nunchi-rpc", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "serde", "serde_json", @@ -2746,11 +2747,32 @@ dependencies = [ "nunchi-common", "nunchi-dkg", "nunchi-mempool", - "rand 0.8.6", + "rand 0.8.7", "thiserror 2.0.18", "tracing", ] +[[package]] +name = "nunchi-clearinghouse" +version = "2026.6.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-runtime", + "futures", + "nunchi-clob", + "nunchi-coins", + "nunchi-common", + "nunchi-crypto", + "nunchi-oracle", + "nunchi-perpetuals", + "thiserror 2.0.18", +] + [[package]] name = "nunchi-clob" version = "2026.6.0" @@ -2826,6 +2848,7 @@ dependencies = [ "nunchi-authority", "nunchi-bridge", "nunchi-chain", + "nunchi-clearinghouse", "nunchi-clob", "nunchi-coins", "nunchi-common", @@ -2833,8 +2856,9 @@ dependencies = [ "nunchi-dkg", "nunchi-mempool", "nunchi-oracle", + "nunchi-perpetuals", "nunchi-rpc", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "serde", "serde_json", @@ -2913,7 +2937,7 @@ dependencies = [ "commonware-utils", "futures", "prometheus-client", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "serde", "thiserror 2.0.18", @@ -2942,6 +2966,26 @@ dependencies = [ "walkdir", ] +[[package]] +name = "nunchi-memclob" +version = "2026.6.0" +dependencies = [ + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-macros", + "commonware-p2p", + "commonware-runtime", + "commonware-utils", + "futures", + "governor", + "nunchi-clob", + "nunchi-common", + "nunchi-crypto", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "nunchi-mempool" version = "2026.6.0" @@ -2978,6 +3022,33 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "nunchi-perpetuals" +version = "2026.6.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-actor", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-runtime", + "commonware-utils", + "futures", + "jsonrpsee", + "nunchi-coins", + "nunchi-common", + "nunchi-crypto", + "nunchi-mempool", + "nunchi-oracle", + "nunchi-rpc", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "nunchi-rpc" version = "2026.6.0" @@ -3144,7 +3215,7 @@ dependencies = [ "futures-util", "opentelemetry", "percent-encoding", - "rand 0.9.4", + "rand 0.9.5", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -3372,7 +3443,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bitflags", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -3440,9 +3511,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3451,9 +3522,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3504,7 +3575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -3615,9 +3686,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -3627,9 +3698,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -3751,9 +3822,9 @@ checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3792,9 +3863,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -3819,9 +3890,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -3866,9 +3937,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4101,9 +4172,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4197,9 +4268,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4217,15 +4288,15 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.6", + "rand 0.8.7", "sha1", ] [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spinning_top" @@ -4413,9 +4484,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -4431,9 +4502,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "num-conv", @@ -4451,9 +4522,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -4481,9 +4552,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -4603,14 +4674,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4619,14 +4690,14 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tonic" @@ -5405,9 +5476,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5469,18 +5540,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -5563,9 +5634,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index 780cad3..3216072 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,16 +2,19 @@ members = [ "authority", "bridge", - "common", "chain", + "clearinghouse", "clob", "coins", + "common", "crypto", "dkg", + "memclob", "narae", "xtask", "mempool", "oracle", + "perpetuals", "rpc", "mcp", "examples/bridge-chain", @@ -44,8 +47,11 @@ nunchi-clob = { version = "2026.5.0", path = "clob" } nunchi-common = { version = "2026.5.0", path = "common" } nunchi-crypto = { version = "2026.5.0", path = "crypto" } nunchi-dkg = { version = "2026.5.0", path = "dkg" } +nunchi-clearinghouse = { version = "2026.5.0", path = "clearinghouse" } +nunchi-memclob = { version = "2026.5.0", path = "memclob" } nunchi-mempool = { version = "2026.5.0", path = "mempool" } nunchi-oracle = { version = "2026.5.0", path = "oracle" } +nunchi-perpetuals = { version = "2026.5.0", path = "perpetuals" } nunchi-rpc = { version = "2026.5.0", path = "rpc" } nunchi-coins-chain = { version = "2026.5.0", path = "examples/coins/chain" } nunchi-bridge-chain = { version = "2026.5.0", path = "examples/bridge-chain" } diff --git a/LICENSE.MD b/LICENSE.MD index 8502512..9d9f9c2 100644 --- a/LICENSE.MD +++ b/LICENSE.MD @@ -6,14 +6,14 @@ License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. Parameters -Licensor: Nunchi Ltd -Licensed Works: The Nunchi SDK source code in this repository. The Licensed Work is (c) 2026 Nunchi Ltd +Licensor: Nunchi Corp +Licensed Works: Azure-related code in Artillery. The Licensed Work is (c) 2024 Nunchi Corp Additional Use Grant: You may make use of the Licensed Work strictly for evaluation and/or non-production use only. Your use does not include offering the Licensed Work to third parties on a hosted or embedded basis. Change Date: Four years from the date the Licensed Work is published Change License: MPL 2.0 For information about commercial licensing arrangements for the Licensed Work, -please contact info@nunchi.trade. +please contact info@nunchi.trade or. Notice @@ -58,4 +58,4 @@ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND -TITLE. +TITLE. \ No newline at end of file diff --git a/authority/src/ledger.rs b/authority/src/ledger.rs index 198f63d..c9619eb 100644 --- a/authority/src/ledger.rs +++ b/authority/src/ledger.rs @@ -231,9 +231,6 @@ impl AuthorityLedger { } let initial_validators = sorted_unique(initial_validators).ok_or(AuthorityError::InvalidPolicy)?; - if initial_validators.is_empty() { - return Err(AuthorityError::InvalidPolicy); - } self.db.set_policy(&policy); self.db.set_validator_index(&initial_validators); diff --git a/authority/src/tests/ledger.rs b/authority/src/tests/ledger.rs index 0cdaf90..fbec3c9 100644 --- a/authority/src/tests/ledger.rs +++ b/authority/src/tests/ledger.rs @@ -374,315 +374,3 @@ fn nonce_mismatch_is_rejected() { ); }); } - -#[test] -fn configure_rejects_empty_validator_set() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let owners = vec![owner(1), owner(2), owner(3)]; - let mut ledger = AuthorityLedger::new(MemoryState::default()); - // An empty validator set must be rejected, matching the genesis path. - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Configure { - policy: policy(&owners, 2), - initial_validators: vec![], - epoch: 0, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::InvalidPolicy)); - }); -} - -// ----- configuration ----- - -#[test] -fn configure_twice_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, validators) = configured().await; - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Configure { - policy: policy(&owners, 2), - initial_validators: validators, - epoch: 0, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::AlreadyConfigured)); - }); -} - -#[test] -fn configure_rejects_signer_outside_owner_set() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let owners = vec![owner(1), owner(2), owner(3)]; - let stranger = owner(9); - let mut ledger = AuthorityLedger::new(MemoryState::default()); - // The signer is not part of the policy it is trying to install. - let result = submit( - &mut ledger, - &stranger, - AuthorityOperation::Configure { - policy: policy(&owners, 2), - initial_validators: vec![validator(10), validator(11)], - epoch: 0, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::Unauthorized)); - }); -} - -// ----- authorization: owner gating ----- - -#[test] -fn propose_before_configure_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let mut ledger = AuthorityLedger::new(MemoryState::default()); - let result = submit( - &mut ledger, - &owner(1), - AuthorityOperation::Propose { - change: RegistryChange::AddValidator { - validator: validator(12), - }, - effective_epoch: 1, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::NotConfigured)); - }); -} - -#[test] -fn non_owner_cannot_propose() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, _owners, _) = configured().await; - let stranger = owner(9); - let result = submit( - &mut ledger, - &stranger, - AuthorityOperation::Propose { - change: RegistryChange::AddValidator { - validator: validator(12), - }, - effective_epoch: 1, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::Unauthorized)); - }); -} - -// ----- proposal lifecycle ----- - -#[test] -fn duplicate_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let change = RegistryChange::AddValidator { - validator: validator(12), - }; - submit( - &mut ledger, - &owners[0], - AuthorityOperation::Propose { - change: change.clone(), - effective_epoch: 1, - }, - 0, - ) - .await - .unwrap(); - // The same change at the same epoch collides on the proposal id. - let result = submit( - &mut ledger, - &owners[1], - AuthorityOperation::Propose { - change, - effective_epoch: 1, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalExists)); - }); -} - -#[test] -fn approve_unknown_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let missing = crate::proposal_id( - &RegistryChange::AddValidator { - validator: validator(12), - }, - 1, - ); - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Approve { proposal: missing }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalNotFound(missing))); - }); -} - -#[test] -fn execute_unknown_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let missing = crate::proposal_id( - &RegistryChange::AddValidator { - validator: validator(12), - }, - 1, - ); - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Execute { proposal: missing }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalNotFound(missing))); - }); -} - -#[test] -fn approve_executed_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let change = RegistryChange::AddValidator { - validator: validator(12), - }; - let proposal = crate::proposal_id(&change, 1); - govern(&mut ledger, &owners, change, 1, 0).await; - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Approve { proposal }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalAlreadyExecuted)); - }); -} - -#[test] -fn execute_executed_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let change = RegistryChange::AddValidator { - validator: validator(12), - }; - let proposal = crate::proposal_id(&change, 1); - govern(&mut ledger, &owners, change, 1, 0).await; - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Execute { proposal }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalAlreadyExecuted)); - }); -} - -// ----- validator set changes (checked at execution) ----- - -#[test] -fn add_active_validator_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, validators) = configured().await; - // validators[0] is already active from configuration. - let change = RegistryChange::AddValidator { - validator: validators[0].clone(), - }; - let proposal = crate::proposal_id(&change, 1); - submit( - &mut ledger, - &owners[0], - AuthorityOperation::Propose { - change, - effective_epoch: 1, - }, - 0, - ) - .await - .unwrap(); - submit( - &mut ledger, - &owners[1], - AuthorityOperation::Approve { proposal }, - 0, - ) - .await - .unwrap(); - let result = submit( - &mut ledger, - &owners[2], - AuthorityOperation::Execute { proposal }, - 0, - ) - .await; - assert_eq!( - result, - Err(AuthorityError::ValidatorAlreadyActive(Box::new( - validators[0].clone() - ))) - ); - }); -} - -#[test] -fn remove_unknown_validator_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let unknown = validator(99); - let change = RegistryChange::RemoveValidator { - validator: unknown.clone(), - }; - let proposal = crate::proposal_id(&change, 1); - submit( - &mut ledger, - &owners[0], - AuthorityOperation::Propose { - change, - effective_epoch: 1, - }, - 0, - ) - .await - .unwrap(); - submit( - &mut ledger, - &owners[1], - AuthorityOperation::Approve { proposal }, - 0, - ) - .await - .unwrap(); - let result = submit( - &mut ledger, - &owners[2], - AuthorityOperation::Execute { proposal }, - 0, - ) - .await; - assert_eq!( - result, - Err(AuthorityError::UnknownValidator(Box::new(unknown))) - ); - }); -} diff --git a/clearinghouse/Cargo.toml b/clearinghouse/Cargo.toml new file mode 100644 index 0000000..1ee775e --- /dev/null +++ b/clearinghouse/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "nunchi-clearinghouse" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Trade settlement layer routing CLOB fills to perpetuals and spot consumers." + +[lints] +workspace = true + +[features] +default = [] + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +commonware-macros = { workspace = true } +nunchi-clob = { workspace = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +nunchi-perpetuals = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +commonware-runtime = { workspace = true } +futures = { workspace = true } +nunchi-coins = { workspace = true } +nunchi-oracle = { workspace = true } diff --git a/clearinghouse/README.md b/clearinghouse/README.md new file mode 100644 index 0000000..4551b51 --- /dev/null +++ b/clearinghouse/README.md @@ -0,0 +1,29 @@ +# nunchi-clearinghouse + +`nunchi-clearinghouse` routes deterministic CLOB `Fill` records to settlement +consumers. It sits between `nunchi-clob` (matching) and domain modules such as +`nunchi-perpetuals` (margin, funding, liquidations). + +## Responsibilities + +- Register settlement markets that link a CLOB market to a consumer domain +- Idempotently settle CLOB fills into perpetuals position state +- Apply counterparty updates atomically for both maker and taker + +## Non-responsibilities + +- Order matching or book state (`nunchi-clob`) +- Margin math, funding, or liquidation (`nunchi-perpetuals`) +- Cooperative batch clearing for house liquidity (future `nunchi-cbc` client) + +## v1 operations + +- `RegisterPerpsMarket` — bind `clob_market` → `perps_market` +- `SettleFill` — read a CLOB fill and apply perps settlement for both parties + +## Execution flow + +``` +Trader → CLOB PlaceOrder → Fill +Proposer/anyone → Clearinghouse SettleFill → Perps apply_fill_settlement (maker + taker) +``` diff --git a/clearinghouse/src/db.rs b/clearinghouse/src/db.rs new file mode 100644 index 0000000..9fb28a2 --- /dev/null +++ b/clearinghouse/src/db.rs @@ -0,0 +1,144 @@ +//! Persistence layer for the clearinghouse module. + +use crate::{ + ClearinghouseError, SettlementMarket, SettlementMarketId, CLEARINGHOUSE_NAMESPACE, +}; +use async_trait::async_trait; +use commonware_codec::{Encode, Read, ReadExt}; +use commonware_cryptography::sha256::Digest; +use nunchi_clob::{FillId, MarketId as ClobMarketId}; +use nunchi_common::{Address, Namespace, StateStore}; + +const NS: Namespace = Namespace::new(CLEARINGHOUSE_NAMESPACE); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + Nonce = 0, + SettlementMarket = 1, + ClobMarketIndex = 2, + SettledFill = 3, +} + +impl From for u8 { + fn from(table: Table) -> Self { + table as Self + } +} + +fn encoded(value: &T) -> Vec { + value.encode().as_ref().to_vec() +} + +fn decoded>(bytes: &[u8]) -> Result { + let mut buf = bytes; + T::read(&mut buf).map_err(|err| ClearinghouseError::Storage(err.to_string())) +} + +fn nonce_key(account: &Address) -> Digest { + NS.key(Table::Nonce, account.encode().as_ref()) +} + +fn settlement_market_key(id: &SettlementMarketId) -> Digest { + NS.key(Table::SettlementMarket, id.encode().as_ref()) +} + +fn clob_market_index_key(clob_market: &ClobMarketId) -> Digest { + NS.key(Table::ClobMarketIndex, clob_market.encode().as_ref()) +} + +fn settled_fill_key(fill: &FillId) -> Digest { + NS.key(Table::SettledFill, fill.encode().as_ref()) +} + +/// Typed state access required by [`crate::ClearinghouseLedger`]. +#[async_trait] +pub trait ClearinghouseDB { + async fn nonce(&self, account: &Address) -> Result; + + fn set_nonce(&mut self, account: &Address, nonce: u64); + + async fn settlement_market( + &self, + id: &SettlementMarketId, + ) -> Result, ClearinghouseError>; + + fn set_settlement_market(&mut self, market: &SettlementMarket); + + async fn settlement_market_for_clob( + &self, + clob_market: &ClobMarketId, + ) -> Result, ClearinghouseError>; + + fn set_clob_market_index(&mut self, clob_market: &ClobMarketId, id: &SettlementMarketId); + + async fn is_fill_settled(&self, fill: &FillId) -> Result; + + fn mark_fill_settled(&mut self, fill: &FillId); +} + +#[async_trait] +impl ClearinghouseDB for S { + async fn nonce(&self, account: &Address) -> Result { + match StateStore::get(self, &nonce_key(account)) + .await + .map_err(|err| ClearinghouseError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_nonce(&mut self, account: &Address, nonce: u64) { + StateStore::set(self, nonce_key(account), encoded(&nonce)); + } + + async fn settlement_market( + &self, + id: &SettlementMarketId, + ) -> Result, ClearinghouseError> { + match StateStore::get(self, &settlement_market_key(id)) + .await + .map_err(|err| ClearinghouseError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_settlement_market(&mut self, market: &SettlementMarket) { + StateStore::set(self, settlement_market_key(&market.id), encoded(market)); + } + + async fn settlement_market_for_clob( + &self, + clob_market: &ClobMarketId, + ) -> Result, ClearinghouseError> { + let key = clob_market_index_key(clob_market); + let id = match StateStore::get(self, &key) + .await + .map_err(|err| ClearinghouseError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes)?, + None => return Ok(None), + }; + self.settlement_market(&id).await + } + + fn set_clob_market_index(&mut self, clob_market: &ClobMarketId, id: &SettlementMarketId) { + StateStore::set(self, clob_market_index_key(clob_market), encoded(id)); + } + + async fn is_fill_settled(&self, fill: &FillId) -> Result { + Ok( + StateStore::get(self, &settled_fill_key(fill)) + .await + .map_err(|err| ClearinghouseError::Storage(err.to_string()))? + .is_some(), + ) + } + + fn mark_fill_settled(&mut self, fill: &FillId) { + StateStore::set(self, settled_fill_key(fill), vec![1]); + } +} diff --git a/clearinghouse/src/ledger.rs b/clearinghouse/src/ledger.rs new file mode 100644 index 0000000..f7906f1 --- /dev/null +++ b/clearinghouse/src/ledger.rs @@ -0,0 +1,231 @@ +use crate::{ + derive_settlement_market_id, ClearinghouseDB, ClearinghouseOperation, SettlementDomain, + SettlementMarket, SettlementMarketId, Transaction, +}; +use nunchi_clob::{ClobDB, ClobError, ClobLedger, Fill, FillId, Side as ClobSide}; +use nunchi_common::{Address, Authorization, RuntimeContext, StateStore}; +use nunchi_crypto::SignatureError; +use nunchi_perpetuals::{PerpetualDB, PerpetualError, Side as PerpsSide}; +use thiserror::Error; + +/// Deterministic clearinghouse state-machine errors. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ClearinghouseError { + #[error("bad clearinghouse transaction signature: {0}")] + BadSignature(#[from] SignatureError), + #[error("nonce mismatch for {account:?}: expected {expected}, got {actual}")] + NonceMismatch { + account: Box
, + expected: u64, + actual: u64, + }, + #[error("nonce overflow")] + NonceOverflow, + #[error("settlement market already registered for clob market {0:?}")] + DuplicateSettlementMarket(nunchi_clob::MarketId), + #[error("settlement market not found")] + SettlementMarketNotFound, + #[error("fill not found")] + FillNotFound, + #[error("fill already settled")] + FillAlreadySettled, + #[error("unauthorized clearinghouse operation")] + Unauthorized, + #[error("clob module error: {0}")] + Clob(#[from] ClobError), + #[error("perpetuals module error: {0}")] + Perpetuals(#[from] PerpetualError), + #[error("invalid fill quantity")] + InvalidFillQuantity, + #[error("state storage error: {0}")] + Storage(String), +} + +/// Settlement ledger routing CLOB fills to consumer modules. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClearinghouseLedger { + db: D, +} + +impl ClearinghouseLedger { + /// Wrap a database backend as a clearinghouse ledger. + pub fn new(db: D) -> Self { + Self { db } + } + + /// Borrow the underlying database. + pub fn db(&self) -> &D { + &self.db + } + + /// Consume the ledger, returning the underlying database. + pub fn into_inner(self) -> D { + self.db + } +} + +impl ClearinghouseLedger { + pub async fn nonce(&self, id: &Address) -> Result { + ClearinghouseDB::nonce(&self.db, id).await + } + + /// Validate and apply a signed clearinghouse transaction. + pub async fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + self.ensure_authorized(tx)?; + let expected = ClearinghouseDB::nonce(&self.db, &tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(ClearinghouseError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + self.apply_operation(&tx.payload.operation, context).await?; + let next_nonce = expected + .checked_add(1) + .ok_or(ClearinghouseError::NonceOverflow)?; + ClearinghouseDB::set_nonce(&mut self.db, &tx.account_id, next_nonce); + Ok(()) + } + + pub async fn register_perps_market( + &mut self, + clob_market: nunchi_clob::MarketId, + perps_market: nunchi_perpetuals::MarketId, + ) -> Result { + if self + .db + .settlement_market_for_clob(&clob_market) + .await? + .is_some() + { + return Err(ClearinghouseError::DuplicateSettlementMarket(clob_market)); + } + let domain = SettlementDomain::Perps(perps_market); + let id = derive_settlement_market_id(&clob_market, &domain); + let market = SettlementMarket { + id, + clob_market, + domain, + }; + self.db.set_settlement_market(&market); + self.db.set_clob_market_index(&clob_market, &id); + Ok(id) + } + + pub async fn settle_fill( + &mut self, + fill_id: FillId, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + if self.db.is_fill_settled(&fill_id).await? { + return Err(ClearinghouseError::FillAlreadySettled); + } + let fill = self + .db + .fill(&fill_id) + .await? + .ok_or(ClearinghouseError::FillNotFound)?; + if fill.base_quantity == 0 { + return Err(ClearinghouseError::InvalidFillQuantity); + } + let settlement = self + .db + .settlement_market_for_clob(&fill.market) + .await? + .ok_or(ClearinghouseError::SettlementMarketNotFound)?; + match settlement.domain { + SettlementDomain::Perps(perps_market) => { + self.settle_perps_fill(&fill, perps_market, context) + .await?; + } + } + self.db.mark_fill_settled(&fill_id); + Ok(()) + } + + pub async fn commit_and_settle_fill( + &mut self, + fill: Fill, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + let fill_id = fill.id; + ClobLedger::new(&mut self.db) + .record_fill(&fill) + .await?; + self.settle_fill(fill_id, context).await + } + + async fn settle_perps_fill( + &mut self, + fill: &Fill, + perps_market: nunchi_perpetuals::MarketId, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + let maker_side = clob_side_to_perps_side(fill.taker_side.opposite()); + let taker_side = clob_side_to_perps_side(fill.taker_side); + nunchi_perpetuals::apply_fill_settlement( + &mut self.db, + fill.maker.clone(), + perps_market, + maker_side, + fill.price, + fill.base_quantity, + context, + ) + .await?; + nunchi_perpetuals::apply_fill_settlement( + &mut self.db, + fill.taker.clone(), + perps_market, + taker_side, + fill.price, + fill.base_quantity, + context, + ) + .await?; + Ok(()) + } + + async fn apply_operation( + &mut self, + operation: &ClearinghouseOperation, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + match operation { + ClearinghouseOperation::RegisterPerpsMarket { + clob_market, + perps_market, + } => { + self.register_perps_market(*clob_market, *perps_market) + .await?; + } + ClearinghouseOperation::SettleFill { fill } => { + self.settle_fill(*fill, context).await?; + } + ClearinghouseOperation::CommitAndSettleFill { fill } => { + self.commit_and_settle_fill(fill.as_ref().clone(), context).await?; + } + } + Ok(()) + } + + fn ensure_authorized(&self, tx: &Transaction) -> Result<(), ClearinghouseError> { + tx.verify()?; + match &tx.authorization { + Authorization::Single { .. } => Ok(()), + Authorization::Multisig { .. } => Err(ClearinghouseError::Unauthorized), + } + } +} + +fn clob_side_to_perps_side(side: ClobSide) -> PerpsSide { + match side { + ClobSide::Bid => PerpsSide::Long, + ClobSide::Ask => PerpsSide::Short, + } +} diff --git a/clearinghouse/src/lib.rs b/clearinghouse/src/lib.rs new file mode 100644 index 0000000..5b8735b --- /dev/null +++ b/clearinghouse/src/lib.rs @@ -0,0 +1,23 @@ +//! Trade settlement layer routing CLOB fills to consumer modules. + +commonware_macros::stability_scope!(ALPHA { +mod db; +mod ledger; +mod settlement; +#[cfg(test)] +mod tests; +mod transaction; +mod types; + +pub use db::ClearinghouseDB; +pub use ledger::{ClearinghouseError, ClearinghouseLedger}; +pub use settlement::commit_and_settle_transactions; +pub use nunchi_common::{Address, Authorization}; +pub use transaction::{ClearinghouseOperation, Transaction, TransactionPayload}; +pub use types::{ + derive_settlement_market_id, SettlementDomain, SettlementMarket, SettlementMarketId, +}; + +/// Domain separator used for clearinghouse transaction signatures and state keys. +pub const CLEARINGHOUSE_NAMESPACE: &[u8] = b"_NUNCHI_CLEARINGHOUSE"; +}); diff --git a/clearinghouse/src/settlement.rs b/clearinghouse/src/settlement.rs new file mode 100644 index 0000000..cf22ec9 --- /dev/null +++ b/clearinghouse/src/settlement.rs @@ -0,0 +1,24 @@ +//! Helpers for turning off-chain matcher output into clearinghouse transactions. + +use crate::{ClearinghouseOperation, Transaction}; +use nunchi_clob::Fill; +use nunchi_crypto::PrivateKey; + +/// Build signed clearinghouse transactions that commit memclob fills and settle perps. +pub fn commit_and_settle_transactions( + fills: &[Fill], + settler: &PrivateKey, + start_nonce: u64, +) -> Vec { + fills + .iter() + .enumerate() + .map(|(idx, fill)| { + Transaction::sign( + settler, + start_nonce + idx as u64, + ClearinghouseOperation::CommitAndSettleFill { fill: Box::new(fill.clone()) }, + ) + }) + .collect() +} diff --git a/clearinghouse/src/tests/mod.rs b/clearinghouse/src/tests/mod.rs new file mode 100644 index 0000000..e2ef55b --- /dev/null +++ b/clearinghouse/src/tests/mod.rs @@ -0,0 +1,416 @@ +mod transaction; + +use std::collections::BTreeMap; + +use commonware_codec::Encode; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use futures::executor::block_on; +use nunchi_clob::{ + market_id, AssetId, ClobLedger, ClobOperation, Fill, FillId, OrderId, Side as ClobSide, + Transaction as ClobTransaction, +}; +use nunchi_coins::{CoinDB, CoinSpec, TokenDefinition, TokenName, TokenSymbol}; +use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; +use nunchi_crypto::PrivateKey; +use nunchi_oracle::{ + IntervalKey, NamespaceId, OracleLedger, OracleOperation, Transaction as OracleTransaction, +}; +use nunchi_perpetuals::{ + derive_position_id_for_side, CoinId, OraclePricePayload, PerpetualLedger, Side as PerpsSide, + BPS_DENOMINATOR, DEFAULT_LIQUIDATION_REWARD_BPS, +}; + +use crate::{ + ClearinghouseDB, ClearinghouseLedger, ClearinghouseOperation, SettlementDomain, + Transaction as ClearingTx, +}; + +#[derive(Default)] +struct MemoryStore { + values: BTreeMap>>, +} + +impl StateStore for MemoryStore { + async fn get(&self, key: &Digest) -> Result>, StateError> { + Ok(self.values.get(key).cloned().flatten()) + } + + fn set(&mut self, key: Digest, value: Vec) { + self.values.insert(key, Some(value)); + } + + fn remove(&mut self, key: Digest) { + self.values.insert(key, None); + } +} + +fn context(timestamp_ms: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height: timestamp_ms / 100, + timestamp_ms, + block_digest: None, + } +} + +fn digest(seed: &'static [u8]) -> Digest { + Sha256::hash(seed) +} + +fn coin(seed: &'static [u8]) -> CoinId { + CoinId(digest(seed)) +} + +fn asset(seed: &'static [u8]) -> AssetId { + AssetId(digest(seed)) +} + +fn namespace() -> NamespaceId { + NamespaceId(digest(b"perps-price-feed")) +} + +fn address(signer: &PrivateKey) -> Address { + Address::external(&signer.public_key()) +} + +const MARKET_TICK: u128 = 5; +const MARKET_LOT: u128 = 2; +const FILL_PRICE: u128 = 100; +const FILL_QTY: u128 = 4; + +fn clob_market_id() -> nunchi_clob::MarketId { + market_id(&asset(b"base"), &asset(b"quote"), MARKET_TICK, MARKET_LOT) +} + +fn seed_collateral(store: &mut MemoryStore, owner: &Address, amount: u128) { + let issuer = address(&PrivateKey::from_seed(999)); + let token = TokenDefinition::from_spec( + coin(b"usdc"), + issuer, + CoinSpec::new( + TokenSymbol::new("USDC").unwrap(), + TokenName::new("USD Coin").unwrap(), + 6, + 1_000_000_000, + None, + ), + ); + store.set_token(&token); + store.set_balance(owner, &coin(b"usdc"), amount); +} + +fn append_price( + store: &mut MemoryStore, + writer: &PrivateKey, + nonce: u64, + market: Digest, + price: u128, + timestamp_ms: u64, +) { + let payload = OraclePricePayload { + market, + price, + price_decimals: 0, + source_timestamp_ms: timestamp_ms, + }; + let append = OracleTransaction::sign( + writer, + nonce, + OracleOperation::AppendRecord { + namespace: namespace(), + interval: IntervalKey::new(timestamp_ms / 1_000), + payload: payload.encode().as_ref().to_vec(), + proof: None, + }, + ); + let mut oracle = OracleLedger::new(store); + block_on(oracle.apply_transaction(&append, context(timestamp_ms))).unwrap(); +} + +fn setup_perps_market(store: &mut MemoryStore, clob_market: nunchi_clob::MarketId) -> Digest { + let oracle_writer = PrivateKey::from_seed(2); + let perps_market = block_on( + PerpetualLedger::new(&mut *store).create_market( + coin(b"base"), + coin(b"usd"), + coin(b"usdc"), + namespace(), + address(&oracle_writer), + Some(clob_market.0), + 1_000, + 10_000, + 0, + 10 * BPS_DENOMINATOR, + 500, + 3_600_000, + 100, + DEFAULT_LIQUIDATION_REWARD_BPS, + ), + ) + .unwrap(); + append_price(store, &oracle_writer, 0, perps_market, FILL_PRICE, 1_000); + block_on( + PerpetualLedger::new(&mut *store).refresh_market_from_oracle(perps_market, context(1_000)), + ) + .unwrap(); + perps_market +} + +fn register_perps_market( + store: &mut MemoryStore, + clob_market: nunchi_clob::MarketId, + perps_market: Digest, +) { + let settler = PrivateKey::from_seed(3); + block_on( + ClearinghouseLedger::new(store).apply_transaction( + &ClearingTx::sign( + &settler, + 0, + ClearinghouseOperation::RegisterPerpsMarket { + clob_market, + perps_market, + }, + ), + context(1_000), + ), + ) + .unwrap(); +} + +fn test_fill( + clob_market: nunchi_clob::MarketId, + maker: &PrivateKey, + taker: &PrivateKey, + timestamp_ms: u64, + sequence: u64, +) -> Fill { + let ctx = context(timestamp_ms); + Fill { + id: FillId(Sha256::hash( + &[b"clearinghouse-test-fill", sequence.to_le_bytes().as_ref()].concat(), + )), + market: clob_market, + maker_order: OrderId(Sha256::hash(&[b"maker-order", sequence.to_le_bytes().as_ref()].concat())), + taker_order: OrderId(Sha256::hash(&[b"taker-order", sequence.to_le_bytes().as_ref()].concat())), + maker: address(maker), + taker: address(taker), + taker_side: ClobSide::Bid, + price: FILL_PRICE, + base_quantity: FILL_QTY, + quote_quantity: FILL_PRICE * FILL_QTY, + sequence, + written_at_height: ctx.height, + written_at_ms: ctx.timestamp_ms, + } +} + +fn record_test_fill( + store: &mut MemoryStore, + clob_market: nunchi_clob::MarketId, + maker: &PrivateKey, + taker: &PrivateKey, + timestamp_ms: u64, + sequence: u64, +) -> Fill { + let fill = test_fill(clob_market, maker, taker, timestamp_ms, sequence); + block_on(ClobLedger::new(store).record_fill(&fill)).unwrap(); + fill +} + +#[test] +fn settle_fill_opens_counterparty_perps_positions() { + let maker = PrivateKey::from_seed(1); + let taker = PrivateKey::from_seed(2); + let settler = PrivateKey::from_seed(3); + let mut store = MemoryStore::default(); + let clob_market = clob_market_id(); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &maker, + 0, + ClobOperation::CreateMarket { + base_asset: asset(b"base"), + quote_asset: asset(b"quote"), + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, + }, + ), + context(1_000), + ), + ) + .unwrap(); + + let perps_market = setup_perps_market(&mut store, clob_market); + register_perps_market(&mut store, clob_market, perps_market); + + seed_collateral(&mut store, &address(&maker), 100_000); + seed_collateral(&mut store, &address(&taker), 100_000); + + let fill = record_test_fill(&mut store, clob_market, &maker, &taker, 3_000, 0); + + block_on( + ClearinghouseLedger::new(&mut store).apply_transaction( + &ClearingTx::sign( + &settler, + 1, + ClearinghouseOperation::SettleFill { fill: fill.id }, + ), + context(4_000), + ), + ) + .unwrap(); + + let perps = PerpetualLedger::new(&mut store); + let market = block_on(perps.market(&perps_market)).unwrap().unwrap(); + assert_eq!(market.long_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + assert_eq!(market.short_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + + let maker_position = block_on( + perps.position(&derive_position_id_for_side( + &address(&maker), + &perps_market, + PerpsSide::Short, + )), + ) + .unwrap() + .unwrap(); + assert_eq!(maker_position.quantity, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + + let taker_position = block_on( + perps.position(&derive_position_id_for_side( + &address(&taker), + &perps_market, + PerpsSide::Long, + )), + ) + .unwrap() + .unwrap(); + assert_eq!(taker_position.quantity, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + + let registered = block_on( + ClearinghouseLedger::new(&mut store) + .db() + .settlement_market_for_clob(&clob_market), + ) + .unwrap() + .unwrap(); + assert!(matches!( + registered.domain, + SettlementDomain::Perps(id) if id == perps_market + )); +} + +#[test] +fn settle_fill_is_idempotent_guarded() { + let maker = PrivateKey::from_seed(10); + let taker = PrivateKey::from_seed(11); + let mut store = MemoryStore::default(); + let clob_market = clob_market_id(); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &maker, + 0, + ClobOperation::CreateMarket { + base_asset: asset(b"base"), + quote_asset: asset(b"quote"), + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, + }, + ), + context(1_000), + ), + ) + .unwrap(); + + let perps_market = setup_perps_market(&mut store, clob_market); + block_on( + ClearinghouseLedger::new(&mut store).register_perps_market(clob_market, perps_market), + ) + .unwrap(); + + seed_collateral(&mut store, &address(&maker), 100_000); + seed_collateral(&mut store, &address(&taker), 100_000); + + let fill = record_test_fill(&mut store, clob_market, &maker, &taker, 3_000, 1); + let fill_id = fill.id; + + block_on( + ClearinghouseLedger::new(&mut store).settle_fill(fill_id, context(4_000)), + ) + .unwrap(); + let err = block_on( + ClearinghouseLedger::new(&mut store).settle_fill(fill_id, context(5_000)), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "fill already settled"); +} + +#[test] +fn commit_and_settle_fill_applies_memclob_style_fill() { + let maker = PrivateKey::from_seed(20); + let taker = PrivateKey::from_seed(21); + let settler = PrivateKey::from_seed(22); + let mut store = MemoryStore::default(); + let clob_market = clob_market_id(); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &maker, + 0, + ClobOperation::CreateMarket { + base_asset: asset(b"base"), + quote_asset: asset(b"quote"), + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, + }, + ), + context(1_000), + ), + ) + .unwrap(); + + let perps_market = setup_perps_market(&mut store, clob_market); + register_perps_market(&mut store, clob_market, perps_market); + seed_collateral(&mut store, &address(&maker), 100_000); + seed_collateral(&mut store, &address(&taker), 100_000); + + let fill = test_fill(clob_market, &maker, &taker, 3_000, 2); + + block_on( + ClearinghouseLedger::new(&mut store).apply_transaction( + &ClearingTx::sign( + &settler, + 0, + ClearinghouseOperation::CommitAndSettleFill { fill: Box::new(fill.clone()) }, + ), + context(4_000), + ), + ) + .unwrap(); + + let perps = PerpetualLedger::new(&mut store); + let market = block_on(perps.market(&perps_market)).unwrap().unwrap(); + assert_eq!(market.long_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + assert_eq!(market.short_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); +} + +#[test] +fn commit_and_settle_transactions_builds_signed_batch() { + use crate::commit_and_settle_transactions; + + let settler = PrivateKey::from_seed(40); + let fill = test_fill(clob_market_id(), &PrivateKey::from_seed(41), &PrivateKey::from_seed(42), 1_000, 3); + let txs = commit_and_settle_transactions(std::slice::from_ref(&fill), &settler, 5); + assert_eq!(txs.len(), 1); + assert_eq!(txs[0].payload.nonce, 5); + assert!(matches!( + txs[0].payload.operation, + ClearinghouseOperation::CommitAndSettleFill { .. } + )); +} diff --git a/clearinghouse/src/tests/transaction.rs b/clearinghouse/src/tests/transaction.rs new file mode 100644 index 0000000..6a1da06 --- /dev/null +++ b/clearinghouse/src/tests/transaction.rs @@ -0,0 +1,59 @@ +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{Hasher, Sha256}; +use nunchi_clob::{Fill, FillId, OrderId, Side as ClobSide}; +use nunchi_common::Address; +use nunchi_crypto::PrivateKey; + +use crate::{ClearinghouseOperation, Transaction}; + +fn sample_fill() -> Fill { + Fill { + id: FillId(Sha256::hash(b"fill")), + market: nunchi_clob::MarketId(Sha256::hash(b"clob-market")), + maker_order: OrderId(Sha256::hash(b"maker")), + taker_order: OrderId(Sha256::hash(b"taker")), + maker: Address::external(&PrivateKey::from_seed(1).public_key()), + taker: Address::external(&PrivateKey::from_seed(2).public_key()), + taker_side: ClobSide::Bid, + price: 100, + base_quantity: 4, + quote_quantity: 400, + sequence: 0, + written_at_height: 1, + written_at_ms: 1_000, + } +} + +#[test] +fn clearinghouse_operation_codec_roundtrips() { + let settler = PrivateKey::from_seed(3); + let clob_market = nunchi_clob::MarketId(Sha256::hash(b"clob")); + let perps_market = Sha256::hash(b"perps"); + + for (nonce, operation) in [ + ( + 0, + ClearinghouseOperation::RegisterPerpsMarket { + clob_market, + perps_market, + }, + ), + ( + 1, + ClearinghouseOperation::SettleFill { + fill: FillId(Sha256::hash(b"fill-id")), + }, + ), + ( + 2, + ClearinghouseOperation::CommitAndSettleFill { + fill: Box::new(sample_fill()), + }, + ), + ] { + let tx = Transaction::sign(&settler, nonce, operation.clone()); + let decoded = Transaction::decode(tx.encode().as_ref()).expect("decode transaction"); + assert_eq!(decoded.payload.operation, operation); + assert_eq!(decoded.payload.nonce, nonce); + } +} diff --git a/clearinghouse/src/transaction.rs b/clearinghouse/src/transaction.rs new file mode 100644 index 0000000..6c8519f --- /dev/null +++ b/clearinghouse/src/transaction.rs @@ -0,0 +1,104 @@ +use crate::CLEARINGHOUSE_NAMESPACE; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_clob::{Fill, FillId, MarketId as ClobMarketId}; +use nunchi_common::Operation as CommonOperation; +use nunchi_perpetuals::MarketId as PerpsMarketId; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OperationTag { + RegisterPerpsMarket = 0, + SettleFill = 1, + CommitAndSettleFill = 2, +} + +impl TryFrom for OperationTag { + type Error = Error; + + fn try_from(tag: u8) -> Result { + match tag { + 0 => Ok(Self::RegisterPerpsMarket), + 1 => Ok(Self::SettleFill), + 2 => Ok(Self::CommitAndSettleFill), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +/// Clearinghouse state-machine operation carried by a signed Nunchi transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ClearinghouseOperation { + /// Link a CLOB market to a perpetuals market for fill settlement. + RegisterPerpsMarket { + clob_market: ClobMarketId, + perps_market: PerpsMarketId, + }, + /// Apply a previously matched CLOB fill to registered settlement consumers. + SettleFill { fill: FillId }, + /// Commit an off-chain fill to CLOB state and settle it into perpetuals in one step. + CommitAndSettleFill { fill: Box }, +} + +impl Write for ClearinghouseOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::RegisterPerpsMarket { + clob_market, + perps_market, + } => { + (OperationTag::RegisterPerpsMarket as u8).write(buf); + clob_market.write(buf); + perps_market.write(buf); + } + Self::SettleFill { fill } => { + (OperationTag::SettleFill as u8).write(buf); + fill.write(buf); + } + Self::CommitAndSettleFill { fill } => { + (OperationTag::CommitAndSettleFill as u8).write(buf); + fill.write(buf); + } + } + } +} + +impl Read for ClearinghouseOperation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match OperationTag::try_from(u8::read(buf)?)? { + OperationTag::RegisterPerpsMarket => Ok(Self::RegisterPerpsMarket { + clob_market: ClobMarketId::read(buf)?, + perps_market: PerpsMarketId::read(buf)?, + }), + OperationTag::SettleFill => Ok(Self::SettleFill { + fill: FillId::read(buf)?, + }), + OperationTag::CommitAndSettleFill => Ok(Self::CommitAndSettleFill { + fill: Box::new(Fill::read(buf)?), + }), + } + } +} + +impl EncodeSize for ClearinghouseOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::RegisterPerpsMarket { + clob_market, + perps_market, + } => clob_market.encode_size() + perps_market.encode_size(), + Self::SettleFill { fill } => fill.encode_size(), + Self::CommitAndSettleFill { fill } => fill.encode_size(), + } + } +} + +impl CommonOperation for ClearinghouseOperation { + const NAMESPACE: &'static [u8] = CLEARINGHOUSE_NAMESPACE; +} + +/// Signed clearinghouse transaction payload. +pub type TransactionPayload = nunchi_common::TransactionPayload; +/// Signed clearinghouse transaction. +pub type Transaction = nunchi_common::Transaction; diff --git a/clearinghouse/src/types.rs b/clearinghouse/src/types.rs new file mode 100644 index 0000000..c48c73e --- /dev/null +++ b/clearinghouse/src/types.rs @@ -0,0 +1,95 @@ +use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use nunchi_clob::MarketId as ClobMarketId; +use nunchi_perpetuals::MarketId as PerpsMarketId; + +/// Stable identifier for a registered settlement market. +pub type SettlementMarketId = Digest; + +/// Consumer domain that receives settled CLOB fills. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SettlementDomain { + Perps(PerpsMarketId), +} + +/// Registered mapping from a CLOB market to a settlement consumer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SettlementMarket { + pub id: SettlementMarketId, + pub clob_market: ClobMarketId, + pub domain: SettlementDomain, +} + +/// Derive a settlement market id from its CLOB market and domain. +pub fn derive_settlement_market_id( + clob_market: &ClobMarketId, + domain: &SettlementDomain, +) -> SettlementMarketId { + let mut hasher = Sha256::new(); + hasher.update(super::CLEARINGHOUSE_NAMESPACE); + hasher.update(b"/settlement-market/"); + hasher.update(clob_market.encode().as_ref()); + match domain { + SettlementDomain::Perps(market) => { + hasher.update(&[0u8]); + hasher.update(market.encode().as_ref()); + } + } + hasher.finalize() +} + +impl Write for SettlementDomain { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Perps(market) => { + 0u8.write(buf); + market.write(buf); + } + } + } +} + +impl Read for SettlementDomain { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::Perps(PerpsMarketId::read(buf)?)), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for SettlementDomain { + fn encode_size(&self) -> usize { + 1 + match self { + Self::Perps(market) => market.encode_size(), + } + } +} + +impl Write for SettlementMarket { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.clob_market.write(buf); + self.domain.write(buf); + } +} + +impl Read for SettlementMarket { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: SettlementMarketId::read(buf)?, + clob_market: ClobMarketId::read(buf)?, + domain: SettlementDomain::read(buf)?, + }) + } +} + +impl EncodeSize for SettlementMarket { + fn encode_size(&self) -> usize { + self.id.encode_size() + self.clob_market.encode_size() + self.domain.encode_size() + } +} diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs index 7cbf48c..ddbc58c 100644 --- a/clob/src/ledger.rs +++ b/clob/src/ledger.rs @@ -139,6 +139,24 @@ impl ClobLedger { Ok(fills) } + /// Persist an off-chain matched fill into on-chain CLOB state. + pub async fn record_fill(&mut self, fill: &Fill) -> Result<(), ClobError> { + if self.db.fill(&fill.id).await?.is_some() { + return Ok(()); + } + if self.db.market(&fill.market).await?.is_none() { + return Err(ClobError::MarketNotFound); + } + let mut market_fill_ids = self.db.market_fills(&fill.market).await?; + if market_fill_ids.len() >= MAX_FILLS_PER_MARKET { + return Err(ClobError::FillIndexFull); + } + market_fill_ids.push(fill.id); + self.db.set_fill(fill); + self.db.set_market_fills(&fill.market, &market_fill_ids); + Ok(()) + } + /// Validate and apply a signed CLOB transaction. pub async fn apply_transaction( &mut self, diff --git a/examples/coins/chain/Cargo.toml b/examples/coins/chain/Cargo.toml index dfd085b..483b2b2 100644 --- a/examples/coins/chain/Cargo.toml +++ b/examples/coins/chain/Cargo.toml @@ -14,11 +14,13 @@ nunchi-authority = { workspace = true } nunchi-bridge = { workspace = true } nunchi-chain = { workspace = true } nunchi-clob = { workspace = true } +nunchi-clearinghouse = { workspace = true } nunchi-common = { workspace = true } nunchi-crypto = { workspace = true } nunchi-dkg = { workspace = true } nunchi-mempool = { workspace = true } nunchi-oracle = { workspace = true } +nunchi-perpetuals = { workspace = true, features = ["mempool", "mock-execution"] } nunchi-rpc = { workspace = true } commonware-actor = { workspace = true } commonware-broadcast = { workspace = true } diff --git a/examples/coins/chain/src/execution.rs b/examples/coins/chain/src/execution.rs index 72b55b3..b5f773f 100644 --- a/examples/coins/chain/src/execution.rs +++ b/examples/coins/chain/src/execution.rs @@ -10,6 +10,9 @@ use nunchi_clob::ClobMailbox; use nunchi_coins::{rpc::CoinQuery, Address, CoinId, Ledger, LedgerError, TokenDefinition}; use nunchi_common::QmdbReader; use nunchi_mempool::MempoolHandle; +use nunchi_perpetuals::{ + rpc::PerpetualQuery, Market, MarketId, PerpetualError, PerpetualLedger, Position, PositionId, +}; pub use nunchi_chain::SharedAppliedHeight; @@ -43,8 +46,8 @@ where } } - /// A read-only coin query backend over this node's committed databases, suitable for - /// serving the coin RPC (see [`crate::rpc::module`]). + /// A read-only query backend over this node's committed databases, suitable for + /// serving the chain RPC (see [`crate::rpc::module`]). pub fn query(&self) -> StatefulQuery { StatefulQuery::new(self.stateful.clone()) } @@ -80,6 +83,10 @@ where async fn ledger(&self) -> Ledger> { Ledger::new(QmdbReader::new(self.stateful.subscribe_databases().await)) } + + async fn perpetuals(&self) -> PerpetualLedger> { + PerpetualLedger::new(QmdbReader::new(self.stateful.subscribe_databases().await)) + } } #[async_trait] @@ -104,3 +111,26 @@ where Ok(QmdbReader::new(db).root().await) } } + +#[async_trait] +impl PerpetualQuery for StatefulQuery +where + E: Context + Spawner + Metrics + Clock + rand::Rng + Send + Sync + 'static, +{ + async fn nonce(&self, account: Address) -> Result { + self.perpetuals().await.nonce(&account).await + } + + async fn market(&self, market: MarketId) -> Result, PerpetualError> { + self.perpetuals().await.market(&market).await + } + + async fn position(&self, position: PositionId) -> Result, PerpetualError> { + self.perpetuals().await.position(&position).await + } + + async fn state_root(&self) -> Result { + let db = self.stateful.subscribe_databases().await; + Ok(QmdbReader::new(db).root().await) + } +} diff --git a/examples/coins/chain/src/genesis.rs b/examples/coins/chain/src/genesis.rs index 4852806..22c89d1 100644 --- a/examples/coins/chain/src/genesis.rs +++ b/examples/coins/chain/src/genesis.rs @@ -9,6 +9,7 @@ use nunchi_common::{ CommitState, Namespace, Overlay, QmdbConfig, QmdbState, StateError, StateStore, }; use nunchi_oracle::{OracleGenesis, OracleLedger}; +use nunchi_perpetuals::{PerpetualLedger, PerpetualsGenesis}; use serde::{Deserialize, Serialize}; use std::{fs, path::Path}; use thiserror::Error; @@ -37,6 +38,8 @@ pub struct ChainGenesis { pub oracle: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub clob: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub perpetuals: Option, } #[derive(Debug, Error)] @@ -53,6 +56,8 @@ pub enum GenesisError { Oracle(#[from] nunchi_oracle::OracleError), #[error("clob genesis error: {0}")] Clob(#[from] nunchi_clob::ClobError), + #[error("perpetuals genesis error: {0}")] + Perpetuals(#[from] nunchi_perpetuals::PerpetualError), #[error("state error: {0}")] State(#[from] StateError), #[error("existing chain state was initialized with a different genesis")] @@ -115,6 +120,11 @@ impl ChainGenesis { ledger.apply_genesis(clob).await?; overlay = ledger.into_inner(); } + if let Some(perpetuals) = &self.perpetuals { + let mut ledger = PerpetualLedger::new(overlay); + ledger.apply_genesis(perpetuals).await?; + overlay = ledger.into_inner(); + } set_genesis_marker(&mut overlay, fingerprint); overlay.commit(); state.commit().await?; diff --git a/examples/coins/chain/src/rpc.rs b/examples/coins/chain/src/rpc.rs index 6b40304..7bd020b 100644 --- a/examples/coins/chain/src/rpc.rs +++ b/examples/coins/chain/src/rpc.rs @@ -5,9 +5,15 @@ use jsonrpsee::{ core::{RegisterMethodError, RpcResult}, RpcModule, }; -use nunchi_coins::rpc::{CoinQuery, CoinsMempoolRpc, CoinsRpc, MempoolIngress}; +use nunchi_coins::rpc::{ + CoinQuery, CoinsMempoolRpc, CoinsRpc, MempoolIngress as CoinMempoolIngress, +}; use nunchi_coins::Transaction as CoinTransaction; use nunchi_mempool::{AdmissionError, MempoolHandle, TxStatus}; +use nunchi_perpetuals::rpc::{ + MempoolIngress as PerpetualMempoolIngress, PerpetualQuery, PerpetualsMempoolRpc, PerpetualsRpc, +}; +use nunchi_perpetuals::Transaction as PerpetualTransaction; use nunchi_rpc::{encode_hex, module_error, RpcBuildError, RpcRouter}; use serde::{Deserialize, Serialize}; @@ -59,7 +65,7 @@ impl ChainMempoolIngress { } #[jsonrpsee::core::async_trait] -impl MempoolIngress for ChainMempoolIngress { +impl CoinMempoolIngress for ChainMempoolIngress { async fn submit(&self, transaction: CoinTransaction) -> Result { self.mempool.submit(transaction.into()).await } @@ -69,6 +75,17 @@ impl MempoolIngress for ChainMempoolIngress { } } +#[jsonrpsee::core::async_trait] +impl PerpetualMempoolIngress for ChainMempoolIngress { + async fn submit(&self, transaction: PerpetualTransaction) -> Result { + self.mempool.submit(transaction.into()).await + } + + async fn status(&self, digest: Digest) -> Option { + self.mempool.status(digest).await + } +} + /// Build the complete coins-chain RPC module. /// /// Downstream applications can follow this pattern: create one router over their node context, @@ -80,14 +97,14 @@ pub fn module( applied_height: SharedAppliedHeight, ) -> Result>, RpcBuildError> where - Q: CoinQuery, + Q: CoinQuery + PerpetualQuery, { + let ingress = ChainMempoolIngress::new(mempool); let mut router = RpcRouter::new(RpcContext::new(query.clone(), applied_height)); - nunchi_coins::rpc::register(&mut router, CoinsRpc::new(query))?; - nunchi_coins::rpc::register_mempool( - &mut router, - CoinsMempoolRpc::new(ChainMempoolIngress::new(mempool)), - )?; + nunchi_coins::rpc::register(&mut router, CoinsRpc::new(query.clone()))?; + nunchi_coins::rpc::register_mempool(&mut router, CoinsMempoolRpc::new(ingress.clone()))?; + nunchi_perpetuals::rpc::register(&mut router, PerpetualsRpc::new(query))?; + nunchi_perpetuals::rpc::register_mempool(&mut router, PerpetualsMempoolRpc::new(ingress))?; router.merge(chain_module(router.context())?)?; Ok(router.into_module()) } @@ -96,13 +113,11 @@ fn chain_module( context: std::sync::Arc>, ) -> Result>, RegisterMethodError> where - Q: CoinQuery, + Q: CoinQuery + PerpetualQuery, { let mut module = RpcModule::from_arc(context); module.register_async_method("chain.status", |_raw, context, _| async move { - let state_root = context - .query - .state_root() + let state_root = CoinQuery::state_root(&context.query) .await .map_err(|err| module_error(format!("failed to read state root: {err}")))?; let applied_height = *context.applied_height.lock().await; diff --git a/examples/coins/chain/src/runtime.rs b/examples/coins/chain/src/runtime.rs index 89cd380..af5ab98 100644 --- a/examples/coins/chain/src/runtime.rs +++ b/examples/coins/chain/src/runtime.rs @@ -4,9 +4,11 @@ use commonware_codec::EncodeSize; use nunchi_authority::{AuthorityError, AuthorityLedger}; use nunchi_bridge::{escrow_address, BridgeError, BridgeLedger, BridgeOperation}; use nunchi_clob::{ClobError, ClobLedger}; +use nunchi_clearinghouse::{ClearinghouseError, ClearinghouseLedger}; use nunchi_coins::{CoinId, Ledger, LedgerError}; use nunchi_common::{EventSink, NoopEventSink, Overlay, Runtime, RuntimeContext, StateStore}; use nunchi_oracle::{OracleError, OracleLedger}; +use nunchi_perpetuals::{PerpetualError, PerpetualLedger}; use crate::Transaction; @@ -25,6 +27,10 @@ pub enum RuntimeError { Bridge(#[from] BridgeError), #[error("clob module error: {0}")] Clob(#[from] ClobError), + #[error("perpetuals module error: {0}")] + Perpetuals(#[from] PerpetualError), + #[error("clearinghouse module error: {0}")] + Clearinghouse(#[from] ClearinghouseError), } impl RuntimeError { @@ -36,6 +42,16 @@ impl RuntimeError { | Self::Oracle(OracleError::Storage(_)) | Self::Bridge(BridgeError::Storage(_)) | Self::Clob(ClobError::Storage(_)) + | Self::Perpetuals(PerpetualError::Storage(_)) + | Self::Perpetuals(PerpetualError::Coin(LedgerError::Storage(_))) + | Self::Clearinghouse(ClearinghouseError::Storage(_)) + | Self::Clearinghouse(ClearinghouseError::Perpetuals( + PerpetualError::Storage(_) + )) + | Self::Clearinghouse(ClearinghouseError::Perpetuals( + PerpetualError::Coin(LedgerError::Storage(_)) + )) + | Self::Clearinghouse(ClearinghouseError::Clob(ClobError::Storage(_))) ) } } @@ -83,8 +99,6 @@ where S: StateStore + Send + Sync, Events: EventSink + Send, { - // Fee ante: charge the authorizing account before module dispatch. The fee is staged in the - // same overlay as the operation, so a failed transaction reverts its fee. let mut fees = Ledger::new(&mut *state); fees.charge_fee( transaction.account_id(), @@ -107,11 +121,6 @@ where ledger.apply_transaction(transaction, context).await?; } Transaction::Bridge(transaction) => { - // Escrow the locked source coins and record the transfer atomically. Both writes go - // into an inner overlay that is committed only if the whole lock succeeds, so a bridge - // validation failure after the escrow move (bad nonce, unconfigured chain, self-bridge, - // unsupported authorization, ...) reverts everything, without relying on the caller to - // discard partial writes. The bridge crate itself stays decoupled from coins. let mut overlay = Overlay::new(&mut *state); match &transaction.payload.operation { BridgeOperation::Lock { @@ -138,6 +147,37 @@ where let mut ledger = ClobLedger::new(state); ledger.apply_transaction(transaction, context).await?; } + Transaction::Perpetual(transaction) => { + let mut ledger = PerpetualLedger::new(state); + ledger.apply_transaction(transaction, context).await?; + } + Transaction::Clearinghouse(transaction) => { + let mut ledger = ClearinghouseLedger::new(state); + ledger.apply_transaction(transaction, context).await?; + } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_error_classifies_storage_errors() { + assert!(RuntimeError::Coins(LedgerError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Authority(AuthorityError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Oracle(OracleError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Bridge(BridgeError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Clob(ClobError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Perpetuals(PerpetualError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Clearinghouse(ClearinghouseError::Storage("disk".into())).is_storage()); + + assert!(!RuntimeError::Authority(AuthorityError::NotConfigured).is_storage()); + assert!(!RuntimeError::Coins(LedgerError::InvalidTokenSpec("bad")).is_storage()); + assert!(!RuntimeError::Oracle(OracleError::PayloadTooLarge).is_storage()); + assert!(!RuntimeError::Perpetuals(PerpetualError::Unauthorized).is_storage()); + assert!(!RuntimeError::Clob(ClobError::MarketNotFound).is_storage()); + assert!(!RuntimeError::Clearinghouse(ClearinghouseError::Unauthorized).is_storage()); + } +} diff --git a/examples/coins/chain/src/tests/genesis.rs b/examples/coins/chain/src/tests/genesis.rs index 358dce9..3f96dc4 100644 --- a/examples/coins/chain/src/tests/genesis.rs +++ b/examples/coins/chain/src/tests/genesis.rs @@ -70,6 +70,7 @@ fn sample_genesis() -> ChainGenesis { }], }), oracle: Some(OracleGenesis {}), + perpetuals: None, clob: None, } } diff --git a/examples/coins/chain/src/tests/testnet.rs b/examples/coins/chain/src/tests/testnet.rs index 0e7b429..e646cc1 100644 --- a/examples/coins/chain/src/tests/testnet.rs +++ b/examples/coins/chain/src/tests/testnet.rs @@ -51,7 +51,6 @@ fn generated_testnet_has_unique_ports_dirs_and_complete_peer_sets() { .map(|node| node.data_dir.clone()) .collect::>(); assert_eq!(dirs.len(), 4); - let mut dkg_storage_keys = HashSet::new(); for node in &manifest.nodes { let config = NodeConfig::read(&node.config_path).expect("read node config"); @@ -71,12 +70,7 @@ fn generated_testnet_has_unique_ports_dirs_and_complete_peer_sets() { decode_unit::(&config.share, "share").expect("decode share"); decode_unit::(&config.private_key, "private_key") .expect("decode private key"); - let dkg_storage_key = - decode_storage_key(&config.dkg_storage_key).expect("decode dkg storage key"); - assert_ne!(config.dkg_storage_key, config.private_key); - assert!(dkg_storage_keys.insert(dkg_storage_key)); } - assert_eq!(dkg_storage_keys.len(), 4); let _ = fs::remove_dir_all(dir); } diff --git a/examples/coins/chain/src/tests/transaction.rs b/examples/coins/chain/src/tests/transaction.rs index 56292e9..777e2f4 100644 --- a/examples/coins/chain/src/tests/transaction.rs +++ b/examples/coins/chain/src/tests/transaction.rs @@ -1,8 +1,12 @@ use commonware_codec::Encode; use nunchi_authority::{AuthorityOperation, Transaction as AuthorityTransaction}; +use nunchi_bridge::{BridgeOperation, BridgeTransaction, ChainId}; use nunchi_clob::{AssetId, ClobOperation, Transaction as ClobTransaction}; +use nunchi_clearinghouse::{ + ClearinghouseOperation, Transaction as ClearinghouseTransaction, +}; use nunchi_coins::{CoinOperation, Transaction as CoinTransaction}; -use nunchi_common::Operation; +use nunchi_common::{Address, Operation}; use nunchi_mempool::PoolTransaction; use commonware_codec::DecodeExt; @@ -10,6 +14,7 @@ use commonware_cryptography::{ed25519, Hasher, Sha256, Signer as _}; use nunchi_authority::MultisigPolicy; use nunchi_coins::{CoinSpec, PrivateKey, TokenName, TokenSymbol}; use nunchi_oracle::{IntervalKey, NamespaceId, OracleOperation, Transaction as OracleTransaction}; +use nunchi_perpetuals::{PerpetualOperation, Side, Transaction as PerpetualTransaction}; use crate::transaction::*; @@ -60,6 +65,22 @@ fn oracle_transaction(seed: u64, nonce: u64) -> OracleTransaction { ) } +fn bridge_transaction(seed: u64, nonce: u64) -> BridgeTransaction { + let signer = nunchi_crypto::PrivateKey::ed25519_from_seed(seed); + BridgeTransaction::sign( + &signer, + nonce, + BridgeOperation::Lock { + destination_chain_id: ChainId(Sha256::hash(b"remote-chain")), + local_asset: Sha256::hash(b"local-asset"), + amount: 1, + recipient: Address::external( + &nunchi_crypto::PrivateKey::ed25519_from_seed(seed + 1).public_key(), + ), + }, + ) +} + fn clob_transaction(seed: u64, nonce: u64) -> ClobTransaction { let signer = nunchi_crypto::PrivateKey::ed25519_from_seed(seed); ClobTransaction::sign( @@ -74,26 +95,67 @@ fn clob_transaction(seed: u64, nonce: u64) -> ClobTransaction { ) } +fn perpetual_transaction(seed: u64, nonce: u64) -> PerpetualTransaction { + let signer = nunchi_crypto::PrivateKey::ed25519_from_seed(seed); + PerpetualTransaction::sign( + &signer, + nonce, + PerpetualOperation::OpenPosition { + market: Sha256::hash(b"btc-usd-perp"), + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + ) +} + +fn clearinghouse_transaction(seed: u64, nonce: u64) -> ClearinghouseTransaction { + let signer = nunchi_crypto::PrivateKey::ed25519_from_seed(seed); + ClearinghouseTransaction::sign( + &signer, + nonce, + ClearinghouseOperation::RegisterPerpsMarket { + clob_market: nunchi_clob::MarketId(Sha256::hash(b"clob")), + perps_market: Sha256::hash(b"perps"), + }, + ) +} + #[test] fn transaction_codec_uses_stable_tags() { let coin = Transaction::from(coin_transaction(1, 3)); let authority = Transaction::from(authority_transaction(2, 4)); let oracle = Transaction::from(oracle_transaction(3, 5)); - let clob = Transaction::from(clob_transaction(4, 6)); + let bridge = Transaction::from(bridge_transaction(4, 6)); + let clob = Transaction::from(clob_transaction(5, 7)); + let perpetual = Transaction::from(perpetual_transaction(6, 8)); + let clearinghouse = Transaction::from(clearinghouse_transaction(7, 9)); let coin_encoded = coin.encode(); let authority_encoded = authority.encode(); let oracle_encoded = oracle.encode(); + let bridge_encoded = bridge.encode(); let clob_encoded = clob.encode(); + let perpetual_encoded = perpetual.encode(); + let clearinghouse_encoded = clearinghouse.encode(); assert_eq!(coin_encoded[0], TX_COIN); assert_eq!(authority_encoded[0], TX_AUTHORITY); assert_eq!(oracle_encoded[0], TX_ORACLE); + assert_eq!(bridge_encoded[0], TX_BRIDGE); assert_eq!(clob_encoded[0], TX_CLOB); + assert_eq!(perpetual_encoded[0], TX_PERPETUAL); + assert_eq!(clearinghouse_encoded[0], TX_CLEARINGHOUSE); assert_eq!(Transaction::decode(coin_encoded).unwrap(), coin); assert_eq!(Transaction::decode(authority_encoded).unwrap(), authority); assert_eq!(Transaction::decode(oracle_encoded).unwrap(), oracle); + assert_eq!(Transaction::decode(bridge_encoded).unwrap(), bridge); assert_eq!(Transaction::decode(clob_encoded).unwrap(), clob); + assert_eq!(Transaction::decode(perpetual_encoded).unwrap(), perpetual); + assert_eq!( + Transaction::decode(clearinghouse_encoded).unwrap(), + clearinghouse + ); assert!(Transaction::decode([99].as_slice()).is_err()); } @@ -113,7 +175,10 @@ fn pool_transaction_nonce_key_uses_operation_namespace() { let coin = Transaction::from(coin_transaction(1, 0)); let authority = Transaction::from(authority_transaction(1, 0)); let oracle = Transaction::from(oracle_transaction(1, 0)); + let bridge = Transaction::from(bridge_transaction(1, 0)); let clob = Transaction::from(clob_transaction(1, 0)); + let perpetual = Transaction::from(perpetual_transaction(1, 0)); + let clearinghouse = Transaction::from(clearinghouse_transaction(1, 0)); assert_eq!( PoolTransaction::nonce_key(&coin).namespace(), @@ -127,10 +192,22 @@ fn pool_transaction_nonce_key_uses_operation_namespace() { PoolTransaction::nonce_key(&oracle).namespace(), OracleOperation::NAMESPACE ); + assert_eq!( + PoolTransaction::nonce_key(&bridge).namespace(), + BridgeOperation::NAMESPACE + ); assert_eq!( PoolTransaction::nonce_key(&clob).namespace(), ClobOperation::NAMESPACE ); + assert_eq!( + PoolTransaction::nonce_key(&perpetual).namespace(), + PerpetualOperation::NAMESPACE + ); + assert_eq!( + PoolTransaction::nonce_key(&clearinghouse).namespace(), + ClearinghouseOperation::NAMESPACE + ); assert_ne!( PoolTransaction::nonce_key(&coin), @@ -138,10 +215,6 @@ fn pool_transaction_nonce_key_uses_operation_namespace() { ); assert_ne!( PoolTransaction::nonce_key(&coin), - PoolTransaction::nonce_key(&oracle) - ); - assert_ne!( - PoolTransaction::nonce_key(&coin), - PoolTransaction::nonce_key(&clob) + PoolTransaction::nonce_key(&perpetual) ); } diff --git a/examples/coins/chain/src/transaction.rs b/examples/coins/chain/src/transaction.rs index c3944a4..6b9c806 100644 --- a/examples/coins/chain/src/transaction.rs +++ b/examples/coins/chain/src/transaction.rs @@ -1,14 +1,20 @@ use nunchi_authority::{AuthorityOperation, Transaction as AuthorityTransaction}; use nunchi_bridge::{BridgeOperation, BridgeTransaction}; use nunchi_clob::{ClobOperation, Transaction as ClobTransaction}; +use nunchi_clearinghouse::{ + ClearinghouseOperation, Transaction as ClearinghouseTransaction, +}; use nunchi_coins::{CoinOperation, Transaction as CoinTransaction}; use nunchi_oracle::{OracleOperation, Transaction as OracleTransaction}; +use nunchi_perpetuals::{PerpetualOperation, Transaction as PerpetualTransaction}; pub(crate) const TX_COIN: u8 = 0; pub(crate) const TX_AUTHORITY: u8 = 1; pub(crate) const TX_ORACLE: u8 = 2; pub(crate) const TX_BRIDGE: u8 = 3; pub(crate) const TX_CLOB: u8 = 4; +pub(crate) const TX_PERPETUAL: u8 = 5; +pub(crate) const TX_CLEARINGHOUSE: u8 = 6; nunchi_chain::transaction_wrapper! { pub enum Transaction { @@ -37,5 +43,15 @@ nunchi_chain::transaction_wrapper! { transaction: ClobTransaction, operation: ClobOperation, }, + Perpetual { + tag: TX_PERPETUAL, + transaction: PerpetualTransaction, + operation: PerpetualOperation, + }, + Clearinghouse { + tag: TX_CLEARINGHOUSE, + transaction: ClearinghouseTransaction, + operation: ClearinghouseOperation, + }, } } diff --git a/examples/coins/chain/tests/coins.rs b/examples/coins/chain/tests/coins.rs index 8e44f4c..a716f76 100644 --- a/examples/coins/chain/tests/coins.rs +++ b/examples/coins/chain/tests/coins.rs @@ -4,6 +4,7 @@ use common::network::{ deterministic_state, lossy_link, reliable_link, TestNetworkBuilder, ThresholdFixture, ValidatorConfig, }; +use commonware_codec::Encode; use commonware_cryptography::Signer as _; use commonware_cryptography::{Hasher, Sha256}; use commonware_macros::{select, test_traced}; @@ -13,11 +14,21 @@ use nunchi_authority::{ proposal_id, AuthorityOperation, MultisigPolicy, RegistryChange, Transaction as AuthorityTransaction, }; +use nunchi_clearinghouse::{ClearinghouseOperation, Transaction as ClearinghouseTransaction}; +use nunchi_clob::{ + market_id, AssetId, ClobOperation, Fill, FillId, OrderId, Side as ClobSide, + Transaction as ClobTransaction, +}; use nunchi_coins::{ Address, CoinId, CoinOperation, CoinSpec, PrivateKey, TokenFactory, TokenName, TokenSymbol, Transaction, }; use nunchi_oracle::{IntervalKey, NamespaceId, OracleOperation, Transaction as OracleTransaction}; +use nunchi_perpetuals::{ + collateral_escrow_account, derive_market_id, derive_position_id, derive_position_id_for_side, + OraclePricePayload, PerpetualOperation, Side, Transaction as PerpetualTransaction, + DEFAULT_LIQUIDATION_REWARD_BPS, PRICE_SCALE, +}; use rand::{rngs::StdRng, Rng, SeedableRng}; use std::time::Duration; use tracing::info; @@ -28,6 +39,16 @@ const VALIDATORS: u32 = 5; const ALICE: u64 = 100; const BOB: u64 = 101; const CAROL: u64 = 102; +const COLLATERAL_ISSUER: u64 = 800; +const PERPS_TRADER: u64 = 801; +const PERPS_ORACLE_WRITER: u64 = 803; +const PERPS_MAKER: u64 = 802; +const PERPS_SETTLER: u64 = 804; + +const CLOB_TICK: u128 = 5; +const CLOB_LOT: u128 = 2; +const CLOB_FILL_QTY: u128 = 4; +const CLOB_FILL_PRICE: u128 = 100; fn key(seed: u64) -> PrivateKey { PrivateKey::from_seed(seed) @@ -57,6 +78,291 @@ fn oracle_namespace() -> NamespaceId { NamespaceId(Sha256::hash(b"coins-chain-integration-oracle-namespace")) } +fn perps_oracle_namespace() -> NamespaceId { + NamespaceId(Sha256::hash(b"coins-chain-perps-oracle-namespace")) +} + +fn usdc_spec() -> CoinSpec { + CoinSpec::new( + TokenSymbol::new("USDC").expect("valid token symbol"), + TokenName::new("USD Coin").expect("valid token name"), + 6, + 1_000_000, + None, + ) +} + +fn usdc_coin() -> CoinId { + TokenFactory::derive_coin_id( + &Address::from(key(COLLATERAL_ISSUER).public_key()), + 0, + &usdc_spec(), + ) +} + +fn btc_coin() -> CoinId { + CoinId(Sha256::hash(b"btc-asset")) +} + +fn usd_coin() -> CoinId { + CoinId(Sha256::hash(b"usd-quote")) +} + +fn clob_market_id() -> nunchi_clob::MarketId { + market_id( + &AssetId(btc_coin().0), + &AssetId(usd_coin().0), + CLOB_TICK, + CLOB_LOT, + ) +} + +fn integration_fill(clob_market: nunchi_clob::MarketId, maker: &Address, taker: &Address) -> Fill { + Fill { + id: FillId(Sha256::hash(b"perps-integration-fill")), + market: clob_market, + maker_order: OrderId(Sha256::hash(b"maker-order")), + taker_order: OrderId(Sha256::hash(b"taker-order")), + maker: maker.clone(), + taker: taker.clone(), + taker_side: ClobSide::Bid, + price: CLOB_FILL_PRICE, + base_quantity: CLOB_FILL_QTY, + quote_quantity: CLOB_FILL_PRICE * CLOB_FILL_QTY, + sequence: 0, + written_at_height: 0, + written_at_ms: 0, + } +} + +#[test_traced] +fn perps_clob_clearinghouse_flow_finalizes_across_validators() { + let executor = deterministic::Runner::timed(Duration::from_secs(120)); + executor.start(|mut context| async move { + let mut network = TestNetworkBuilder::new(VALIDATORS) + .build(&mut context) + .await; + network.start_all().await; + + let issuer = key(COLLATERAL_ISSUER); + let maker = key(PERPS_MAKER); + let taker = key(PERPS_TRADER); + let settler = key(PERPS_SETTLER); + let oracle_writer = authority_key(PERPS_ORACLE_WRITER); + let oracle_writer_id = Address::from(oracle_writer.public_key()); + let issuer_id = Address::from(issuer.public_key()); + let maker_id = Address::from(maker.public_key()); + let taker_id = Address::from(taker.public_key()); + let settler_id = Address::from(settler.public_key()); + let clob_market = clob_market_id(); + let perps_market = derive_market_id(btc_coin(), usd_coin(), usdc_coin(), 0); + let submitter = network.submitter(0); + + submitter + .submit( + Transaction::sign(&issuer, 0, CoinOperation::CreateToken { spec: usdc_spec() }) + .into(), + ) + .await + .expect("admit collateral token creation"); + for (trader, nonce) in [(&maker, 1_u64), (&taker, 2_u64)] { + submitter + .submit( + Transaction::sign( + &issuer, + nonce, + CoinOperation::Transfer { + coin: usdc_coin(), + from: issuer_id.clone(), + to: Address::from(trader.public_key()), + amount: 100_000, + }, + ) + .into(), + ) + .await + .expect("admit trader collateral transfer"); + } + network.run_until_nonces(&[(issuer_id, 3)]).await; + + submitter + .submit( + OracleTransaction::sign( + &oracle_writer, + 0, + OracleOperation::AppendRecord { + namespace: perps_oracle_namespace(), + interval: IntervalKey::new(0), + payload: OraclePricePayload { + market: perps_market, + price: CLOB_FILL_PRICE, + price_decimals: 0, + source_timestamp_ms: 0, + } + .encode() + .as_ref() + .to_vec(), + proof: None, + }, + ) + .into(), + ) + .await + .expect("admit initial perps oracle record"); + + loop { + let ledgers = network.oracle_ledgers().await; + if ledgers.len() == VALIDATORS as usize { + let mut all_updated = true; + for ledger in ledgers { + let records = ledger + .records_by_namespace( + &perps_oracle_namespace(), + IntervalKey::new(0), + IntervalKey::new(0), + ) + .await + .unwrap(); + if records.len() != 1 { + all_updated = false; + break; + } + } + if all_updated { + break; + } + } + network.context().sleep(Duration::from_secs(1)).await; + } + + submitter + .submit( + ClobTransaction::sign( + &maker, + 0, + ClobOperation::CreateMarket { + base_asset: AssetId(btc_coin().0), + quote_asset: AssetId(usd_coin().0), + tick_size: CLOB_TICK, + lot_size: CLOB_LOT, + }, + ) + .into(), + ) + .await + .expect("admit clob market creation"); + + submitter + .submit( + PerpetualTransaction::sign( + &taker, + 0, + PerpetualOperation::CreateMarket { + base_asset: btc_coin(), + quote_asset: usd_coin(), + collateral_asset: usdc_coin(), + oracle_namespace: perps_oracle_namespace(), + oracle_writer: oracle_writer_id.clone(), + clob_market: Some(clob_market.0), + oracle_interval_ms: u64::MAX, + max_oracle_staleness_ms: u64::MAX, + price_decimals: 0, + max_leverage_bps: 50_000, + maintenance_margin_bps: 1_000, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: DEFAULT_LIQUIDATION_REWARD_BPS, + }, + ) + .into(), + ) + .await + .expect("admit perps market creation"); + submitter + .submit( + PerpetualTransaction::sign( + &taker, + 1, + PerpetualOperation::RefreshMarketFromOracle { + market: perps_market, + }, + ) + .into(), + ) + .await + .expect("admit perps market refresh"); + submitter + .submit( + ClearinghouseTransaction::sign( + &settler, + 0, + ClearinghouseOperation::RegisterPerpsMarket { + clob_market, + perps_market, + }, + ) + .into(), + ) + .await + .expect("admit clearinghouse registration"); + network + .run_until_clob_nonces(&[(maker_id.clone(), 1)]) + .await; + network + .run_until_perpetual_nonces(&[(taker_id.clone(), 2)]) + .await; + network + .run_until_clearinghouse_nonces(&[(settler_id.clone(), 1)]) + .await; + + submitter + .submit( + ClearinghouseTransaction::sign( + &settler, + 1, + ClearinghouseOperation::CommitAndSettleFill { + fill: Box::new(integration_fill(clob_market, &maker_id, &taker_id)), + }, + ) + .into(), + ) + .await + .expect("admit fill commit and settlement"); + network + .run_until_clearinghouse_nonces(&[(settler_id.clone(), 2)]) + .await; + + let expected_qty = CLOB_FILL_QTY * PRICE_SCALE; + for ledger in network.perpetual_ledgers().await { + let market = ledger.market(&perps_market).await.unwrap().unwrap(); + assert_eq!(market.long_open_interest, expected_qty); + assert_eq!(market.short_open_interest, expected_qty); + + let maker_position = ledger + .position(&derive_position_id_for_side( + &maker_id, + &perps_market, + Side::Short, + )) + .await + .unwrap() + .unwrap(); + assert_eq!(maker_position.quantity, expected_qty); + + let taker_position = ledger + .position(&derive_position_id_for_side( + &taker_id, + &perps_market, + Side::Long, + )) + .await + .unwrap() + .unwrap(); + assert_eq!(taker_position.quantity, expected_qty); + } + }); +} + #[test_traced] fn reaches_height_with_reliable_links() { let link = reliable_link(); @@ -487,6 +793,262 @@ fn oracle_updates_finalize_across_validators() { }); } +#[test_traced] +fn perps_oracle_flow_finalizes_across_validators() { + let executor = deterministic::Runner::timed(Duration::from_secs(120)); + executor.start(|mut context| async move { + let mut network = TestNetworkBuilder::new(VALIDATORS) + .build(&mut context) + .await; + network.start_all().await; + + let issuer = key(COLLATERAL_ISSUER); + let trader = key(PERPS_TRADER); + let oracle_writer = authority_key(PERPS_ORACLE_WRITER); + let oracle_writer_id = Address::from(oracle_writer.public_key()); + let issuer_id = Address::from(issuer.public_key()); + let trader_id = Address::from(trader.public_key()); + let market_id = derive_market_id(btc_coin(), usd_coin(), usdc_coin(), 0); + let position_id = derive_position_id(&trader_id, &market_id, 0); + let submitter = network.submitter(0); + + submitter + .submit( + Transaction::sign(&issuer, 0, CoinOperation::CreateToken { spec: usdc_spec() }) + .into(), + ) + .await + .expect("admit collateral token creation"); + submitter + .submit( + Transaction::sign( + &issuer, + 1, + CoinOperation::Transfer { + coin: usdc_coin(), + from: issuer_id.clone(), + to: trader_id.clone(), + amount: 100_000, + }, + ) + .into(), + ) + .await + .expect("admit trader collateral transfer"); + network.run_until_nonces(&[(issuer_id, 2)]).await; + + submitter + .submit( + OracleTransaction::sign( + &oracle_writer, + 0, + OracleOperation::AppendRecord { + namespace: perps_oracle_namespace(), + interval: IntervalKey::new(0), + payload: OraclePricePayload { + market: market_id, + price: 5_000_000, + price_decimals: 2, + source_timestamp_ms: 0, + } + .encode() + .as_ref() + .to_vec(), + proof: None, + }, + ) + .into(), + ) + .await + .expect("admit initial perps oracle record"); + + loop { + let ledgers = network.oracle_ledgers().await; + if ledgers.len() == VALIDATORS as usize { + let mut all_updated = true; + for ledger in ledgers { + let records = ledger + .records_by_namespace( + &perps_oracle_namespace(), + IntervalKey::new(0), + IntervalKey::new(0), + ) + .await + .unwrap(); + if records.len() != 1 { + all_updated = false; + break; + } + } + if all_updated { + break; + } + } + network.context().sleep(Duration::from_secs(1)).await; + } + + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 0, + PerpetualOperation::CreateMarket { + base_asset: btc_coin(), + quote_asset: usd_coin(), + collateral_asset: usdc_coin(), + oracle_namespace: perps_oracle_namespace(), + oracle_writer: oracle_writer_id.clone(), + clob_market: None, + oracle_interval_ms: u64::MAX, + max_oracle_staleness_ms: u64::MAX, + price_decimals: 2, + max_leverage_bps: 50_000, + maintenance_margin_bps: 1_000, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: DEFAULT_LIQUIDATION_REWARD_BPS, + }, + ) + .into(), + ) + .await + .expect("admit perps market creation"); + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 1, + PerpetualOperation::RefreshMarketFromOracle { market: market_id }, + ) + .into(), + ) + .await + .expect("admit perps market refresh"); + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 2, + PerpetualOperation::OpenPosition { + market: market_id, + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + ) + .into(), + ) + .await + .expect("admit long position"); + network + .run_until_perpetual_nonces(&[(trader_id.clone(), 3)]) + .await; + + for ledger in network.perpetual_ledgers().await { + assert!(ledger.market(&market_id).await.unwrap().is_some()); + assert!(ledger.position(&position_id).await.unwrap().is_some()); + } + for ledger in network.ledgers().await { + assert_eq!( + ledger.balance(&trader_id, &usdc_coin()).await.unwrap(), + 99_000 + ); + assert_eq!( + ledger + .balance(&collateral_escrow_account(), &usdc_coin()) + .await + .unwrap(), + 1_000 + ); + } + + submitter + .submit( + OracleTransaction::sign( + &oracle_writer, + 1, + OracleOperation::AppendRecord { + namespace: perps_oracle_namespace(), + interval: IntervalKey::new(0), + payload: OraclePricePayload { + market: market_id, + price: 2_500_000, + price_decimals: 2, + source_timestamp_ms: 0, + } + .encode() + .as_ref() + .to_vec(), + proof: None, + }, + ) + .into(), + ) + .await + .expect("admit adverse perps oracle record"); + + loop { + let ledgers = network.oracle_ledgers().await; + if ledgers.len() == VALIDATORS as usize { + let mut all_updated = true; + for ledger in ledgers { + let records = ledger + .records_by_namespace( + &perps_oracle_namespace(), + IntervalKey::new(0), + IntervalKey::new(0), + ) + .await + .unwrap(); + if records.len() != 2 { + all_updated = false; + break; + } + } + if all_updated { + break; + } + } + network.context().sleep(Duration::from_secs(1)).await; + } + + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 3, + PerpetualOperation::RefreshMarketFromOracle { market: market_id }, + ) + .into(), + ) + .await + .expect("admit adverse market refresh"); + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 4, + PerpetualOperation::Liquidate { + position: position_id, + }, + ) + .into(), + ) + .await + .expect("admit liquidation"); + network + .run_until_perpetual_nonces(&[(trader_id.clone(), 5)]) + .await; + + for ledger in network.perpetual_ledgers().await { + assert!(ledger.position(&position_id).await.unwrap().is_none()); + } + for ledger in network.ledgers().await { + assert!(ledger.balance(&trader_id, &usdc_coin()).await.unwrap() > 99_000); + } + }); +} + /// The mempool reports each submission's lifecycle: executable transactions finalize, while a /// nonce-gapped transaction is admitted but never proposed and stays pending. #[test_traced] @@ -577,9 +1139,10 @@ fn mempool_replaces_same_nonce_resubmission() { let executor = deterministic::Runner::timed(Duration::from_secs(120)); executor.start(|mut context| async move { let mut network = TestNetworkBuilder::new(VALIDATORS) + .without_initial_links() .build(&mut context) .await; - network.start_all().await; + network.start_validator(0).await; let alice = key(ALICE); let alice_id = Address::from(alice.public_key()); @@ -619,6 +1182,11 @@ fn mempool_replaces_same_nonce_resubmission() { }) ); + for index in 1..VALIDATORS as usize { + network.start_validator(index).await; + } + network.link_all(reliable_link()).await; + network.run_until_nonces(&[(alice_id.clone(), 1)]).await; loop { match node0.status(replacement).await { diff --git a/examples/coins/chain/tests/common/network.rs b/examples/coins/chain/tests/common/network.rs index d3451bf..2cf9dec 100644 --- a/examples/coins/chain/tests/common/network.rs +++ b/examples/coins/chain/tests/common/network.rs @@ -21,6 +21,8 @@ use commonware_utils::{ }; use governor::Quota; use nunchi_authority::AuthorityLedger; +use nunchi_clearinghouse::ClearinghouseLedger; +use nunchi_clob::{ClobLedger, ClobMailbox, Fill, MarketId}; use nunchi_coins::{Address, Ledger}; use nunchi_coins_chain::{ engine::{Config, Engine}, @@ -31,6 +33,7 @@ use nunchi_common::QmdbReader; use nunchi_dkg::{ContinueOnUpdate, PeerConfig}; use nunchi_mempool::{MempoolHandle, PoolConfig}; use nunchi_oracle::OracleLedger; +use nunchi_perpetuals::PerpetualLedger; use std::{ collections::{HashMap, HashSet}, time::Duration, @@ -56,6 +59,9 @@ type Channel = ( type ReadLedger = Ledger>; type ReadAuthorityLedger = AuthorityLedger>; type ReadOracleLedger = OracleLedger>; +type ReadPerpetualLedger = PerpetualLedger>; +type ReadClobLedger = ClobLedger>; +type ReadClearinghouseLedger = ClearinghouseLedger>; #[derive(Clone)] pub(crate) struct ThresholdFixture { @@ -360,6 +366,43 @@ impl TestNetwork<'_> { .clone() } + #[allow(dead_code)] + pub(crate) fn clob(&self, index: usize) -> ClobMailbox { + self.nodes + .get(&self.participants[index]) + .expect("validator not started") + .clob + .clone() + } + + #[allow(dead_code)] + /// Mirror an on-chain CLOB market into each validator's local matcher actor. + pub(crate) async fn sync_clob_actor_market(&self, market: MarketId) { + let ledgers = self.clob_ledgers().await; + for (index, ledger) in ledgers.into_iter().enumerate() { + let market_info = ledger + .market(&market) + .await + .expect("clob market read") + .expect("clob market exists"); + let sequence = ledger + .market_sequence(&market) + .await + .expect("clob market sequence read"); + self.clob(index).upsert_market_state(market_info, sequence); + } + } + + #[allow(dead_code)] + pub(crate) async fn sync_clob_actor_nonce(&self, account: &Address, nonce: u64) { + for index in 0..self.participants.len() { + self.clob(index) + .sync_nonce(account.clone(), nonce) + .await + .expect("sync clob actor nonce"); + } + } + /// Snapshot, in registration order, every started validator's committed coin ledger. pub(crate) async fn ledgers(&self) -> Vec { let mut ledgers = Vec::new(); @@ -397,6 +440,178 @@ impl TestNetwork<'_> { ledgers } + pub(crate) async fn perpetual_ledgers(&self) -> Vec { + let mut ledgers = Vec::new(); + for participant in &self.participants { + let Some(node) = self.nodes.get(participant) else { + continue; + }; + let db = node.stateful.subscribe_databases().await; + ledgers.push(PerpetualLedger::new(QmdbReader::new(db))); + } + ledgers + } + + pub(crate) async fn clob_ledgers(&self) -> Vec { + let mut ledgers = Vec::new(); + for participant in &self.participants { + let Some(node) = self.nodes.get(participant) else { + continue; + }; + let db = node.stateful.subscribe_databases().await; + ledgers.push(ClobLedger::new(QmdbReader::new(db))); + } + ledgers + } + + pub(crate) async fn clearinghouse_ledgers(&self) -> Vec { + let mut ledgers = Vec::new(); + for participant in &self.participants { + let Some(node) = self.nodes.get(participant) else { + continue; + }; + let db = node.stateful.subscribe_databases().await; + ledgers.push(ClearinghouseLedger::new(QmdbReader::new(db))); + } + ledgers + } + + #[allow(dead_code)] + pub(crate) async fn sync_clob_actors_from_chain(&self) { + for (index, participant) in self.participants.iter().enumerate() { + let Some(node) = self.nodes.get(participant) else { + continue; + }; + let db = node.stateful.subscribe_databases().await; + let ledger = ClobLedger::new(QmdbReader::new(db)); + let Ok(markets) = ledger.markets().await else { + continue; + }; + for market in markets { + let Ok(sequence) = ledger.market_sequence(&market.id).await else { + continue; + }; + self.clob(index).upsert_market_state(market, sequence); + } + } + } + + pub(crate) async fn run_until_clob_nonces(&self, expected: &[(Address, u64)]) { + loop { + self.sync_clob_actors_from_chain().await; + if self.all_clob_nonces_reached(expected).await { + break; + } + self.context.sleep(Duration::from_secs(1)).await; + } + } + + pub(crate) async fn run_until_clearinghouse_nonces(&self, expected: &[(Address, u64)]) { + loop { + if self.all_clearinghouse_nonces_reached(expected).await { + break; + } + self.context.sleep(Duration::from_secs(1)).await; + } + } + + #[allow(dead_code)] + pub(crate) async fn wait_for_clob_fill(&self, market: MarketId) -> Fill { + loop { + self.sync_clob_actors_from_chain().await; + let ledgers = self.clob_ledgers().await; + if ledgers.len() == self.participants.len() { + let mut fill: Option = None; + let mut all_have_fill = true; + for ledger in &ledgers { + let fills = ledger + .market_fills(&market) + .await + .expect("read clob market fills"); + let Some(record) = fills.last() else { + all_have_fill = false; + break; + }; + if let Some(expected) = &fill { + if expected.id != record.id { + all_have_fill = false; + break; + } + } else { + fill = Some(record.clone()); + } + } + if all_have_fill { + return fill.expect("matched fill on all validators"); + } + } + self.context.sleep(Duration::from_secs(1)).await; + } + } + + pub(crate) async fn run_until_perpetual_nonces(&self, expected: &[(Address, u64)]) { + loop { + if self.all_perpetual_nonces_reached(expected).await { + break; + } + self.context.sleep(Duration::from_secs(1)).await; + } + } + + async fn all_clob_nonces_reached(&self, expected: &[(Address, u64)]) -> bool { + let ledgers = self.clob_ledgers().await; + if ledgers.len() != self.participants.len() { + return false; + } + for ledger in ledgers { + for (account, target) in expected { + let nonce = ledger.nonce(account).await.expect("clob nonce read failed"); + if nonce != *target { + return false; + } + } + } + true + } + + async fn all_clearinghouse_nonces_reached(&self, expected: &[(Address, u64)]) -> bool { + let ledgers = self.clearinghouse_ledgers().await; + if ledgers.len() != self.participants.len() { + return false; + } + for ledger in ledgers { + for (account, target) in expected { + let nonce = ledger + .nonce(account) + .await + .expect("clearinghouse nonce read failed"); + if nonce != *target { + return false; + } + } + } + true + } + + async fn all_perpetual_nonces_reached(&self, expected: &[(Address, u64)]) -> bool { + let ledgers = self.perpetual_ledgers().await; + if ledgers.len() != self.participants.len() { + return false; + } + for ledger in ledgers { + for (account, target) in expected { + let nonce = ledger + .nonce(account) + .await + .expect("perpetual nonce read failed"); + if nonce != *target { + return false; + } + } + } + true + } + /// Poll until every node's ledger shows the expected nonce for each listed account. /// /// An account's nonce advances once per applied transaction, so this is a precise "all the diff --git a/examples/coins/chain/tests/rpc.rs b/examples/coins/chain/tests/rpc.rs index cf2b092..ac17a9e 100644 --- a/examples/coins/chain/tests/rpc.rs +++ b/examples/coins/chain/tests/rpc.rs @@ -4,6 +4,7 @@ //! connection tasks, exercising the same server path an operator would run. use commonware_consensus::types::Height; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; use commonware_runtime::{tokio, Runner as _, Supervisor as _}; use futures::lock::Mutex as AsyncMutex; use jsonrpsee::{ @@ -11,18 +12,27 @@ use jsonrpsee::{ types::error::INVALID_PARAMS_CODE, }; use nunchi_coins::{ - rpc::SharedLedger, CoinOperation, CoinSpec, Ledger, PrivateKey, TokenName, TokenSymbol, - Transaction as CoinTransaction, + rpc::CoinQuery, CoinId, CoinOperation, CoinSpec, LedgerError, PrivateKey, TokenDefinition, + TokenName, TokenSymbol, Transaction as CoinTransaction, }; use nunchi_coins_chain::rpc::{ self, StatusResponse, SubmitTransactionResponse, TransactionStatusResponse, }; use nunchi_coins_chain::Transaction; -use nunchi_common::QmdbState; +use nunchi_common::Address; use nunchi_mempool::{Mempool, PoolConfig}; +use nunchi_perpetuals::{ + rpc::PerpetualQuery, Market, MarketId, PerpetualError, PerpetualOperation, Position, + PositionId, Side, Transaction as PerpetualTransaction, +}; use nunchi_rpc::{encode_hex, ServerBuilder}; use std::sync::Arc; +fn sort_pending(mut pending: Vec) -> Vec { + pending.sort_by_key(Transaction::digest); + pending +} + fn submit_params(transaction: &str) -> ObjectParams { let mut params = ObjectParams::new(); params @@ -31,21 +41,63 @@ fn submit_params(transaction: &str) -> ObjectParams { params } +#[derive(Clone)] +struct TestQuery { + root: Digest, +} + +#[jsonrpsee::core::async_trait] +impl CoinQuery for TestQuery { + async fn nonce(&self, _account: Address) -> Result { + Ok(0) + } + + async fn token(&self, _coin: CoinId) -> Result, LedgerError> { + Ok(None) + } + + async fn balance(&self, _account: Address, _coin: CoinId) -> Result { + Ok(0) + } + + async fn state_root(&self) -> Result { + Ok(self.root) + } +} + +#[jsonrpsee::core::async_trait] +impl PerpetualQuery for TestQuery { + async fn nonce(&self, _account: Address) -> Result { + Ok(0) + } + + async fn market(&self, _market: MarketId) -> Result, PerpetualError> { + Ok(None) + } + + async fn position(&self, _position: PositionId) -> Result, PerpetualError> { + Ok(None) + } + + async fn state_root(&self) -> Result { + Ok(self.root) + } +} + #[test] fn rpc_serves_status_and_filters_submissions_over_http() { tokio::Runner::default().start(|context| async move { // An RPC backend without the full engine: a mempool plus a fresh ledger. let (mempool, submitter) = Mempool::::new(PoolConfig::default()); let _mempool = mempool.start(context.child("mempool")); - let db = QmdbState::init(context.child("coins_state"), "rpc-test-coins") - .await - .expect("init coin state"); - let ledger = SharedLedger::new(Ledger::new(db)); + let query = TestQuery { + root: Sha256::hash(b"rpc-test-root"), + }; let applied_height = Arc::new(AsyncMutex::new(Height::zero())); - let expected_root = encode_hex(&ledger.lock().await.root()); + let expected_root = encode_hex(&query.root); - let module = rpc::module(ledger.clone(), submitter.clone(), applied_height) - .expect("build RPC module"); + let module = + rpc::module(query, submitter.clone(), applied_height).expect("build RPC module"); let server = ServerBuilder::default() .build("127.0.0.1:0") .await @@ -93,6 +145,30 @@ fn rpc_serves_status_and_filters_submissions_over_http() { vec![transaction.clone().into()] ); + let perps_signer = PrivateKey::from_seed(101); + let perpetual = PerpetualTransaction::sign( + &perps_signer, + 0, + PerpetualOperation::OpenPosition { + market: Sha256::hash(b"btc-usd-perp"), + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + ); + let accepted_perp: nunchi_perpetuals::rpc::SubmitTransactionResponse = client + .request( + "perpetuals.submit_transaction", + submit_params(&encode_hex(&perpetual)), + ) + .await + .expect("submit valid perpetuals transaction"); + assert_eq!(accepted_perp.hash, encode_hex(&perpetual.digest())); + assert_eq!( + sort_pending(submitter.pending(usize::MAX).await), + sort_pending(vec![transaction.clone().into(), perpetual.clone().into()]) + ); + // The pool reports the admitted transaction as pending. let mut status_params = ObjectParams::new(); status_params @@ -104,6 +180,16 @@ fn rpc_serves_status_and_filters_submissions_over_http() { .expect("transaction status"); assert_eq!(tx_status.status, "pending"); + let mut perp_status_params = ObjectParams::new(); + perp_status_params + .insert("hash", accepted_perp.hash) + .expect("serialize perps hash param"); + let perp_status: nunchi_perpetuals::rpc::TransactionStatusResponse = client + .request("perpetuals.transaction_status", perp_status_params) + .await + .expect("perpetuals transaction status"); + assert_eq!(perp_status.status, "pending"); + // Resubmitting the identical transaction is rejected as a duplicate. let duplicate = client .request::( @@ -138,8 +224,8 @@ fn rpc_serves_status_and_filters_submissions_over_http() { } // The pool still only holds the valid submission. assert_eq!( - submitter.pending(usize::MAX).await, - vec![transaction.into()] + sort_pending(submitter.pending(usize::MAX).await), + sort_pending(vec![transaction.into(), perpetual.into()]) ); server.stop().expect("stop RPC server"); diff --git a/examples/custom-module/src/events.rs b/examples/custom-module/src/events.rs deleted file mode 100644 index dfa275b..0000000 --- a/examples/custom-module/src/events.rs +++ /dev/null @@ -1,73 +0,0 @@ -use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; -use nunchi_common::{Address, Event}; - -pub const VALUE_SET_EVENT: &[u8] = b"custom.value_set.v1"; -pub const VALUE_CLEARED_EVENT: &[u8] = b"custom.value_cleared.v1"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ValueSet { - pub account_id: Address, - pub value: u64, -} - -impl Write for ValueSet { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.account_id.write(buf); - self.value.write(buf); - } -} - -impl Read for ValueSet { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - account_id: Address::read(buf)?, - value: u64::read(buf)?, - }) - } -} - -impl EncodeSize for ValueSet { - fn encode_size(&self) -> usize { - self.account_id.encode_size() + self.value.encode_size() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ValueCleared { - pub account_id: Address, -} - -impl Write for ValueCleared { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.account_id.write(buf); - } -} - -impl Read for ValueCleared { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - account_id: Address::read(buf)?, - }) - } -} - -impl EncodeSize for ValueCleared { - fn encode_size(&self) -> usize { - self.account_id.encode_size() - } -} - -pub fn value_set_event(value: ValueSet) -> Event { - Event::new(bytes::Bytes::from_static(VALUE_SET_EVENT), value.encode()) -} - -pub fn value_cleared_event(value: ValueCleared) -> Event { - Event::new( - bytes::Bytes::from_static(VALUE_CLEARED_EVENT), - value.encode(), - ) -} diff --git a/examples/custom-module/src/ledger.rs b/examples/custom-module/src/ledger.rs index 8adc446..36b6cc8 100644 --- a/examples/custom-module/src/ledger.rs +++ b/examples/custom-module/src/ledger.rs @@ -1,8 +1,5 @@ -use crate::{ - value_cleared_event, value_set_event, CustomDB, CustomOperation, Transaction, ValueCleared, - ValueSet, -}; -use nunchi_common::{Address, Event, EventSink}; +use crate::{CustomDB, CustomOperation, Transaction}; +use nunchi_common::Address; use nunchi_crypto::SignatureError; use thiserror::Error; @@ -53,14 +50,7 @@ impl CustomLedger { self.db.value(account).await } - pub async fn apply_transaction( - &mut self, - tx: &Transaction, - mut events: Events, - ) -> Result<(), CustomError> - where - Events: EventSink + Send, - { + pub async fn apply_transaction(&mut self, tx: &Transaction) -> Result<(), CustomError> { tx.verify()?; let expected = self.db.nonce(&tx.account_id).await?; @@ -72,28 +62,17 @@ impl CustomLedger { }); } - let next_nonce = expected.checked_add(1).ok_or(CustomError::NonceOverflow)?; - let event = self.apply_operation(&tx.account_id, &tx.payload.operation); - self.db.set_nonce(&tx.account_id, next_nonce); - events.emit(event); - Ok(()) - } - - fn apply_operation(&mut self, account_id: &Address, operation: &CustomOperation) -> Event { - match operation { + match &tx.payload.operation { CustomOperation::SetValue { value } => { - self.db.set_value(account_id, *value); - value_set_event(ValueSet { - account_id: account_id.clone(), - value: *value, - }) + self.db.set_value(&tx.account_id, *value); } CustomOperation::ClearValue => { - self.db.remove_value(account_id); - value_cleared_event(ValueCleared { - account_id: account_id.clone(), - }) + self.db.remove_value(&tx.account_id); } } + + let next_nonce = expected.checked_add(1).ok_or(CustomError::NonceOverflow)?; + self.db.set_nonce(&tx.account_id, next_nonce); + Ok(()) } } diff --git a/examples/custom-module/src/lib.rs b/examples/custom-module/src/lib.rs index cea1a05..a8f0e13 100644 --- a/examples/custom-module/src/lib.rs +++ b/examples/custom-module/src/lib.rs @@ -6,7 +6,6 @@ commonware_macros::stability_scope!(ALPHA { mod db; -mod events; mod genesis; mod ledger; #[cfg(feature = "rpc")] @@ -16,10 +15,6 @@ mod tests; mod transaction; pub use db::CustomDB; -pub use events::{ - value_cleared_event, value_set_event, ValueCleared, ValueSet, VALUE_CLEARED_EVENT, - VALUE_SET_EVENT, -}; pub use genesis::{CustomAccountGenesis, CustomGenesis}; pub use ledger::{CustomError, CustomLedger}; pub use transaction::{CustomOperation, Transaction, TransactionPayload}; diff --git a/examples/custom-module/src/tests.rs b/examples/custom-module/src/tests.rs new file mode 100644 index 0000000..2fa477f --- /dev/null +++ b/examples/custom-module/src/tests.rs @@ -0,0 +1,57 @@ +use crate::{CustomLedger, CustomOperation, Transaction}; +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::{Address, StateError, StateStore}; +use nunchi_crypto::PrivateKey; +use std::collections::BTreeMap; + +#[derive(Default)] +struct MemoryState { + values: BTreeMap>, +} + +impl StateStore for MemoryState { + async fn get(&self, key: &Digest) -> Result>, StateError> { + Ok(self.values.get(key).cloned()) + } + + fn set(&mut self, key: Digest, value: Vec) { + self.values.insert(key, value); + } + + fn remove(&mut self, key: Digest) { + self.values.remove(&key); + } +} + +#[test] +fn operation_codec_uses_stable_tags() { + let set = CustomOperation::SetValue { value: 7 }; + let clear = CustomOperation::ClearValue; + + assert_eq!(set.encode()[0], 0); + assert_eq!(clear.encode()[0], 1); + assert_eq!(CustomOperation::decode(set.encode()).unwrap(), set); + assert_eq!(CustomOperation::decode(clear.encode()).unwrap(), clear); + assert!(CustomOperation::decode([99].as_slice()).is_err()); +} + +#[test] +fn ledger_applies_signed_transactions() { + let mut state = MemoryState::default(); + futures::executor::block_on(async { + let signer = PrivateKey::ed25519_from_seed(1); + let account = Address::external(&signer.public_key()); + let set = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); + let clear = Transaction::sign(&signer, 1, CustomOperation::ClearValue); + + let mut ledger = CustomLedger::new(&mut state); + ledger.apply_transaction(&set).await.unwrap(); + assert_eq!(ledger.value(&account).await.unwrap(), Some(42)); + assert_eq!(ledger.nonce(&account).await.unwrap(), 1); + + ledger.apply_transaction(&clear).await.unwrap(); + assert_eq!(ledger.value(&account).await.unwrap(), None); + assert_eq!(ledger.nonce(&account).await.unwrap(), 2); + }); +} diff --git a/examples/custom-module/src/tests/events.rs b/examples/custom-module/src/tests/events.rs deleted file mode 100644 index e7f1ca3..0000000 --- a/examples/custom-module/src/tests/events.rs +++ /dev/null @@ -1,180 +0,0 @@ -use super::MemoryState; -use crate::{ - CustomDB, CustomError, CustomLedger, CustomOperation, Transaction, ValueCleared, ValueSet, - VALUE_CLEARED_EVENT, VALUE_SET_EVENT, -}; -use commonware_codec::DecodeExt; -use nunchi_common::{Address, NoopEventSink, VecEventSink}; -use nunchi_crypto::{PrivateKey, SignatureError}; - -fn address(key: &PrivateKey) -> Address { - Address::external(&key.public_key()) -} - -fn event_sink() -> VecEventSink { - VecEventSink::new() -} - -#[test] -fn set_value_emits_event() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let tx = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let mut ledger = CustomLedger::new(&mut state); - let mut events = event_sink(); - - ledger.apply_transaction(&tx, &mut events).await.unwrap(); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), VALUE_SET_EVENT); - assert_eq!( - ValueSet::decode(event.value.as_ref()).unwrap(), - ValueSet { - account_id: account, - value: 42, - } - ); - }); -} - -#[test] -fn clear_value_emits_event() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let set = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let clear = Transaction::sign(&signer, 1, CustomOperation::ClearValue); - let mut ledger = CustomLedger::new(&mut state); - let mut events = event_sink(); - - ledger.apply_transaction(&set, NoopEventSink).await.unwrap(); - ledger - .apply_transaction(&clear, &mut events) - .await - .unwrap(); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), VALUE_CLEARED_EVENT); - assert_eq!( - ValueCleared::decode(event.value.as_ref()).unwrap(), - ValueCleared { - account_id: account, - } - ); - }); -} - -#[test] -fn events_are_recorded_in_application_order() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let set = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let clear = Transaction::sign(&signer, 1, CustomOperation::ClearValue); - let mut ledger = CustomLedger::new(&mut state); - let mut events = event_sink(); - - ledger.apply_transaction(&set, &mut events).await.unwrap(); - ledger - .apply_transaction(&clear, &mut events) - .await - .unwrap(); - - assert_eq!(events.len(), 2); - assert_eq!(events.events()[0].name.as_ref(), VALUE_SET_EVENT); - assert_eq!(events.events()[1].name.as_ref(), VALUE_CLEARED_EVENT); - }); -} - -#[test] -fn failed_transactions_emit_no_events() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let mut ledger = CustomLedger::new(&mut state); - - let mut bad_signature = - Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 1 }); - bad_signature.payload.operation = CustomOperation::SetValue { value: 2 }; - assert_no_event( - &mut ledger, - &bad_signature, - CustomError::BadSignature(SignatureError::InvalidSignature), - ) - .await; - - let wrong_nonce = Transaction::sign(&signer, 5, CustomOperation::SetValue { value: 1 }); - let mut events = event_sink(); - assert!(matches!( - ledger.apply_transaction(&wrong_nonce, &mut events).await, - Err(CustomError::NonceMismatch { - expected: 0, - actual: 5, - .. - }) - )); - assert!(events.is_empty()); - assert_eq!(ledger.value(&account).await.unwrap(), None); - }); -} - -#[test] -fn nonce_overflow_emits_no_event_and_leaves_value_unchanged() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let tx = Transaction::sign( - &signer, - u64::MAX, - CustomOperation::SetValue { value: 99 }, - ); - let mut ledger = CustomLedger::new(&mut state); - ledger.db.set_nonce(&account, u64::MAX); - ledger.db.set_value(&account, 7); - let mut events = event_sink(); - - assert_eq!( - ledger.apply_transaction(&tx, &mut events).await, - Err(CustomError::NonceOverflow) - ); - assert!(events.is_empty()); - assert_eq!(ledger.value(&account).await.unwrap(), Some(7)); - assert_eq!(ledger.nonce(&account).await.unwrap(), u64::MAX); - }); -} - -#[test] -fn none_event_sink_applies_transaction() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let tx = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let mut ledger = CustomLedger::new(&mut state); - - ledger.apply_transaction(&tx, NoopEventSink).await.unwrap(); - - assert_eq!(ledger.value(&account).await.unwrap(), Some(42)); - assert_eq!(ledger.nonce(&account).await.unwrap(), 1); - }); -} - -async fn assert_no_event( - ledger: &mut CustomLedger<&mut MemoryState>, - tx: &Transaction, - expected: CustomError, -) { - let mut events = event_sink(); - assert_eq!( - ledger.apply_transaction(tx, &mut events).await, - Err(expected) - ); - assert!(events.is_empty()); -} diff --git a/examples/custom-module/src/tests/ledger.rs b/examples/custom-module/src/tests/ledger.rs deleted file mode 100644 index ac9b56f..0000000 --- a/examples/custom-module/src/tests/ledger.rs +++ /dev/null @@ -1,24 +0,0 @@ -use super::MemoryState; -use crate::{CustomLedger, CustomOperation, Transaction}; -use nunchi_common::{Address, NoopEventSink}; -use nunchi_crypto::PrivateKey; - -#[test] -fn ledger_applies_signed_transactions() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = Address::external(&signer.public_key()); - let set = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let clear = Transaction::sign(&signer, 1, CustomOperation::ClearValue); - - let mut ledger = CustomLedger::new(&mut state); - ledger.apply_transaction(&set, NoopEventSink).await.unwrap(); - assert_eq!(ledger.value(&account).await.unwrap(), Some(42)); - assert_eq!(ledger.nonce(&account).await.unwrap(), 1); - - ledger.apply_transaction(&clear, NoopEventSink).await.unwrap(); - assert_eq!(ledger.value(&account).await.unwrap(), None); - assert_eq!(ledger.nonce(&account).await.unwrap(), 2); - }); -} diff --git a/examples/custom-module/src/tests/mod.rs b/examples/custom-module/src/tests/mod.rs deleted file mode 100644 index 8adee53..0000000 --- a/examples/custom-module/src/tests/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -use commonware_cryptography::sha256::Digest; -use nunchi_common::{StateError, StateStore}; -use std::collections::BTreeMap; - -mod events; -mod ledger; -mod transaction; - -#[derive(Default)] -struct MemoryState { - values: BTreeMap>, -} - -impl StateStore for MemoryState { - async fn get(&self, key: &Digest) -> Result>, StateError> { - Ok(self.values.get(key).cloned()) - } - - fn set(&mut self, key: Digest, value: Vec) { - self.values.insert(key, value); - } - - fn remove(&mut self, key: Digest) { - self.values.remove(&key); - } -} diff --git a/examples/custom-module/src/tests/transaction.rs b/examples/custom-module/src/tests/transaction.rs deleted file mode 100644 index 83e2684..0000000 --- a/examples/custom-module/src/tests/transaction.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::CustomOperation; -use commonware_codec::{DecodeExt, Encode}; - -#[test] -fn operation_codec_uses_stable_tags() { - let set = CustomOperation::SetValue { value: 7 }; - let clear = CustomOperation::ClearValue; - - assert_eq!(set.encode()[0], 0); - assert_eq!(clear.encode()[0], 1); - assert_eq!(CustomOperation::decode(set.encode()).unwrap(), set); - assert_eq!(CustomOperation::decode(clear.encode()).unwrap(), clear); - assert!(CustomOperation::decode([99].as_slice()).is_err()); -} diff --git a/memclob/Cargo.toml b/memclob/Cargo.toml new file mode 100644 index 0000000..a637ab0 --- /dev/null +++ b/memclob/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "nunchi-memclob" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Validator-local in-memory order books with P2P gossip (dYdX v4 style)." + +[lints] +workspace = true + +[features] +default = [] + +[dependencies] +bytes = { workspace = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-macros = { workspace = true } +commonware-p2p = { workspace = true } +commonware-runtime = { workspace = true } +futures = { workspace = true } +nunchi-clob = { workspace = true } +nunchi-common = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +commonware-cryptography = { workspace = true } +commonware-p2p = { workspace = true } +commonware-runtime = { workspace = true } +commonware-utils = { workspace = true } +governor = { workspace = true } +nunchi-crypto = { workspace = true } diff --git a/memclob/README.md b/memclob/README.md new file mode 100644 index 0000000..5ae1cf3 --- /dev/null +++ b/memclob/README.md @@ -0,0 +1,33 @@ +# nunchi-memclob + +Validator-local, in-memory central limit order books with P2P gossip — modeled after [dYdX v4](https://docs.dydx.exchange/introduction-onboarding_faqs). + +## Architecture + +Each validator runs a `MemBookEngine` that is **not** committed to consensus directly. Signed order instructions (`PlaceOrder`, `CancelOrder`, `CreateMarket`) propagate over a dedicated P2P channel using the same wire format as `nunchi-clob` transactions. Every honest node applies the same deterministic matching rules, so local books stay eventually consistent. + +When a validator proposes a block, it drains `pending_fills()` from its memclob and includes those fills for on-chain settlement via `nunchi-clob::ClobLedger`. + +``` +Trader ──submit──► MemClobHandle ──► MemBookEngine (RAM) + │ + └── P2P gossip (Recipients::All) ──► peer validators + +Proposer ──pending_fills()──► block ──finalize──► ClobLedger (QMDB) +``` + +## Relationship to `nunchi-clob` + +| Layer | Crate | Role | +|-------|-------|------| +| Short-term | `nunchi-memclob` | In-memory book + P2P order gossip | +| Long-term | `nunchi-clob` | Deterministic on-chain book state + fills | + +Memclob reuses `nunchi_clob::Transaction` for wire compatibility so the same signed bytes can later be replayed into chain state at block boundaries. + +## Integration + +- P2P channel: `examples/coins-chain` registers `channels::MEMCLOB = 7` +- Start with `MemClob::start_p2p(context, p2p_sender, p2p_receiver)` +- Query open book via `MemClobHandle::book(market, side)` +- Proposer pulls `MemClobHandle::pending_fills(limit)` diff --git a/memclob/src/actor.rs b/memclob/src/actor.rs new file mode 100644 index 0000000..5e77f44 --- /dev/null +++ b/memclob/src/actor.rs @@ -0,0 +1,295 @@ +use commonware_codec::{Encode, Read}; +use commonware_cryptography::PublicKey; +use commonware_macros::select_loop; +use commonware_p2p::{Receiver, Recipients, Sender}; +use commonware_runtime::{spawn_cell, ContextCell, Handle, Spawner}; +use futures::channel::{mpsc, oneshot}; +use futures::{SinkExt, StreamExt}; +use nunchi_clob::{Fill, FillId, MarketId, Order, Side, Transaction}; +use nunchi_common::RuntimeContext; +use tracing::{debug, warn}; + +use crate::book::MemBookEngine; +use crate::config::MemClobConfig; +use crate::error::MemClobError; + +enum Message { + Submit { + tx: Box, + context: RuntimeContext, + responder: oneshot::Sender>, + }, + Book { + market: MarketId, + side: Side, + responder: oneshot::Sender>, + }, + PendingFills { + limit: usize, + responder: oneshot::Sender>, + }, + Finalize { + fills: Vec, + }, +} + +/// Cloneable ingress handle for a running [`MemClob`]. +#[derive(Clone)] +pub struct MemClobHandle { + sender: mpsc::Sender, +} + +impl MemClobHandle { + /// Submit a signed order instruction for local matching and P2P gossip. + pub async fn submit( + &self, + tx: Transaction, + context: RuntimeContext, + ) -> Result<(), MemClobError> { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender + .send(Message::Submit { + tx: Box::new(tx), + context, + responder, + }) + .await + .is_err() + { + return Err(MemClobError::Shutdown); + } + receiver.await.unwrap_or(Err(MemClobError::Shutdown)) + } + + /// Read the current in-memory book side for a market. + pub async fn book(&self, market: MarketId, side: Side) -> Vec { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender + .send(Message::Book { + market, + side, + responder, + }) + .await + .is_err() + { + return Vec::new(); + } + receiver.await.unwrap_or_default() + } + + /// Drain fills waiting for on-chain settlement. + pub async fn pending_fills(&self, limit: usize) -> Vec { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender + .send(Message::PendingFills { limit, responder }) + .await + .is_err() + { + return Vec::new(); + } + receiver.await.unwrap_or_default() + } + + /// Mark fills as committed after block finalization. + pub fn finalize(&self, fills: Vec) { + let mut sender = self.sender.clone(); + if sender.try_send(Message::Finalize { fills }).is_err() { + warn!("memclob mailbox unavailable; dropping finalization report"); + } + } +} + +/// Single-owner in-memory order book actor. +pub struct MemClob { + receiver: mpsc::Receiver, + engine: MemBookEngine, +} + +impl MemClob { + pub fn new(config: MemClobConfig) -> (Self, MemClobHandle) { + let (sender, receiver) = mpsc::channel(config.mailbox_size.get()); + ( + Self { + receiver, + engine: MemBookEngine::with_dedup_capacity(config.dedup_capacity), + }, + MemClobHandle { sender }, + ) + } + + pub fn engine(&self) -> &MemBookEngine { + &self.engine + } + + pub fn start(self, context: E) -> Handle<()> { + InnerActor { + context: ContextCell::new(context), + memclob: self, + } + .start() + } + + pub fn start_p2p(self, context: E, p2p: (S, R)) -> Handle<()> + where + E: Spawner, + S: Sender + 'static, + R: Receiver + 'static, + Transaction: Read, + { + InnerActor { + context: ContextCell::new(context), + memclob: self, + } + .start_p2p(p2p) + } +} + +struct InnerActor { + context: ContextCell, + memclob: MemClob, +} + +impl InnerActor +where + E: Spawner, +{ + fn start(mut self) -> Handle<()> { + spawn_cell!(self.context, self.run()) + } + + fn start_p2p(mut self, p2p: (S, R)) -> Handle<()> + where + S: Sender + 'static, + R: Receiver + 'static, + Transaction: Read, + { + spawn_cell!(self.context, self.run_p2p(p2p)) + } + + async fn run(mut self) { + select_loop! { + self.context, + on_stopped => {}, + message = self.memclob.receiver.next() => { + let Some(message) = message else { + warn!("memclob mailbox closed, stopping runtime"); + return; + }; + self.handle(message); + }, + } + } + + async fn run_p2p(mut self, (mut sender, mut receiver): (S, R)) + where + S: Sender, + R: Receiver, + Transaction: Read, + { + select_loop! { + self.context, + on_stopped => {}, + message = self.memclob.receiver.next() => { + let Some(message) = message else { + warn!("memclob mailbox closed, stopping runtime"); + return; + }; + self.handle_with_gossip(message, &mut sender); + }, + message = receiver.recv() => { + match message { + Ok((peer, bytes)) => self.handle_network(peer, bytes), + Err(error) => { + warn!(?error, "memclob p2p receiver closed; continuing node-local"); + self.run().await; + return; + } + } + }, + } + } + + fn handle(&mut self, message: Message) { + match message { + Message::Submit { + tx, + context, + responder, + } => { + let result = self + .memclob + .engine + .apply_transaction(tx.as_ref(), context) + .map_err(MemClobError::from); + let _ = responder.send(result); + } + Message::Book { + market, + side, + responder, + } => { + let _ = responder.send(self.memclob.engine.book(&market, side)); + } + Message::PendingFills { limit, responder } => { + let _ = responder.send(self.memclob.engine.pending_fills_since(limit)); + } + Message::Finalize { fills } => { + self.memclob.engine.finalize_settlement(&fills); + } + } + } + + fn handle_with_gossip(&mut self, message: Message, sender: &mut S) + where + S: Sender, + { + match message { + Message::Submit { + tx, + context, + responder, + } => { + let gossip = tx.as_ref().clone(); + let result = self + .memclob + .engine + .apply_transaction(tx.as_ref(), context) + .map_err(MemClobError::from); + if result.is_ok() { + let sent = sender.send(Recipients::All, gossip.encode(), false); + if sent.is_empty() { + debug!("memclob p2p broadcast accepted by no peers"); + } + } + let _ = responder.send(result); + } + message => self.handle(message), + } + } + + fn handle_network

(&mut self, peer: P, mut bytes: commonware_runtime::IoBuf) + where + P: PublicKey, + Transaction: Read, + { + match Transaction::read_cfg(&mut bytes, &()) { + Ok(tx) => { + let context = RuntimeContext { + epoch: 0, + height: 0, + timestamp_ms: 0, + block_digest: None, + }; + match self.memclob.engine.apply_transaction(&tx, context) { + Ok(()) => debug!(?peer, "admitted gossiped memclob instruction"), + Err(error) => debug!(?peer, ?error, "rejected gossiped memclob instruction"), + } + } + Err(error) => warn!(?peer, ?error, "invalid gossiped memclob frame"), + } + } +} diff --git a/memclob/src/book.rs b/memclob/src/book.rs new file mode 100644 index 0000000..23b4646 --- /dev/null +++ b/memclob/src/book.rs @@ -0,0 +1,612 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use commonware_codec::Encode; +use commonware_cryptography::{Hasher, Sha256, sha256::Digest}; +use nunchi_clob::{ + canonical_asset_pair, market_id, ClobError, ClobOperation, Fill, FillId, Market, MarketId, Order, + OrderId, OrderStatus, Side, TimeInForce, Transaction, MAX_ACCOUNT_ORDERS, MAX_BOOK_ORDERS, + MAX_FILLS_PER_MARKET, MAX_MARKETS, CLOB_NAMESPACE, +}; +use nunchi_common::{Address, RuntimeContext}; + +/// Inputs for programmatic order placement inside the in-memory matcher. +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlaceOrderParams { + owner: Address, + order_id: OrderId, + market: MarketId, + side: Side, + price: u128, + base_quantity: u128, + time_in_force: TimeInForce, +} + +/// In-memory replica of the CLOB matching engine kept in validator RAM. +/// +/// Deterministic given the same stream of signed order instructions and runtime +/// context (height/timestamp), matching the on-chain `ClobLedger` semantics. +#[derive(Clone, Debug, Default)] +pub struct MemBookEngine { + nonces: HashMap, + markets: HashMap, + market_index: Vec, + orders: HashMap, + bid_books: HashMap>, + ask_books: HashMap>, + account_orders: HashMap>, + market_fill_ids: HashMap>, + fills: HashMap, + market_sequences: HashMap, + pending_settlement: VecDeque, + seen_digests: HashSet, + dedup_order: VecDeque, + dedup_capacity: usize, +} + +impl MemBookEngine { + pub fn with_dedup_capacity(capacity: usize) -> Self { + Self { + dedup_capacity: capacity.max(1), + ..Self::default() + } + } + + pub fn seed_market(&mut self, market: Market) { + let id = market.id; + if self.markets.contains_key(&id) { + return; + } + self.markets.insert(id, market); + if !self.market_index.contains(&id) { + self.market_index.push(id); + } + self.market_sequences.entry(id).or_insert(0); + } + + pub fn market(&self, id: &MarketId) -> Option<&Market> { + self.markets.get(id) + } + + pub fn order(&self, id: &OrderId) -> Option<&Order> { + self.orders.get(id) + } + + pub fn book(&self, market: &MarketId, side: Side) -> Vec { + self.book_ids(market, side) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|id| self.orders.get(&id).cloned()) + .collect() + } + + fn book_ids(&self, market: &MarketId, side: Side) -> Option<&Vec> { + match side { + Side::Bid => self.bid_books.get(market), + Side::Ask => self.ask_books.get(market), + } + } + + fn book_ids_mut(&mut self, market: MarketId, side: Side) -> &mut Vec { + match side { + Side::Bid => self.bid_books.entry(market).or_default(), + Side::Ask => self.ask_books.entry(market).or_default(), + } + } + + fn set_book_ids(&mut self, market: MarketId, side: Side, ids: Vec) { + match side { + Side::Bid => { + self.bid_books.insert(market, ids); + } + Side::Ask => { + self.ask_books.insert(market, ids); + } + } + } + + pub fn pending_fills(&self) -> &[Fill] { + self.pending_settlement.as_slices().0 + } + + pub fn pending_fills_since(&self, limit: usize) -> Vec { + self.pending_settlement.iter().take(limit).cloned().collect() + } + + pub fn finalize_settlement(&mut self, committed: &[FillId]) { + if committed.is_empty() { + return; + } + let committed: HashSet<_> = committed.iter().copied().collect(); + self.pending_settlement + .retain(|fill| !committed.contains(&fill.id)); + } + + pub fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), ClobError> { + tx.verify()?; + let digest = tx.digest(); + if self.seen_digests.contains(&digest) { + return Ok(()); + } + self.remember_digest(digest); + + let expected = self.nonces.get(&tx.account_id).copied().unwrap_or(0); + if tx.payload.nonce != expected { + return Err(ClobError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + + self.apply_operation(tx, context)?; + self.nonces.insert( + tx.account_id.clone(), + expected.checked_add(1).ok_or(ClobError::NonceOverflow)?, + ); + Ok(()) + } + + fn apply_operation( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), ClobError> { + match &tx.payload.operation { + ClobOperation::CreateMarket { + base_asset, + quote_asset, + tick_size, + lot_size, + } => self.create_market( + &tx.account_id, + *base_asset, + *quote_asset, + *tick_size, + *lot_size, + context, + ), + ClobOperation::PlaceOrder { + market, + side, + price, + base_quantity, + time_in_force, + } => { + self.place_order( + &PlaceOrderParams { + owner: tx.account_id.clone(), + order_id: OrderId(tx.digest()), + market: *market, + side: *side, + price: *price, + base_quantity: *base_quantity, + time_in_force: *time_in_force, + }, + context, + )?; + Ok(()) + } + ClobOperation::CancelOrder { order } => self.cancel_order(&tx.account_id, order), + ClobOperation::ApplyMatchBatch { .. } => Err(ClobError::OffchainOnly), + } + } + + fn create_market( + &mut self, + signer: &Address, + base_asset: nunchi_clob::AssetId, + quote_asset: nunchi_clob::AssetId, + tick_size: u128, + lot_size: u128, + context: RuntimeContext, + ) -> Result<(), ClobError> { + validate_market(base_asset, quote_asset, tick_size, lot_size)?; + let (base_asset, quote_asset) = canonical_asset_pair(base_asset, quote_asset); + let id = market_id(&base_asset, "e_asset, tick_size, lot_size); + if self.markets.contains_key(&id) { + return Err(ClobError::MarketAlreadyExists); + } + if self.market_index.len() == MAX_MARKETS { + return Err(ClobError::MarketIndexFull); + } + let market = Market { + id, + base_asset, + quote_asset, + tick_size, + lot_size, + created_by: signer.clone(), + created_at_height: context.height, + created_at_ms: context.timestamp_ms, + }; + self.markets.insert(id, market); + self.market_index.push(id); + self.market_sequences.insert(id, 0); + Ok(()) + } + + fn place_order( + &mut self, + params: &PlaceOrderParams, + context: RuntimeContext, + ) -> Result { + let market = self + .markets + .get(¶ms.market) + .ok_or(ClobError::MarketNotFound)? + .clone(); + validate_order(&market, params.price, params.base_quantity)?; + + if self.orders.contains_key(¶ms.order_id) { + return Err(ClobError::InvalidOrder("duplicate order id")); + } + + let account_orders = self + .account_orders + .get(¶ms.owner) + .cloned() + .unwrap_or_default(); + if account_orders.len() == MAX_ACCOUNT_ORDERS { + return Err(ClobError::AccountIndexFull); + } + + let opposite_side = params.side.opposite(); + let opposite_orders = self.load_orders( + self.book_ids(¶ms.market, opposite_side) + .cloned() + .unwrap_or_default(), + )?; + let simulation = simulate_matches( + params.side, + params.price, + params.base_quantity, + &opposite_orders, + )?; + + let market_fill_ids = self + .market_fill_ids + .get(¶ms.market) + .cloned() + .unwrap_or_default(); + if market_fill_ids.len().saturating_add(simulation.fills) > MAX_FILLS_PER_MARKET { + return Err(ClobError::FillIndexFull); + } + + if params.time_in_force == TimeInForce::GoodTilCancelled && simulation.remaining_base > 0 { + let same_side_book = self.book_ids(¶ms.market, params.side).map(Vec::len).unwrap_or(0); + if same_side_book == MAX_BOOK_ORDERS { + return Err(ClobError::BookFull); + } + } + + let sequence = self.next_sequence(¶ms.market)?; + let mut taker = Order { + id: params.order_id, + owner: params.owner.clone(), + market: params.market, + side: params.side, + price: params.price, + original_base: params.base_quantity, + remaining_base: params.base_quantity, + filled_base: 0, + status: OrderStatus::Open, + sequence, + created_at_height: context.height, + created_at_ms: context.timestamp_ms, + }; + + let mut updated_opposite_book = Vec::with_capacity(opposite_orders.len()); + let mut market_fill_ids = market_fill_ids; + for idx in 0..opposite_orders.len() { + if taker.remaining_base == 0 { + append_open_orders(&mut updated_opposite_book, &opposite_orders[idx..]); + break; + } + + let mut maker = opposite_orders[idx].clone(); + if !maker.status.is_open() || maker.remaining_base == 0 { + continue; + } + if !params.side.crosses(params.price, maker.price) { + append_open_orders(&mut updated_opposite_book, &opposite_orders[idx..]); + break; + } + + let base = taker.remaining_base.min(maker.remaining_base); + let quote = maker + .price + .checked_mul(base) + .ok_or(ClobError::QuoteOverflow)?; + let fill_sequence = self.next_sequence(¶ms.market)?; + let fill = Fill { + id: fill_id(&taker.id, &maker.id, fill_sequence), + market: params.market, + maker_order: maker.id, + taker_order: taker.id, + maker: maker.owner.clone(), + taker: taker.owner.clone(), + taker_side: params.side, + price: maker.price, + base_quantity: base, + quote_quantity: quote, + sequence: fill_sequence, + written_at_height: context.height, + written_at_ms: context.timestamp_ms, + }; + + taker.remaining_base -= base; + taker.filled_base += base; + maker.remaining_base -= base; + maker.filled_base += base; + maker.status = if maker.remaining_base == 0 { + OrderStatus::Filled + } else { + OrderStatus::PartiallyFilled + }; + + self.fills.insert(fill.id, fill.clone()); + market_fill_ids.push(fill.id); + self.pending_settlement.push_back(fill); + self.orders.insert(maker.id, maker.clone()); + if maker.status.is_open() && maker.remaining_base > 0 { + updated_opposite_book.push(maker.id); + } else { + self.remove_from_account_orders(&maker.owner, &maker.id); + } + } + + self.set_book_ids(params.market, opposite_side, updated_opposite_book); + self.market_fill_ids.insert(params.market, market_fill_ids); + + taker.status = if taker.remaining_base == 0 { + OrderStatus::Filled + } else if params.time_in_force == TimeInForce::ImmediateOrCancel { + OrderStatus::Expired + } else if taker.filled_base == 0 { + OrderStatus::Open + } else { + OrderStatus::PartiallyFilled + }; + + self.orders.insert(taker.id, taker.clone()); + if taker.status.is_open() { + let mut account_orders = self + .account_orders + .get(¶ms.owner) + .cloned() + .unwrap_or_default(); + account_orders.push(taker.id); + self.account_orders.insert(params.owner.clone(), account_orders); + if taker.remaining_base > 0 { + self.insert_resting_order(&taker)?; + } + } else { + self.remove_from_account_orders(¶ms.owner, &taker.id); + } + Ok(taker) + } + + fn cancel_order(&mut self, signer: &Address, order_id: &OrderId) -> Result<(), ClobError> { + let mut order = self + .orders + .get(order_id) + .cloned() + .ok_or(ClobError::OrderNotFound)?; + if order.owner != *signer { + return Err(ClobError::UnauthorizedCancel); + } + if !order.status.is_open() || order.remaining_base == 0 { + return Err(ClobError::OrderClosed); + } + + if let Some(book) = match order.side { + Side::Bid => self.bid_books.get_mut(&order.market), + Side::Ask => self.ask_books.get_mut(&order.market), + } { + book.retain(|id| id != order_id); + } + + order.status = OrderStatus::Cancelled; + self.orders.insert(*order_id, order.clone()); + self.remove_from_account_orders(&order.owner, order_id); + Ok(()) + } + + fn remove_from_account_orders(&mut self, owner: &Address, order_id: &OrderId) { + if let Some(orders) = self.account_orders.get_mut(owner) { + let before = orders.len(); + orders.retain(|id| id != order_id); + if orders.is_empty() && before != 0 { + self.account_orders.remove(owner); + } + } + } + + fn insert_resting_order(&mut self, order: &Order) -> Result<(), ClobError> { + let book_ids = self + .book_ids(&order.market, order.side) + .cloned() + .unwrap_or_default(); + if book_ids.len() == MAX_BOOK_ORDERS { + return Err(ClobError::BookFull); + } + + let mut insert_at = book_ids.len(); + for (idx, resting_id) in book_ids.iter().enumerate() { + let resting = self + .orders + .get(resting_id) + .ok_or(ClobError::MissingOrder)?; + if has_better_priority(order, resting) { + insert_at = idx; + break; + } + } + let book = self.book_ids_mut(order.market, order.side); + if book.len() == MAX_BOOK_ORDERS { + return Err(ClobError::BookFull); + } + book.insert(insert_at, order.id); + Ok(()) + } + + fn next_sequence(&mut self, market: &MarketId) -> Result { + let sequence = *self.market_sequences.get(market).unwrap_or(&0); + let next = sequence.checked_add(1).ok_or(ClobError::SequenceOverflow)?; + self.market_sequences.insert(*market, next); + Ok(sequence) + } + + fn load_orders(&self, ids: Vec) -> Result, ClobError> { + ids.into_iter() + .map(|id| self.orders.get(&id).cloned().ok_or(ClobError::MissingOrder)) + .collect() + } + + fn remember_digest(&mut self, digest: Digest) { + if !self.seen_digests.insert(digest) { + return; + } + self.dedup_order.push_back(digest); + while self.dedup_order.len() > self.dedup_capacity { + if let Some(old) = self.dedup_order.pop_front() { + self.seen_digests.remove(&old); + } + } + } +} + +fn fill_id(taker: &OrderId, maker: &OrderId, sequence: u64) -> FillId { + let mut bytes = taker.encode().as_ref().to_vec(); + bytes.extend_from_slice(maker.encode().as_ref()); + bytes.extend_from_slice(sequence.encode().as_ref()); + FillId(Sha256::hash(&bytes)) +} + +fn validate_market( + base_asset: nunchi_clob::AssetId, + quote_asset: nunchi_clob::AssetId, + tick_size: u128, + lot_size: u128, +) -> Result<(), ClobError> { + if base_asset == quote_asset { + return Err(ClobError::InvalidMarket( + "base and quote assets must differ", + )); + } + if tick_size == 0 { + return Err(ClobError::InvalidMarket("tick size must be non-zero")); + } + if lot_size == 0 { + return Err(ClobError::InvalidMarket("lot size must be non-zero")); + } + Ok(()) +} + +fn validate_order(market: &Market, price: u128, base_quantity: u128) -> Result<(), ClobError> { + if price == 0 { + return Err(ClobError::InvalidOrder("price must be non-zero")); + } + if base_quantity == 0 { + return Err(ClobError::InvalidOrder("quantity must be non-zero")); + } + if !price.is_multiple_of(market.tick_size) { + return Err(ClobError::InvalidOrder("price is not on the market tick")); + } + if !base_quantity.is_multiple_of(market.lot_size) { + return Err(ClobError::InvalidOrder( + "quantity is not on the market lot", + )); + } + Ok(()) +} + +fn has_better_priority(candidate: &Order, resting: &Order) -> bool { + match candidate.side { + Side::Bid => { + candidate.price > resting.price + || (candidate.price == resting.price && candidate.sequence < resting.sequence) + } + Side::Ask => { + candidate.price < resting.price + || (candidate.price == resting.price && candidate.sequence < resting.sequence) + } + } +} + +struct MatchSimulation { + fills: usize, + remaining_base: u128, +} + +fn append_open_orders(book: &mut Vec, orders: &[Order]) { + for order in orders { + if order.status.is_open() && order.remaining_base > 0 { + book.push(order.id); + } + } +} + +fn simulate_matches( + side: Side, + price: u128, + base_quantity: u128, + opposite_orders: &[Order], +) -> Result { + let mut remaining = base_quantity; + let mut fills = 0; + for maker in opposite_orders { + if remaining == 0 { + break; + } + if !maker.status.is_open() || maker.remaining_base == 0 { + continue; + } + if !side.crosses(price, maker.price) { + break; + } + maker + .price + .checked_mul(remaining.min(maker.remaining_base)) + .ok_or(ClobError::QuoteOverflow)?; + remaining -= remaining.min(maker.remaining_base); + fills += 1; + } + Ok(MatchSimulation { + fills, + remaining_base: remaining, + }) +} + +/// Deterministic digest for a memclob P2P frame prefix. +pub const MEMCLOB_NAMESPACE: &[u8] = b"_NUNCHI_MEMCLOB"; + +/// Hash helper used by tests and future snapshot sync. +pub fn snapshot_digest(engine: &MemBookEngine) -> Digest { + let mut hasher = Sha256::new(); + hasher.update(MEMCLOB_NAMESPACE); + hasher.update(CLOB_NAMESPACE); + hasher.update(engine.market_index.len().encode().as_ref()); + for market in &engine.market_index { + hasher.update(market.encode().as_ref()); + if let Some(book) = engine.book_ids(market, Side::Bid) { + encode_order_ids(&mut hasher, book); + } + if let Some(book) = engine.book_ids(market, Side::Ask) { + encode_order_ids(&mut hasher, book); + } + } + hasher.finalize() +} + +fn encode_order_ids(hasher: &mut Sha256, ids: &[OrderId]) { + hasher.update(ids.len().encode().as_ref()); + for id in ids { + hasher.update(id.encode().as_ref()); + } +} diff --git a/memclob/src/config.rs b/memclob/src/config.rs new file mode 100644 index 0000000..dc52477 --- /dev/null +++ b/memclob/src/config.rs @@ -0,0 +1,19 @@ +use std::num::NonZeroUsize; + +/// Memclob actor configuration. +#[derive(Clone, Debug)] +pub struct MemClobConfig { + /// Capacity of the actor ingress mailbox. + pub mailbox_size: NonZeroUsize, + /// Maximum gossiped instructions retained for deduplication. + pub dedup_capacity: usize, +} + +impl Default for MemClobConfig { + fn default() -> Self { + Self { + mailbox_size: NonZeroUsize::new(1024).expect("non-zero mailbox"), + dedup_capacity: 65_536, + } + } +} diff --git a/memclob/src/error.rs b/memclob/src/error.rs new file mode 100644 index 0000000..0f6451d --- /dev/null +++ b/memclob/src/error.rs @@ -0,0 +1,13 @@ +use nunchi_clob::ClobError; +use thiserror::Error; + +/// Errors surfaced by the in-memory book actor. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum MemClobError { + #[error("memclob actor shut down")] + Shutdown, + #[error("duplicate gossiped order instruction")] + DuplicateInstruction, + #[error("clob error: {0}")] + Clob(#[from] ClobError), +} diff --git a/memclob/src/lib.rs b/memclob/src/lib.rs new file mode 100644 index 0000000..82c2670 --- /dev/null +++ b/memclob/src/lib.rs @@ -0,0 +1,19 @@ +//! Validator-local in-memory order books with P2P gossip. +//! +//! Each validator keeps a deterministic [`MemBookEngine`] in RAM. Signed +//! `nunchi_clob::Transaction` instructions gossip over the overlay before +//! fills settle on-chain through `nunchi_clob::ClobLedger`. + +commonware_macros::stability_scope!(ALPHA { +mod actor; +mod book; +mod config; +mod error; +#[cfg(test)] +mod tests; + +pub use actor::{MemClob, MemClobHandle}; +pub use book::{snapshot_digest, MemBookEngine, MEMCLOB_NAMESPACE}; +pub use config::MemClobConfig; +pub use error::MemClobError; +}); diff --git a/memclob/src/tests.rs b/memclob/src/tests.rs new file mode 100644 index 0000000..a3399a7 --- /dev/null +++ b/memclob/src/tests.rs @@ -0,0 +1,262 @@ +use commonware_cryptography::{ed25519, Hasher, Sha256, Signer as _}; +use commonware_p2p::simulated::{self, Link, Network}; +use commonware_runtime::{deterministic, Clock, Runner as _, Supervisor}; +use commonware_utils::{NZUsize, NZU32}; +use governor::Quota; +use nunchi_clob::{ + market_id, AssetId, ClobOperation, MatchBatch, OrderStatus, Side, TimeInForce, Transaction, +}; +use nunchi_common::RuntimeContext; +use nunchi_crypto::PrivateKey; +use std::time::Duration; + +use crate::{MemClob, MemClobConfig}; + +fn block_context(height: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height, + timestamp_ms: height * 1_000, + block_digest: None, + } +} + +fn asset(seed: u8) -> AssetId { + AssetId(Sha256::hash(&[seed])) +} + +fn market() -> nunchi_clob::MarketId { + market_id(&asset(1), &asset(2), 1, 1) +} + +#[test] +fn memclob_matches_orders_deterministically() { + let mut engine = crate::MemBookEngine::default(); + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); + + let create = Transaction::sign( + &creator, + 0, + ClobOperation::CreateMarket { + base_asset: asset(1), + quote_asset: asset(2), + tick_size: 1, + lot_size: 1, + }, + ); + engine.apply_transaction(&create, block_context(1)).unwrap(); + + let ask = Transaction::sign( + &maker, + 0, + ClobOperation::PlaceOrder { + market: market(), + side: Side::Ask, + price: 100, + base_quantity: 10, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ); + engine.apply_transaction(&ask, block_context(2)).unwrap(); + + let bid = Transaction::sign( + &taker, + 0, + ClobOperation::PlaceOrder { + market: market(), + side: Side::Bid, + price: 100, + base_quantity: 4, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ); + engine.apply_transaction(&bid, block_context(3)).unwrap(); + + assert_eq!(engine.pending_fills().len(), 1); + let fill = &engine.pending_fills()[0]; + assert_eq!(fill.base_quantity, 4); + assert_eq!(fill.price, 100); + + let asks = engine.book(&market(), Side::Ask); + assert_eq!(asks.len(), 1); + assert_eq!(asks[0].remaining_base, 6); + assert_eq!(asks[0].status, OrderStatus::PartiallyFilled); +} + +#[test] +fn memclob_gossips_orders_between_validators() { + deterministic::Runner::default().start(|context| async move { + let key_a = ed25519::PrivateKey::from_seed(1); + let key_b = ed25519::PrivateKey::from_seed(2); + let peer_a = key_a.public_key(); + let peer_b = key_b.public_key(); + let (network, oracle) = Network::<_, ed25519::PublicKey>::new_with_peers( + context.child("network"), + simulated::Config { + max_size: 1024 * 1024, + disconnect_on_block: true, + tracked_peer_sets: NZUsize!(1), + }, + [peer_a.clone(), peer_b.clone()], + ) + .await; + network.start(); + + let channel = 7; + let quota = Quota::per_second(NZU32!(u32::MAX)); + let p2p_a = oracle + .control(peer_a.clone()) + .register(channel, quota) + .await + .unwrap(); + let p2p_b = oracle + .control(peer_b.clone()) + .register(channel, quota) + .await + .unwrap(); + let link = Link { + latency: Duration::from_millis(10), + jitter: Duration::ZERO, + success_rate: 1.0, + }; + oracle + .add_link(peer_a.clone(), peer_b.clone(), link.clone()) + .await + .unwrap(); + oracle.add_link(peer_b, peer_a, link).await.unwrap(); + + let cfg = MemClobConfig::default(); + let (memclob_a, handle_a) = MemClob::new(cfg.clone()); + let (memclob_b, handle_b) = MemClob::new(cfg); + memclob_a.start_p2p(context.child("memclob_a"), p2p_a); + memclob_b.start_p2p(context.child("memclob_b"), p2p_b); + context.sleep(Duration::from_millis(1)).await; + + let creator = PrivateKey::from_seed(10); + let maker = PrivateKey::from_seed(11); + let taker = PrivateKey::from_seed(12); + + handle_a + .submit( + Transaction::sign( + &creator, + 0, + ClobOperation::CreateMarket { + base_asset: asset(1), + quote_asset: asset(2), + tick_size: 1, + lot_size: 1, + }, + ), + block_context(1), + ) + .await + .unwrap(); + + handle_a + .submit( + Transaction::sign( + &maker, + 0, + ClobOperation::PlaceOrder { + market: market(), + side: Side::Ask, + price: 50, + base_quantity: 5, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ), + block_context(2), + ) + .await + .unwrap(); + + for _ in 0..100 { + if !handle_b.book(market(), Side::Ask).await.is_empty() { + break; + } + context.sleep(Duration::from_millis(5)).await; + } + + handle_b + .submit( + Transaction::sign( + &taker, + 0, + ClobOperation::PlaceOrder { + market: market(), + side: Side::Bid, + price: 50, + base_quantity: 5, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ), + block_context(3), + ) + .await + .unwrap(); + + for _ in 0..100 { + let fills_a = handle_a.pending_fills(10).await; + let fills_b = handle_b.pending_fills(10).await; + if fills_a.len() == 1 && fills_b.len() == 1 { + assert_eq!(fills_a[0].id, fills_b[0].id); + return; + } + context.sleep(Duration::from_millis(5)).await; + } + panic!("gossiped memclob orders did not converge"); + }); +} + +#[test] +fn memclob_rejects_on_chain_only_operations() { + let mut engine = crate::MemBookEngine::default(); + let maker = PrivateKey::from_seed(4); + let market = market(); + + engine + .apply_transaction( + &Transaction::sign( + &PrivateKey::from_seed(1), + 0, + ClobOperation::CreateMarket { + base_asset: asset(1), + quote_asset: asset(2), + tick_size: 1, + lot_size: 1, + }, + ), + block_context(1), + ) + .unwrap(); + + let ask = Transaction::sign( + &maker, + 0, + ClobOperation::PlaceOrder { + market, + side: Side::Ask, + price: 100, + base_quantity: 5, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ); + engine.apply_transaction(&ask, block_context(2)).unwrap(); + + let err = engine + .apply_transaction( + &Transaction::sign( + &maker, + 1, + ClobOperation::ApplyMatchBatch { + batch: MatchBatch::default(), + }, + ), + block_context(3), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "signed order intents are off-chain only"); +} diff --git a/perpetuals/Cargo.toml b/perpetuals/Cargo.toml new file mode 100644 index 0000000..5a87d6f --- /dev/null +++ b/perpetuals/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "nunchi-perpetuals" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[features] +default = ["rpc"] +rpc = ["dep:futures", "dep:jsonrpsee", "dep:nunchi-rpc"] +mempool = ["dep:nunchi-mempool"] +actor = ["dep:commonware-actor", "dep:commonware-macros", "dep:commonware-runtime", "dep:commonware-utils", "dep:tracing"] +mock-execution = [] + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +thiserror = { workspace = true } +nunchi-coins = { workspace = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +nunchi-mempool = { workspace = true, optional = true } +nunchi-oracle = { workspace = true } +nunchi-rpc = { workspace = true, optional = true } +commonware-actor = { workspace = true, optional = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +commonware-macros = { workspace = true, optional = true } +commonware-runtime = { workspace = true, optional = true } +commonware-utils = { workspace = true, optional = true } +jsonrpsee = { workspace = true, optional = true } +serde = { workspace = true } +futures = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } + +[dev-dependencies] +commonware-actor = { workspace = true } +commonware-runtime = { workspace = true } +commonware-utils = { workspace = true } +tracing = { workspace = true } +serde_json = { workspace = true } diff --git a/perpetuals/README.md b/perpetuals/README.md new file mode 100644 index 0000000..05079ec --- /dev/null +++ b/perpetuals/README.md @@ -0,0 +1,77 @@ +# nunchi-perpetuals + +`nunchi-perpetuals` is a minimal perpetual futures primitive for Nunchi chains. It is intentionally generic: markets, isolated collateral, funding, liquidation, and Oracle-backed mark prices are implemented without assuming a specific venue or product shape. + +## Current model + +- Markets define base, quote, and collateral assets, plus their Oracle namespace, trusted `oracle_writer`, and risk parameters. +- Oracle records remain opaque to `nunchi-oracle`; this module decodes `OraclePricePayload`, scales/truncates prices, and owns freshness checks. Malformed or untrusted-writer records are skipped rather than aborting refresh. +- `index_price` comes from the trusted oracle writer. `mark_price` is bootstrapped from the first oracle refresh and can diverge via `UpdateMarkPrice` or the optional `actor` mailbox wired to the CLOB module. +- Positions use isolated margin. Collateral is escrowed in a deterministic perps account backed by `nunchi-coins` balances. +- Funding accrues only when both long and short open interest are non-zero, keeping index deltas zero-sum across sides. +- Liquidation pays a configurable reward to the liquidator, returns residual equity to the owner when possible, and routes the remainder to an insurance fund account. +- Underwater positions cannot be closed voluntarily; they must be liquidated. + +## CLOB + clearinghouse integration + +Production position changes arrive via `nunchi-clearinghouse::SettleFill`, which calls +`apply_fill_settlement` for both maker and taker after the shared CLOB emits a `Fill`. + +Direct `OpenPosition` / `ClosePosition` transactions require the `mock-execution` feature. + +## CLOB mark-price ingress (PR #117) + +Enable the `actor` feature to expose: + +- `nunchi_perpetuals::ingress::Mailbox` — send `UpdateMarkPrice` from the CLOB actor after matching. +- `nunchi_perpetuals::actor::Actor` — drain mailbox messages into `PerpetualLedger` state. + +Markets may optionally store a linked `clob_market` id at creation time so operators can wire the two modules deterministically. + +## Mock Oracle payload + +The perps module expects Oracle records to carry an encoded `OraclePricePayload`: + +```rust +OraclePricePayload { + market, + price, + price_decimals, + source_timestamp_ms, +} +``` + +The Oracle stores this as opaque bytes under a `NamespaceId` and `IntervalKey`. The perps market refresh step queries the configured namespace, decodes only records matching the market id, verifies source and write-time freshness, and updates the market's mark and index price. + +## RPC exercise flow + +The coins-chain example exposes the perps module over JSON-RPC when built with this crate: + +- `perpetuals.nonce` +- `perpetuals.market` +- `perpetuals.position` +- `perpetuals.state_root` +- `perpetuals.submit_transaction` +- `perpetuals.transaction_status` + +A minimal mock flow is: + +1. Create a collateral token through `coins.submit_transaction`. +2. Transfer collateral to the trader account. +3. Configure an Oracle namespace and writer through Oracle transactions. +4. Append a mock `OraclePricePayload` record for the target market. +5. Submit `PerpetualOperation::CreateMarket`. +6. Submit `PerpetualOperation::RefreshMarketFromOracle`. +7. Submit `PerpetualOperation::OpenPosition`. +8. Append an adverse mock price record. +9. Submit another `RefreshMarketFromOracle`. +10. Submit `PerpetualOperation::Liquidate`. + +The consensus-backed integration test `perps_oracle_flow_finalizes_across_validators` in `examples/coins-chain/tests/coins.rs` executes this sequence through the same submitted transaction path used by the example chain. + +## Known follow-ups + +- Add explicit insurance fund and liquidation reward semantics. +- Decide whether profitable PnL should be paid from pooled collateral, a funded insurance account, or a separate market-liquidity primitive. +- Add market admin controls for parameter updates once the first draft API is reviewed. +- Reconcile wire tags when this branch is rebased onto the final Oracle and swap branch stack. diff --git a/perpetuals/ROADMAP.md b/perpetuals/ROADMAP.md new file mode 100644 index 0000000..1a1284b --- /dev/null +++ b/perpetuals/ROADMAP.md @@ -0,0 +1,46 @@ +# Perpetuals Modular Roadmap + +This document supersedes the original PR #80 follow-up list. Perpetuals is now a +**CLOB consumer** settled through `nunchi-clearinghouse`, not a standalone execution engine. + +## Module boundaries + +| Module | Responsibility | +|--------|----------------| +| `nunchi-clob` | Book state, matching, `Fill` records | +| `nunchi-clearinghouse` | Fill settlement routing, idempotency, counterparty pairing | +| `nunchi-perpetuals` | Margin, funding, PnL, liquidations, oracle index/mark | +| `nunchi-cbc` (future) | Uniform-price batch clearing client — not in CLOB or perps | + +## Landed in `jl/clearinghouse-perps-modular` + +- [x] `nunchi-clearinghouse` crate with `RegisterPerpsMarket` + `SettleFill` +- [x] `apply_fill_settlement` on perps — deterministic `(owner, market, side)` positions +- [x] CLOB lot → perps `PRICE_SCALE` quantity conversion at settlement +- [x] `OpenPosition` / `ClosePosition` gated behind `mock-execution` feature +- [x] Integration tests: CLOB match → clearinghouse settle → long/short OI + +## Next PRs (replaces open #79 / #80) + +1. **Wire into `examples/coins/chain`** — transaction tags for clearinghouse + perps refactor (landed) +2. **Block proposer path** — batch `SettleFill` from memclob `pending_fills()` +3. **Reduce-only CLOB orders** — perps close flow via opposite-side fills +4. **Spot settlement domain** — `SettlementDomain::Spot` in clearinghouse +5. **CBC client** — batch settlement into clearinghouse (separate crate) + +## Production hardening (carried from original #80) + +- Market admin controls (`SetMarketStatus`, `UpdateRiskParams`) +- Operator tooling for oracle payload + settlement demo +- Expanded adversarial tests (partial close, insurance draw, multi-market) +- Fee model and protocol revenue accounting +- Wire-tag reconciliation with Jacob before merge + +## Merge readiness + +- [x] `cargo test -p nunchi-clearinghouse` +- [x] `cargo test -p nunchi-perpetuals --features mock-execution` +- [x] `cargo test -p nunchi-clob` +- [x] coins-chain integration test for clearinghouse → perps settlement (`CommitAndSettleFill`) +- [ ] coins-chain integration test for live mailbox match batch → `SettleFill` (tracked in #133 follow-up) +- [ ] Close superseded PRs #79 and #80 with links to new stack diff --git a/perpetuals/src/actor.rs b/perpetuals/src/actor.rs new file mode 100644 index 0000000..3290af1 --- /dev/null +++ b/perpetuals/src/actor.rs @@ -0,0 +1,85 @@ +//! Perpetuals actor that drains mailbox messages into ledger state. +//! +//! Chain runtimes can spawn this beside the CLOB actor and wire the CLOB +//! mailbox as a producer of [`crate::ingress::Message::UpdateMarkPrice`]. + +use crate::{ + ingress::{apply_message, Mailbox}, + PerpetualDB, PerpetualLedger, +}; +use commonware_actor::mailbox::{self, Receiver as ActorReceiver}; +use commonware_macros::select_loop; +use commonware_runtime::{Clock, ContextCell, Handle, Metrics, Spawner}; +use nunchi_common::StateStore; +use std::num::NonZeroUsize; +use tracing::{debug, warn}; + +/// Perpetuals actor configuration. +#[derive(Clone, Debug)] +pub struct Config { + pub mailbox_size: NonZeroUsize, +} + +/// Perpetuals actor over a shared database backend. +pub struct Actor { + context: ContextCell, + mailbox: ActorReceiver, + ledger: PerpetualLedger, +} + +impl Actor +where + C: Metrics + Spawner + Clock, + D: PerpetualDB + nunchi_coins::CoinDB + StateStore + Send + Sync + 'static, +{ + /// Create a new actor and its paired mailbox. + pub fn new(context: C, cfg: Config, ledger: PerpetualLedger) -> (Self, Mailbox) { + let (sender, mailbox) = mailbox::new(context.child("perpetuals-mailbox"), cfg.mailbox_size); + ( + Self { + context: ContextCell::new(context), + mailbox, + ledger, + }, + Mailbox::new(sender), + ) + } + + /// Borrow the ledger for queries while the actor is idle. + pub fn ledger(&self) -> &PerpetualLedger { + &self.ledger + } + + /// Borrow the ledger mutably for deterministic tests. + pub fn ledger_mut(&mut self) -> &mut PerpetualLedger { + &mut self.ledger + } + + /// Start processing mailbox messages until the context is stopped. + pub fn start(mut self) -> Handle<()> + where + C: Spawner + Send + 'static, + { + commonware_runtime::spawn_cell!(self.context, self.run()) + } + + async fn run(mut self) { + loop { + select_loop! { + self.context, + on_stopped => { + debug!("perpetuals actor stopped"); + break; + }, + message = self.mailbox.recv() => { + let Some(message) = message else { + continue; + }; + if let Err(err) = apply_message(&mut self.ledger, message).await { + warn!(?err, "failed to apply perpetuals mailbox message"); + } + }, + } + } + } +} diff --git a/perpetuals/src/db.rs b/perpetuals/src/db.rs new file mode 100644 index 0000000..00fc89a --- /dev/null +++ b/perpetuals/src/db.rs @@ -0,0 +1,148 @@ +//! Persistence layer for the perpetuals module. + +use crate::{Market, MarketId, PerpetualError, Position, PositionId, PERPETUALS_NAMESPACE}; +use async_trait::async_trait; +use commonware_codec::{Encode, Read, ReadExt}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::{Address, Namespace, StateStore}; + +const NS: Namespace = Namespace::new(PERPETUALS_NAMESPACE); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + Account = 0, + MarketNonce = 1, + PositionNonce = 2, + Market = 3, + Position = 4, +} + +impl From

for u8 { + fn from(table: Table) -> Self { + table as Self + } +} + +fn encoded(value: &T) -> Vec { + value.encode().as_ref().to_vec() +} + +fn decoded>(bytes: &[u8]) -> Result { + let mut buf = bytes; + T::read(&mut buf).map_err(|err| PerpetualError::Storage(err.to_string())) +} + +/// State access required by the perps ledger. +#[async_trait] +pub trait PerpetualDB { + async fn nonce(&self, id: &Address) -> Result; + + fn set_nonce(&mut self, id: &Address, nonce: u64); + + async fn market_nonce(&self) -> Result; + + fn set_market_nonce(&mut self, nonce: u64); + + async fn position_nonce(&self) -> Result; + + fn set_position_nonce(&mut self, nonce: u64); + + async fn market(&self, market: &MarketId) -> Result, PerpetualError>; + + fn set_market(&mut self, market: &Market); + + async fn position(&self, position: &PositionId) -> Result, PerpetualError>; + + fn set_position(&mut self, position: &Position); + + fn remove_position(&mut self, position: &PositionId); +} + +#[async_trait] +impl PerpetualDB for S { + async fn nonce(&self, id: &Address) -> Result { + let key = NS.key(Table::Account, &encoded(id)); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes), + None => Ok(0), + } + } + + fn set_nonce(&mut self, id: &Address, nonce: u64) { + let key = NS.key(Table::Account, &encoded(id)); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn market_nonce(&self) -> Result { + let key = NS.key(Table::MarketNonce, &[]); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes), + None => Ok(0), + } + } + + fn set_market_nonce(&mut self, nonce: u64) { + let key = NS.key(Table::MarketNonce, &[]); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn position_nonce(&self) -> Result { + let key = NS.key(Table::PositionNonce, &[]); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes), + None => Ok(0), + } + } + + fn set_position_nonce(&mut self, nonce: u64) { + let key = NS.key(Table::PositionNonce, &[]); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn market(&self, market: &MarketId) -> Result, PerpetualError> { + let key = NS.key(Table::Market, &encoded(market)); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded::(&bytes)?)), + None => Ok(None), + } + } + + fn set_market(&mut self, market: &Market) { + let key = NS.key(Table::Market, &encoded(&market.id)); + StateStore::set(self, key, encoded(market)); + } + + async fn position(&self, position: &PositionId) -> Result, PerpetualError> { + let key = NS.key(Table::Position, &encoded(position)); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded::(&bytes)?)), + None => Ok(None), + } + } + + fn set_position(&mut self, position: &Position) { + let key = NS.key(Table::Position, &encoded(&position.id)); + StateStore::set(self, key, encoded(position)); + } + + fn remove_position(&mut self, position: &PositionId) { + let key: Digest = NS.key(Table::Position, &encoded(position)); + StateStore::remove(self, key); + } +} diff --git a/perpetuals/src/genesis.rs b/perpetuals/src/genesis.rs new file mode 100644 index 0000000..64c3114 --- /dev/null +++ b/perpetuals/src/genesis.rs @@ -0,0 +1,152 @@ +use crate::{MarketId, PerpetualDB, PerpetualError, PerpetualLedger}; +use commonware_codec::{DecodeExt, Encode}; +use commonware_formatting::{from_hex, hex}; +use nunchi_coins::CoinId; +use nunchi_oracle::NamespaceId; +use serde::{Deserialize, Serialize}; + +/// JSON-facing perpetuals module genesis state. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PerpetualsGenesis { + #[serde(default)] + pub markets: Vec, +} + +/// JSON-facing market configuration seeded at genesis. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct MarketGenesis { + #[serde(with = "serde_hex")] + pub base_asset: CoinId, + #[serde(with = "serde_hex")] + pub quote_asset: CoinId, + #[serde(with = "serde_hex")] + pub collateral_asset: CoinId, + #[serde(with = "serde_hex")] + pub oracle_namespace: NamespaceId, + #[serde(with = "serde_address")] + pub oracle_writer: nunchi_common::Address, + #[serde(default, with = "serde_optional_hex")] + pub clob_market: Option, + pub oracle_interval_ms: u64, + pub max_oracle_staleness_ms: u64, + pub price_decimals: u8, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub funding_interval_ms: u64, + pub max_funding_rate_bps: u32, + #[serde(default = "default_liquidation_reward_bps")] + pub liquidation_reward_bps: u32, +} + +fn default_liquidation_reward_bps() -> u32 { + crate::DEFAULT_LIQUIDATION_REWARD_BPS +} + +impl + PerpetualLedger +{ + /// Seed perpetuals state from genesis without transaction authorization. + pub async fn apply_genesis( + &mut self, + genesis: &PerpetualsGenesis, + ) -> Result, PerpetualError> { + let mut ids = Vec::with_capacity(genesis.markets.len()); + for market in &genesis.markets { + ids.push( + self.create_market( + market.base_asset, + market.quote_asset, + market.collateral_asset, + market.oracle_namespace, + market.oracle_writer.clone(), + market.clob_market, + market.oracle_interval_ms, + market.max_oracle_staleness_ms, + market.price_decimals, + market.max_leverage_bps, + market.maintenance_margin_bps, + market.funding_interval_ms, + market.max_funding_rate_bps, + market.liquidation_reward_bps, + ) + .await?, + ); + } + Ok(ids) + } +} + +mod serde_hex { + use super::*; + use serde::{de::Error as _, Deserializer, Serializer}; + + pub fn serialize(value: &T, serializer: S) -> Result + where + T: Encode, + S: Serializer, + { + serializer.serialize_str(&hex(&value.encode())) + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result + where + T: DecodeExt<()>, + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let bytes = + from_hex(&value).ok_or_else(|| D::Error::custom("expected hex-encoded codec bytes"))?; + T::decode(bytes.as_ref()).map_err(D::Error::custom) + } +} + +mod serde_address { + use nunchi_common::Address; + use serde::{de::Error as _, Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &Address, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(D::Error::custom) + } +} + +mod serde_optional_hex { + use super::*; + use serde::{de::Error as _, Deserializer, Serializer}; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + T: Encode, + S: Serializer, + { + match value { + Some(value) => serializer.serialize_some(&hex(&value.encode())), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result, D::Error> + where + T: DecodeExt<()>, + D: Deserializer<'de>, + { + let value: Option = Option::deserialize(deserializer)?; + value + .map(|value| { + let bytes = from_hex(&value) + .ok_or_else(|| D::Error::custom("expected hex-encoded codec bytes"))?; + T::decode(bytes.as_ref()).map_err(D::Error::custom) + }) + .transpose() + } +} diff --git a/perpetuals/src/ingress.rs b/perpetuals/src/ingress.rs new file mode 100644 index 0000000..510c07d --- /dev/null +++ b/perpetuals/src/ingress.rs @@ -0,0 +1,100 @@ +//! Perpetuals [Actor] ingress for cross-module mark-price updates. +//! +//! The spot/perps CLOB actor publishes executable mark prices here so the +//! perpetuals ledger can keep `mark_price` distinct from oracle `index_price`. +//! +//! [Actor]: super::actor::Actor + +use crate::{MarketId, PerpetualError, PerpetualLedger}; +use commonware_actor::mailbox::{Policy, Sender}; +use commonware_utils::Acknowledgement; +use nunchi_common::{RuntimeContext, StateStore}; +use std::collections::VecDeque; +use tracing::error; + +/// Message delivered to the perpetuals actor mailbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Message +where + A: Acknowledgement, +{ + /// Apply a CLOB-derived mark price to a perpetuals market. + UpdateMarkPrice { + market: MarketId, + mark_price: u128, + context: RuntimeContext, + response: A, + }, +} + +impl Policy for Message +where + A: Acknowledgement, +{ + type Overflow = VecDeque; + + fn handle(overflow: &mut VecDeque, message: Self) { + overflow.push_back(message); + } +} + +/// Outbox for sending mark-price updates to the perpetuals actor. +#[derive(Clone)] +pub struct Mailbox +where + A: Acknowledgement, +{ + sender: Sender>, +} + +impl Mailbox +where + A: Acknowledgement, +{ + /// Create a mailbox from an actor ingress sender. + pub const fn new(sender: Sender>) -> Self { + Self { sender } + } + + /// Publish a CLOB mid/last price as the market mark. + pub fn update_mark_price( + &mut self, + market: MarketId, + mark_price: u128, + context: RuntimeContext, + response: A, + ) -> commonware_actor::Feedback { + self.sender.enqueue(Message::UpdateMarkPrice { + market, + mark_price, + context, + response, + }) + } +} + +/// Apply a mailbox message to a perpetuals ledger. +pub async fn apply_message( + ledger: &mut PerpetualLedger, + message: Message, +) -> Result<(), PerpetualError> +where + D: crate::PerpetualDB + nunchi_coins::CoinDB + StateStore + Send + Sync, + A: Acknowledgement, +{ + match message { + Message::UpdateMarkPrice { + market, + mark_price, + context, + response, + } => { + let result = ledger.update_mark_price(market, mark_price, context).await; + if result.is_err() { + error!(?result, "mark price update failed"); + } + response.acknowledge(); + result + } + } +} diff --git a/perpetuals/src/ledger.rs b/perpetuals/src/ledger.rs new file mode 100644 index 0000000..8b4b31c --- /dev/null +++ b/perpetuals/src/ledger.rs @@ -0,0 +1,1399 @@ +use crate::{ + derive_market_id, derive_position_id, derive_position_id_for_side, Address, Authorization, + Market, MarketId, OraclePricePayload, PerpetualDB, PerpetualOperation, Position, PositionId, + Side, Transaction, BPS_DENOMINATOR, MAX_PRICE_DECIMALS, PERPETUALS_NAMESPACE, PRICE_SCALE, +}; +use commonware_codec::{DecodeExt, Encode, ReadExt}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use nunchi_coins::{CoinDB, CoinId, LedgerError}; +use nunchi_common::{CommitState, RuntimeContext, StateStore}; +use nunchi_crypto::SignatureError; +use nunchi_oracle::{IntervalKey, NamespaceId, OracleError, OracleLedger, OracleRecord}; +use thiserror::Error; + +/// Deterministic perpetuals state-machine errors. +#[derive(Debug, Error, Clone, Eq, PartialEq)] +pub enum PerpetualError { + #[error("bad perpetual transaction signature: {0}")] + BadSignature(#[from] SignatureError), + #[error("nonce mismatch for {account:?}: expected {expected}, got {actual}")] + NonceMismatch { + account: Box
, + expected: u64, + actual: u64, + }, + #[error("nonce overflow")] + NonceOverflow, + #[error("market nonce overflow")] + MarketNonceOverflow, + #[error("position nonce overflow")] + PositionNonceOverflow, + #[error("invalid zero collateral")] + InvalidCollateral, + #[error("invalid oracle price")] + InvalidOraclePrice, + #[error("invalid leverage")] + InvalidLeverage, + #[error("invalid maintenance margin")] + InvalidMaintenanceMargin, + #[error("invalid oracle interval")] + InvalidOracleInterval, + #[error("invalid oracle staleness threshold")] + InvalidOracleStaleness, + #[error("invalid funding interval")] + InvalidFundingInterval, + #[error("invalid funding rate")] + InvalidFundingRate, + #[error("invalid price decimals")] + InvalidPriceDecimals, + #[error("market has no fresh oracle price")] + MarketNotReady, + #[error("missing oracle price")] + MissingOraclePrice, + #[error("stale oracle price")] + StaleOraclePrice, + #[error("oracle payload decode failed: {0}")] + OraclePayload(String), + #[error("oracle module error: {0}")] + Oracle(#[from] OracleError), + #[error("coin module error: {0}")] + Coin(#[from] LedgerError), + #[error("unknown market {0:?}")] + UnknownMarket(MarketId), + #[error("duplicate market {0:?}")] + DuplicateMarket(MarketId), + #[error("unknown position {0:?}")] + UnknownPosition(PositionId), + #[error("unauthorized oracle writer {writer:?} for market {market:?}")] + UnauthorizedOracleWriter { + market: MarketId, + writer: Box
, + }, + #[error("unauthorized perpetual operation")] + Unauthorized, + #[error("invalid liquidation reward")] + InvalidLiquidationReward, + #[error("invalid mark price")] + InvalidMarkPrice, + #[error("max leverage exceeded: max {max}, requested {requested}")] + MaxLeverageExceeded { max: u32, requested: u32 }, + #[error("position is not liquidatable")] + PositionNotLiquidatable, + #[error("position is underwater {0:?}")] + PositionUnderwater(PositionId), + #[error("collateral reduction exceeds available balance")] + CollateralUnderflow, + #[error("collateral reduction would push position into liquidatable territory")] + CollateralReductionWouldCauseLiquidation, + #[error("perpetuals escrow balance too low for {coin:?}: available {available}, required {required}")] + InsufficientEscrowBalance { + coin: CoinId, + available: u128, + required: u128, + }, + #[error("arithmetic overflow")] + ArithmeticOverflow, + #[error("state storage error: {0}")] + Storage(String), +} + +/// Perpetuals ledger over a shared SDK state backend. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PerpetualLedger { + db: D, +} + +impl PerpetualLedger { + /// Wrap a database backend as a perpetuals ledger. + pub fn new(db: D) -> Self { + Self { db } + } + + /// Borrow the underlying database. + pub fn db(&self) -> &D { + &self.db + } + + #[cfg(test)] + pub(crate) fn db_mut(&mut self) -> &mut D { + &mut self.db + } + + /// Consume the ledger, returning the underlying database. + pub fn into_inner(self) -> D { + self.db + } + + pub async fn nonce(&self, id: &Address) -> Result { + PerpetualDB::nonce(&self.db, id).await + } + + pub async fn market(&self, id: &MarketId) -> Result, PerpetualError> { + self.db.market(id).await + } + + pub async fn position(&self, id: &PositionId) -> Result, PerpetualError> { + self.db.position(id).await + } + + /// Validate and apply a signed perpetuals transaction. + pub async fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + self.ensure_authorized(tx)?; + + let expected = PerpetualDB::nonce(&self.db, &tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(PerpetualError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + + self.apply_operation(&tx.account_id, &tx.payload.operation, context) + .await?; + let next_nonce = expected + .checked_add(1) + .ok_or(PerpetualError::NonceOverflow)?; + PerpetualDB::set_nonce(&mut self.db, &tx.account_id, next_nonce); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub async fn create_market( + &mut self, + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + oracle_namespace: NamespaceId, + oracle_writer: Address, + clob_market: Option, + oracle_interval_ms: u64, + max_oracle_staleness_ms: u64, + price_decimals: u8, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + funding_interval_ms: u64, + max_funding_rate_bps: u32, + liquidation_reward_bps: u32, + ) -> Result { + validate_market_params( + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + liquidation_reward_bps, + )?; + let nonce = self.db.market_nonce().await?; + let market_id = derive_market_id(base_asset, quote_asset, collateral_asset, nonce); + if self.db.market(&market_id).await?.is_some() { + return Err(PerpetualError::DuplicateMarket(market_id)); + } + let market = Market { + id: market_id, + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_writer, + clob_market, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + liquidation_reward_bps, + mark_price: 0, + index_price: 0, + long_open_interest: 0, + short_open_interest: 0, + last_oracle_interval: 0, + last_oracle_update_ms: 0, + last_funding_ms: 0, + cumulative_funding_long: 0, + cumulative_funding_short: 0, + }; + self.db.set_market(&market); + self.db.set_market_nonce( + nonce + .checked_add(1) + .ok_or(PerpetualError::MarketNonceOverflow)?, + ); + Ok(market_id) + } + + /// Pull and decode the latest valid opaque Oracle record for a market. + pub async fn refresh_market_from_oracle( + &mut self, + market_id: MarketId, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + + let current_interval = context.timestamp_ms / market.oracle_interval_ms; + let start = IntervalKey::new(current_interval.saturating_sub(1)); + let end = IntervalKey::new(current_interval); + let records = { + let oracle = OracleLedger::new(&mut self.db); + oracle + .records_by_namespace(&market.oracle_namespace, start, end) + .await? + }; + let (record, payload) = latest_payload_for_market(&market, &records, context)?; + let price = scale_price(payload.price, payload.price_decimals, market.price_decimals)?; + + market.index_price = price; + if market.clob_market.is_none() || market.mark_price == 0 { + market.mark_price = price; + } + market.last_oracle_interval = record.interval.bucket; + market.last_oracle_update_ms = record.written_at_ms; + self.db.set_market(&market); + Ok(()) + } + + /// Update a market's mark price, typically from the linked CLOB mid or last trade. + pub async fn update_mark_price( + &mut self, + market_id: MarketId, + mark_price: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + if mark_price == 0 { + return Err(PerpetualError::InvalidMarkPrice); + } + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + market.mark_price = mark_price; + self.db.set_market(&market); + Ok(()) + } + + pub async fn settle_funding( + &mut self, + market_id: MarketId, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + self.db.set_market(&market); + Ok(()) + } + + pub async fn open_position( + &mut self, + owner: Address, + market_id: MarketId, + side: Side, + collateral: u128, + leverage_bps: u32, + context: RuntimeContext, + ) -> Result { + if collateral == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + if leverage_bps < BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLeverage); + } + if leverage_bps > market.max_leverage_bps { + return Err(PerpetualError::MaxLeverageExceeded { + max: market.max_leverage_bps, + requested: leverage_bps, + }); + } + let quantity = quantity_from_collateral(collateral, leverage_bps, market.mark_price)?; + // Leverage cap implies initial margin at open; maintenance is checked on reduce/liquidate. + let nonce = self.db.position_nonce().await?; + let next_nonce = nonce + .checked_add(1) + .ok_or(PerpetualError::PositionNonceOverflow)?; + let position_id = derive_position_id(&owner, &market_id, nonce); + let position = Position { + id: position_id, + market: market_id, + owner: owner.clone(), + side, + quantity, + entry_price: market.mark_price, + collateral, + entry_funding_index: funding_index_for_side(&market, side), + }; + match side { + Side::Long => { + market.long_open_interest = market + .long_open_interest + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Side::Short => { + market.short_open_interest = market + .short_open_interest + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + } + self.deposit_collateral(&owner, market.collateral_asset, collateral) + .await?; + self.db.set_market(&market); + self.db.set_position(&position); + self.db.set_position_nonce(next_nonce); + Ok(position_id) + } + + pub async fn add_collateral( + &mut self, + owner: &Address, + position_id: PositionId, + amount: u128, + ) -> Result<(), PerpetualError> { + if amount == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let mut position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(PerpetualError::Unauthorized); + } + let market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + let new_collateral = position + .collateral + .checked_add(amount) + .ok_or(PerpetualError::ArithmeticOverflow)?; + self.deposit_collateral(owner, market.collateral_asset, amount) + .await?; + position.collateral = position + .collateral + .checked_add(amount) + .ok_or(PerpetualError::ArithmeticOverflow)?; + debug_assert_eq!(position.collateral, new_collateral); + self.db.set_position(&position); + Ok(()) + } + + pub async fn reduce_collateral( + &mut self, + owner: &Address, + position_id: PositionId, + amount: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + if amount == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let mut position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(PerpetualError::Unauthorized); + } + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + let new_collateral = position + .collateral + .checked_sub(amount) + .ok_or(PerpetualError::CollateralUnderflow)?; + let temp = Position { + collateral: new_collateral, + ..position.clone() + }; + if self.is_liquidatable_with_market(&temp, &market)? { + return Err(PerpetualError::CollateralReductionWouldCauseLiquidation); + } + self.withdraw_collateral(owner, market.collateral_asset, amount) + .await?; + position.collateral = new_collateral; + self.db.set_market(&market); + self.db.set_position(&position); + Ok(()) + } + + #[cfg(feature = "mock-execution")] + pub async fn close_position( + &mut self, + owner: &Address, + position_id: PositionId, + context: RuntimeContext, + ) -> Result { + let position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(PerpetualError::Unauthorized); + } + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + let equity = position_equity(&position, &market)?; + if equity <= 0 { + return Err(PerpetualError::PositionUnderwater(position_id)); + } + decrement_open_interest(&mut market, &position)?; + let payout = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; + pay_from_escrow_and_insurance(&mut self.db, owner, market.collateral_asset, payout).await?; + self.db.set_market(&market); + self.db.remove_position(&position_id); + Ok(payout) + } + + /// Apply a CLOB fill settlement for one party on a perpetuals market. + pub async fn apply_fill_settlement( + &mut self, + owner: Address, + market_id: MarketId, + side: Side, + price: u128, + quantity: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + if quantity == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let scaled_quantity = quantity + .checked_mul(PRICE_SCALE) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + + let opposite_id = derive_position_id_for_side(&owner, &market_id, side.opposite()); + let mut remaining = scaled_quantity; + if let Some(mut opposite) = self.db.position(&opposite_id).await? { + let close_qty = opposite.quantity.min(remaining); + if close_qty > 0 { + self.apply_partial_close(&mut market, &mut opposite, close_qty, price, &owner) + .await?; + remaining = remaining + .checked_sub(close_qty) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + } + + if remaining > 0 { + self.apply_same_side_fill( + &mut market, + &owner, + market_id, + side, + price, + remaining, + context, + ) + .await?; + } + + self.db.set_market(&market); + Ok(()) + } + + async fn apply_partial_close( + &mut self, + market: &mut Market, + position: &mut Position, + close_qty: u128, + price: u128, + owner: &Address, + ) -> Result<(), PerpetualError> { + let total_qty = position.quantity; + let collateral_release = position + .collateral + .checked_mul(close_qty) + .ok_or(PerpetualError::ArithmeticOverflow)? + / total_qty; + let closed = Position { + quantity: close_qty, + collateral: collateral_release, + ..position.clone() + }; + let pnl = pnl(&closed, price)?; + let mut payout = i128_from_u128(collateral_release)?; + payout = payout + .checked_add(pnl) + .ok_or(PerpetualError::ArithmeticOverflow)?; + if payout > 0 { + let amount = u128::try_from(payout).map_err(|_| PerpetualError::ArithmeticOverflow)?; + pay_from_escrow_and_insurance(&mut self.db, owner, market.collateral_asset, amount) + .await?; + } + decrement_open_interest(market, &closed)?; + position.quantity = position + .quantity + .checked_sub(close_qty) + .ok_or(PerpetualError::ArithmeticOverflow)?; + position.collateral = position + .collateral + .checked_sub(collateral_release) + .ok_or(PerpetualError::ArithmeticOverflow)?; + if position.quantity == 0 { + self.db.remove_position(&position.id); + } else { + self.db.set_position(position); + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn apply_same_side_fill( + &mut self, + market: &mut Market, + owner: &Address, + market_id: MarketId, + side: Side, + price: u128, + quantity: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + let position_id = derive_position_id_for_side(owner, &market_id, side); + let lot_quantity = quantity + .checked_div(PRICE_SCALE) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let collateral = initial_margin_for_quantity(price, lot_quantity, market.max_leverage_bps)?; + self.deposit_collateral(owner, market.collateral_asset, collateral) + .await?; + + if let Some(mut position) = self.db.position(&position_id).await? { + let total_qty = position + .quantity + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let weighted_entry = position + .entry_price + .checked_mul(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)? + .checked_add( + price + .checked_mul(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?, + ) + .ok_or(PerpetualError::ArithmeticOverflow)? + / total_qty; + position.quantity = total_qty; + position.entry_price = weighted_entry; + position.collateral = position + .collateral + .checked_add(collateral) + .ok_or(PerpetualError::ArithmeticOverflow)?; + increment_open_interest(market, side, quantity)?; + self.db.set_position(&position); + } else { + let position = Position { + id: position_id, + market: market_id, + owner: owner.clone(), + side, + quantity, + entry_price: price, + collateral, + entry_funding_index: funding_index_for_side(market, side), + }; + increment_open_interest(market, side, quantity)?; + self.db.set_position(&position); + } + let _ = context; + Ok(()) + } + + pub async fn liquidate( + &mut self, + liquidator: &Address, + position_id: PositionId, + context: RuntimeContext, + ) -> Result { + let position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + if !self.is_liquidatable_with_market(&position, &market)? { + return Err(PerpetualError::PositionNotLiquidatable); + } + let equity = position_equity(&position, &market)?; + decrement_open_interest(&mut market, &position)?; + let reward = settle_position_liquidation( + &mut self.db, + liquidator, + &position.owner, + &position, + &market, + equity, + ) + .await?; + self.db.set_market(&market); + self.db.remove_position(&position_id); + Ok(reward) + } + + fn ensure_authorized(&self, tx: &Transaction) -> Result<(), PerpetualError> { + tx.verify()?; + match &tx.authorization { + Authorization::Single { .. } => Ok(()), + Authorization::Multisig { .. } => Err(PerpetualError::Unauthorized), + } + } + + async fn apply_operation( + &mut self, + signer: &Address, + operation: &PerpetualOperation, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + match operation { + PerpetualOperation::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_writer, + clob_market, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + liquidation_reward_bps, + } => { + self.create_market( + *base_asset, + *quote_asset, + *collateral_asset, + *oracle_namespace, + oracle_writer.clone(), + *clob_market, + *oracle_interval_ms, + *max_oracle_staleness_ms, + *price_decimals, + *max_leverage_bps, + *maintenance_margin_bps, + *funding_interval_ms, + *max_funding_rate_bps, + *liquidation_reward_bps, + ) + .await?; + } + PerpetualOperation::RefreshMarketFromOracle { market } => { + self.refresh_market_from_oracle(*market, context).await?; + } + PerpetualOperation::SettleFunding { market } => { + self.settle_funding(*market, context).await?; + } + PerpetualOperation::UpdateMarkPrice { market, mark_price } => { + self.update_mark_price(*market, *mark_price, context) + .await?; + } + PerpetualOperation::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + #[cfg(not(feature = "mock-execution"))] + { + let _ = (market, side, collateral, leverage_bps); + return Err(PerpetualError::Unauthorized); + } + #[cfg(feature = "mock-execution")] + self.open_position( + signer.clone(), + *market, + *side, + *collateral, + *leverage_bps, + context, + ) + .await?; + } + PerpetualOperation::AddCollateral { position, amount } => { + self.add_collateral(signer, *position, *amount).await?; + } + PerpetualOperation::ReduceCollateral { position, amount } => { + self.reduce_collateral(signer, *position, *amount, context) + .await?; + } + PerpetualOperation::ClosePosition { position } => { + #[cfg(not(feature = "mock-execution"))] + { + let _ = position; + return Err(PerpetualError::Unauthorized); + } + #[cfg(feature = "mock-execution")] + self.close_position(signer, *position, context).await?; + } + PerpetualOperation::Liquidate { position } => { + self.liquidate(signer, *position, context).await?; + } + } + Ok(()) + } + + fn ensure_market_ready(&self, market: &Market, now_ms: u64) -> Result<(), PerpetualError> { + if market.mark_price == 0 || market.index_price == 0 { + return Err(PerpetualError::MarketNotReady); + } + let age = now_ms + .checked_sub(market.last_oracle_update_ms) + .ok_or(PerpetualError::StaleOraclePrice)?; + if age > market.max_oracle_staleness_ms { + return Err(PerpetualError::StaleOraclePrice); + } + Ok(()) + } + + fn settle_market_funding( + &self, + market: &mut Market, + now_ms: u64, + ) -> Result<(), PerpetualError> { + if market.mark_price == 0 || market.index_price == 0 { + market.last_funding_ms = now_ms; + return Ok(()); + } + if market.long_open_interest == 0 || market.short_open_interest == 0 { + return Ok(()); + } + if market.last_funding_ms == 0 { + market.last_funding_ms = now_ms; + return Ok(()); + } + let elapsed = now_ms + .checked_sub(market.last_funding_ms) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let intervals = elapsed / market.funding_interval_ms; + if intervals == 0 { + return Ok(()); + } + + let rate_bps = funding_rate_bps(market)?; + let mark = i128_from_u128(market.mark_price)?; + let delta_per_interval = mark + .checked_mul(i128::from(rate_bps)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / i128::from(BPS_DENOMINATOR); + let delta = delta_per_interval + .checked_mul(i128::from(intervals)) + .ok_or(PerpetualError::ArithmeticOverflow)?; + market.cumulative_funding_long = market + .cumulative_funding_long + .checked_add(delta) + .ok_or(PerpetualError::ArithmeticOverflow)?; + market.cumulative_funding_short = market + .cumulative_funding_short + .checked_sub(delta) + .ok_or(PerpetualError::ArithmeticOverflow)?; + market.last_funding_ms = market + .last_funding_ms + .checked_add( + intervals + .checked_mul(market.funding_interval_ms) + .ok_or(PerpetualError::ArithmeticOverflow)?, + ) + .ok_or(PerpetualError::ArithmeticOverflow)?; + Ok(()) + } + + fn is_liquidatable_with_market( + &self, + position: &Position, + market: &Market, + ) -> Result { + let equity = position_equity(position, market)?; + if equity <= 0 { + return Ok(true); + } + let maintenance = maintenance_margin(position.quantity, market)?; + let equity = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; + Ok(equity <= maintenance) + } + + async fn deposit_collateral( + &mut self, + owner: &Address, + coin: CoinId, + amount: u128, + ) -> Result<(), PerpetualError> { + if CoinDB::token(&self.db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let available = CoinDB::balance(&self.db, owner, &coin).await?; + if available < amount { + return Err(PerpetualError::Coin(LedgerError::InsufficientBalance { + account: Box::new(owner.clone()), + coin: Box::new(coin), + available, + required: amount, + })); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(&self.db, &escrow, &coin).await?; + let escrow_updated = escrow_available + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + self.db.set_balance(owner, &coin, available - amount); + self.db.set_balance(&escrow, &coin, escrow_updated); + Ok(()) + } + + async fn withdraw_collateral( + &mut self, + owner: &Address, + coin: CoinId, + amount: u128, + ) -> Result<(), PerpetualError> { + transfer_from_escrow(&mut self.db, owner, coin, amount).await + } +} + +impl PerpetualLedger { + pub async fn commit(&mut self) -> Result { + self.db + .commit() + .await + .map_err(|err| PerpetualError::Storage(err.to_string())) + } + + pub fn root(&self) -> Digest { + self.db.root() + } +} + +/// Account that holds collateral escrowed by the perpetuals module. +pub fn collateral_escrow_account() -> Address { + let mut hasher = Sha256::new(); + hasher.update(PERPETUALS_NAMESPACE); + hasher.update(b"/collateral-escrow"); + Address::decode(hasher.finalize().encode().as_ref()).expect("digest decodes as address") +} + +/// Account that holds insurance and backstop balances for the perpetuals module. +pub fn insurance_fund_account() -> Address { + let mut hasher = Sha256::new(); + hasher.update(PERPETUALS_NAMESPACE); + hasher.update(b"/insurance-fund"); + Address::decode(hasher.finalize().encode().as_ref()).expect("digest decodes as address") +} + +fn decrement_open_interest(market: &mut Market, position: &Position) -> Result<(), PerpetualError> { + match position.side { + Side::Long => { + market.long_open_interest = market + .long_open_interest + .checked_sub(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Side::Short => { + market.short_open_interest = market + .short_open_interest + .checked_sub(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + } + Ok(()) +} + +fn increment_open_interest( + market: &mut Market, + side: Side, + quantity: u128, +) -> Result<(), PerpetualError> { + match side { + Side::Long => { + market.long_open_interest = market + .long_open_interest + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Side::Short => { + market.short_open_interest = market + .short_open_interest + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + } + Ok(()) +} + +fn initial_margin_for_quantity( + price: u128, + quantity: u128, + max_leverage_bps: u32, +) -> Result { + // `quantity` is in CLOB lot units; notional uses native price * quantity. + let notional = price + .checked_mul(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + if notional == 0 { + return Err(PerpetualError::InvalidCollateral); + } + Ok(notional + .checked_mul(u128::from(BPS_DENOMINATOR)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / u128::from(max_leverage_bps)) +} + +/// Apply a CLOB fill settlement for one party on a perpetuals market. +pub async fn apply_fill_settlement( + db: &mut D, + owner: Address, + market_id: MarketId, + side: Side, + price: u128, + quantity: u128, + context: RuntimeContext, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + PerpetualLedger::new(db) + .apply_fill_settlement(owner, market_id, side, price, quantity, context) + .await +} + +async fn settle_position_liquidation( + db: &mut D, + liquidator: &Address, + owner: &Address, + position: &Position, + market: &Market, + equity: i128, +) -> Result +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + let collateral = position.collateral; + let reward = collateral + .checked_mul(u128::from(market.liquidation_reward_bps)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / u128::from(BPS_DENOMINATOR); + let reward = reward.min(collateral); + + let owner_return = if equity > 0 { + let equity = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; + equity + .saturating_sub(reward) + .min(collateral.saturating_sub(reward)) + } else { + 0 + }; + let insurance = collateral + .saturating_sub(reward) + .saturating_sub(owner_return); + + if reward > 0 { + transfer_from_escrow(db, liquidator, market.collateral_asset, reward).await?; + } + if owner_return > 0 { + transfer_from_escrow(db, owner, market.collateral_asset, owner_return).await?; + } + if insurance > 0 { + transfer_escrow_to_insurance(db, market.collateral_asset, insurance).await?; + } + Ok(reward) +} + +async fn pay_from_escrow_and_insurance( + db: &mut D, + recipient: &Address, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if amount == 0 { + return Ok(()); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(db, &escrow, &coin).await?; + let from_escrow = amount.min(escrow_available); + if from_escrow > 0 { + transfer_from_escrow(db, recipient, coin, from_escrow).await?; + } + let remainder = amount.saturating_sub(from_escrow); + if remainder > 0 { + transfer_from_insurance(db, recipient, coin, remainder).await?; + } + Ok(()) +} + +async fn transfer_from_escrow( + db: &mut D, + recipient: &Address, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if CoinDB::token(db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(db, &escrow, &coin).await?; + if escrow_available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available: escrow_available, + required: amount, + }); + } + let current = CoinDB::balance(db, recipient, &coin).await?; + let updated = current + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + db.set_balance(&escrow, &coin, escrow_available - amount); + db.set_balance(recipient, &coin, updated); + Ok(()) +} + +async fn transfer_from_insurance( + db: &mut D, + recipient: &Address, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if CoinDB::token(db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let insurance = insurance_fund_account(); + let available = CoinDB::balance(db, &insurance, &coin).await?; + if available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available, + required: amount, + }); + } + let current = CoinDB::balance(db, recipient, &coin).await?; + let updated = current + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + db.set_balance(&insurance, &coin, available - amount); + db.set_balance(recipient, &coin, updated); + Ok(()) +} + +async fn transfer_escrow_to_insurance( + db: &mut D, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if amount == 0 { + return Ok(()); + } + let escrow = collateral_escrow_account(); + let insurance = insurance_fund_account(); + let escrow_available = CoinDB::balance(db, &escrow, &coin).await?; + if escrow_available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available: escrow_available, + required: amount, + }); + } + let insurance_available = CoinDB::balance(db, &insurance, &coin).await?; + let insurance_updated = insurance_available + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + db.set_balance(&escrow, &coin, escrow_available - amount); + db.set_balance(&insurance, &coin, insurance_updated); + Ok(()) +} + +fn latest_payload_for_market( + market: &Market, + records: &[OracleRecord], + context: RuntimeContext, +) -> Result<(OracleRecord, OraclePricePayload), PerpetualError> { + let mut latest: Option<(OracleRecord, OraclePricePayload)> = None; + for record in records { + if record.writer != market.oracle_writer { + continue; + } + let payload = match decode_oracle_payload(&record.payload) { + Ok(payload) => payload, + Err(_) => continue, + }; + if payload.market != market.id { + continue; + } + if payload.source_timestamp_ms > context.timestamp_ms { + continue; + } + let record_age = context + .timestamp_ms + .checked_sub(record.written_at_ms) + .ok_or(PerpetualError::StaleOraclePrice)?; + let source_age = context + .timestamp_ms + .checked_sub(payload.source_timestamp_ms) + .ok_or(PerpetualError::StaleOraclePrice)?; + if record_age > market.max_oracle_staleness_ms + || source_age > market.max_oracle_staleness_ms + { + continue; + } + if latest + .as_ref() + .is_none_or(|(current, _)| record.written_at_ms > current.written_at_ms) + { + latest = Some((record.clone(), payload)); + } + } + latest.ok_or(PerpetualError::MissingOraclePrice) +} + +fn decode_oracle_payload(bytes: &[u8]) -> Result { + let mut buf = bytes; + OraclePricePayload::read(&mut buf).map_err(|err| PerpetualError::OraclePayload(err.to_string())) +} + +#[allow(clippy::too_many_arguments)] +fn validate_market_params( + oracle_interval_ms: u64, + max_oracle_staleness_ms: u64, + price_decimals: u8, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + funding_interval_ms: u64, + max_funding_rate_bps: u32, + liquidation_reward_bps: u32, +) -> Result<(), PerpetualError> { + if oracle_interval_ms == 0 { + return Err(PerpetualError::InvalidOracleInterval); + } + if max_oracle_staleness_ms == 0 { + return Err(PerpetualError::InvalidOracleStaleness); + } + if price_decimals > MAX_PRICE_DECIMALS { + return Err(PerpetualError::InvalidPriceDecimals); + } + if max_leverage_bps < BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLeverage); + } + if maintenance_margin_bps == 0 || maintenance_margin_bps >= BPS_DENOMINATOR { + return Err(PerpetualError::InvalidMaintenanceMargin); + } + if funding_interval_ms == 0 { + return Err(PerpetualError::InvalidFundingInterval); + } + if max_funding_rate_bps > BPS_DENOMINATOR { + return Err(PerpetualError::InvalidFundingRate); + } + if liquidation_reward_bps == 0 || liquidation_reward_bps >= BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLiquidationReward); + } + Ok(()) +} + +fn scale_price(price: u128, from_decimals: u8, to_decimals: u8) -> Result { + if price == 0 { + return Err(PerpetualError::InvalidOraclePrice); + } + if from_decimals > MAX_PRICE_DECIMALS || to_decimals > MAX_PRICE_DECIMALS { + return Err(PerpetualError::InvalidPriceDecimals); + } + let scaled = if from_decimals > to_decimals { + let factor = pow10(from_decimals - to_decimals)?; + price / factor + } else { + let factor = pow10(to_decimals - from_decimals)?; + price + .checked_mul(factor) + .ok_or(PerpetualError::ArithmeticOverflow)? + }; + if scaled == 0 { + return Err(PerpetualError::InvalidOraclePrice); + } + Ok(scaled) +} + +fn pow10(exp: u8) -> Result { + let mut value = 1u128; + for _ in 0..exp { + value = value + .checked_mul(10) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Ok(value) +} + +fn quantity_from_collateral( + collateral: u128, + leverage_bps: u32, + mark_price: u128, +) -> Result { + let notional = collateral + .checked_mul(u128::from(leverage_bps)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / u128::from(BPS_DENOMINATOR); + let quantity = notional + .checked_mul(PRICE_SCALE) + .ok_or(PerpetualError::ArithmeticOverflow)? + / mark_price; + if quantity == 0 { + return Err(PerpetualError::InvalidCollateral); + } + Ok(quantity) +} + +fn notional(quantity: u128, mark_price: u128) -> Result { + quantity + .checked_mul(mark_price) + .ok_or(PerpetualError::ArithmeticOverflow) + .map(|value| value / PRICE_SCALE) +} + +fn pnl(position: &Position, mark_price: u128) -> Result { + let entry = i128_from_u128(notional(position.quantity, position.entry_price)?)?; + let current = i128_from_u128(notional(position.quantity, mark_price)?)?; + match position.side { + Side::Long => current + .checked_sub(entry) + .ok_or(PerpetualError::ArithmeticOverflow), + Side::Short => entry + .checked_sub(current) + .ok_or(PerpetualError::ArithmeticOverflow), + } +} + +fn position_equity(position: &Position, market: &Market) -> Result { + let collateral = i128_from_u128(position.collateral)?; + let pnl = pnl(position, market.mark_price)?; + let funding = funding_payment(position, market)?; + collateral + .checked_add(pnl) + .and_then(|value| value.checked_sub(funding)) + .ok_or(PerpetualError::ArithmeticOverflow) +} + +fn maintenance_margin(quantity: u128, market: &Market) -> Result { + notional(quantity, market.mark_price)? + .checked_mul(u128::from(market.maintenance_margin_bps)) + .ok_or(PerpetualError::ArithmeticOverflow) + .map(|value| value / u128::from(BPS_DENOMINATOR)) +} + +fn funding_index_for_side(market: &Market, side: Side) -> i128 { + match side { + Side::Long => market.cumulative_funding_long, + Side::Short => market.cumulative_funding_short, + } +} + +fn funding_payment(position: &Position, market: &Market) -> Result { + let matched = market.matched_open_interest(); + if matched == 0 { + return Ok(0); + } + let effective_qty = position.quantity.min(matched); + let current = funding_index_for_side(market, position.side); + let delta = current + .checked_sub(position.entry_funding_index) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let quantity = i128_from_u128(effective_qty)?; + quantity + .checked_mul(delta) + .ok_or(PerpetualError::ArithmeticOverflow) + .map(|value| value / i128_from_u128(PRICE_SCALE).expect("PRICE_SCALE fits i128")) +} + +fn funding_rate_bps(market: &Market) -> Result { + if market.index_price == 0 { + return Err(PerpetualError::MarketNotReady); + } + let diff_abs = market.mark_price.abs_diff(market.index_price); + let raw = diff_abs + .checked_mul(u128::from(BPS_DENOMINATOR)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / market.index_price; + let capped = raw.min(u128::from(market.max_funding_rate_bps)); + let capped = i32::try_from(capped).map_err(|_| PerpetualError::ArithmeticOverflow)?; + if market.mark_price >= market.index_price { + Ok(capped) + } else { + capped + .checked_neg() + .ok_or(PerpetualError::ArithmeticOverflow) + } +} + +fn i128_from_u128(value: u128) -> Result { + i128::try_from(value).map_err(|_| PerpetualError::ArithmeticOverflow) +} + +#[cfg(test)] +mod math_tests { + use super::*; + + #[test] + fn scale_price_truncates_to_consumer_decimals() { + assert_eq!(scale_price(50_000_123_456, 6, 2).unwrap(), 5_000_012); + assert_eq!(scale_price(50_000, 0, 2).unwrap(), 5_000_000); + } +} diff --git a/perpetuals/src/lib.rs b/perpetuals/src/lib.rs new file mode 100644 index 0000000..14a71b3 --- /dev/null +++ b/perpetuals/src/lib.rs @@ -0,0 +1,34 @@ +//! Minimal Oracle-consuming perpetual futures module. + +#[cfg(feature = "actor")] +pub mod actor; +mod db; +mod genesis; +#[cfg(feature = "actor")] +pub mod ingress; +mod ledger; +/// JSON-RPC surface for the perpetuals module. +#[cfg(feature = "rpc")] +pub mod rpc; +#[cfg(test)] +mod tests; +mod transaction; +mod types; + +pub use db::PerpetualDB; +pub use genesis::{MarketGenesis, PerpetualsGenesis}; +pub use ledger::{ + apply_fill_settlement, collateral_escrow_account, insurance_fund_account, PerpetualError, + PerpetualLedger, +}; +pub use nunchi_coins::CoinId; +pub use nunchi_common::{Address, Authorization}; +pub use transaction::{PerpetualOperation, Transaction, TransactionPayload}; +pub use types::{ + derive_market_id, derive_position_id, derive_position_id_for_side, Market, MarketId, + OraclePricePayload, Position, PositionId, Side, BPS_DENOMINATOR, + DEFAULT_LIQUIDATION_REWARD_BPS, MAX_PRICE_DECIMALS, PRICE_SCALE, +}; + +/// Domain separator used for perpetual transaction signatures and state keys. +pub const PERPETUALS_NAMESPACE: &[u8] = b"_NUNCHI_PERPETUALS"; diff --git a/perpetuals/src/rpc.rs b/perpetuals/src/rpc.rs new file mode 100644 index 0000000..b0031e4 --- /dev/null +++ b/perpetuals/src/rpc.rs @@ -0,0 +1,273 @@ +//! JSON-RPC surface for the perpetuals module. + +#[cfg(feature = "mempool")] +mod mempool; +#[cfg(feature = "mempool")] +pub use mempool::{ + register_mempool, MempoolIngress, PerpetualMempoolServer, PerpetualsMempoolRpc, + SubmitTransactionParams, SubmitTransactionResponse, TransactionStatusResponse, +}; + +use std::sync::Arc; + +use commonware_cryptography::sha256::Digest; +use futures::lock::Mutex as AsyncMutex; +use jsonrpsee::{ + core::{async_trait, RegisterMethodError, RpcResult}, + proc_macros::rpc, +}; +use nunchi_common::CommitState; +use nunchi_rpc::{decode_hex, encode_hex, module_error, RpcRouter}; +use serde::{Deserialize, Serialize}; + +use crate::{Address, Market, MarketId, PerpetualDB, PerpetualError, PerpetualLedger, Position}; + +/// Read-only perpetuals state required by the perps RPC server. +#[async_trait] +pub trait PerpetualQuery: Clone + Send + Sync + 'static { + async fn nonce(&self, account: Address) -> Result; + + async fn market(&self, market: MarketId) -> Result, PerpetualError>; + + async fn position( + &self, + position: crate::PositionId, + ) -> Result, PerpetualError>; + + async fn state_root(&self) -> Result; +} + +/// Shared committed perpetuals ledger handle suitable for RPC query servers. +pub struct SharedLedger { + ledger: Arc>>, +} + +impl SharedLedger { + pub fn new(ledger: PerpetualLedger) -> Self { + Self { + ledger: Arc::new(AsyncMutex::new(ledger)), + } + } + + pub async fn lock(&self) -> futures::lock::MutexGuard<'_, PerpetualLedger> { + self.ledger.lock().await + } +} + +impl Clone for SharedLedger { + fn clone(&self) -> Self { + Self { + ledger: self.ledger.clone(), + } + } +} + +#[async_trait] +impl PerpetualQuery for SharedLedger +where + D: PerpetualDB + + nunchi_coins::CoinDB + + CommitState + + nunchi_common::StateStore + + Send + + Sync + + 'static, +{ + async fn nonce(&self, account: Address) -> Result { + self.lock().await.nonce(&account).await + } + + async fn market(&self, market: MarketId) -> Result, PerpetualError> { + self.lock().await.market(&market).await + } + + async fn position( + &self, + position: crate::PositionId, + ) -> Result, PerpetualError> { + self.lock().await.position(&position).await + } + + async fn state_root(&self) -> Result { + Ok(self.lock().await.root()) + } +} + +/// Concrete perpetuals RPC server over a query backend. +#[derive(Clone)] +pub struct PerpetualsRpc { + query: Q, +} + +impl PerpetualsRpc { + pub fn new(query: Q) -> Self { + Self { query } + } +} + +#[rpc(server, namespace = "perpetuals", namespace_separator = ".")] +pub trait Perpetuals { + #[method(name = "nonce", param_kind = map)] + async fn nonce(&self, account: String) -> RpcResult; + + #[method(name = "market", param_kind = map)] + async fn market(&self, market: String) -> RpcResult>; + + #[method(name = "position", param_kind = map)] + async fn position(&self, position: String) -> RpcResult>; + + #[method(name = "state_root")] + async fn state_root(&self) -> RpcResult; +} + +#[async_trait] +impl PerpetualsServer for PerpetualsRpc +where + Q: PerpetualQuery, +{ + async fn nonce(&self, account: String) -> RpcResult { + let account: Address = decode_hex(&account, "account")?; + let nonce = self.query.nonce(account.clone()).await.map_err(rpc_error)?; + Ok(NonceResponse { + account: encode_hex(&account), + nonce, + }) + } + + async fn market(&self, market: String) -> RpcResult> { + let market: MarketId = decode_hex(&market, "market")?; + let market = self.query.market(market).await.map_err(rpc_error)?; + Ok(market.map(MarketResponse::from)) + } + + async fn position(&self, position: String) -> RpcResult> { + let position: crate::PositionId = decode_hex(&position, "position")?; + let position = self.query.position(position).await.map_err(rpc_error)?; + Ok(position.map(PositionResponse::from)) + } + + async fn state_root(&self) -> RpcResult { + let root = self.query.state_root().await.map_err(rpc_error)?; + Ok(RootResponse { + root: encode_hex(&root), + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct NonceResponse { + pub account: String, + pub nonce: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MarketResponse { + pub id: String, + pub base_asset: String, + pub quote_asset: String, + pub collateral_asset: String, + pub oracle_namespace: String, + pub oracle_writer: String, + pub clob_market: Option, + pub oracle_interval_ms: u64, + pub max_oracle_staleness_ms: u64, + pub price_decimals: u8, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub funding_interval_ms: u64, + pub max_funding_rate_bps: u32, + pub liquidation_reward_bps: u32, + pub mark_price: String, + pub index_price: String, + pub long_open_interest: String, + pub short_open_interest: String, + pub matched_open_interest: String, + pub last_oracle_interval: u64, + pub last_oracle_update_ms: u64, + pub last_funding_ms: u64, + pub cumulative_funding_long: String, + pub cumulative_funding_short: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PositionResponse { + pub id: String, + pub market: String, + pub owner: String, + pub side: String, + pub quantity: String, + pub entry_price: String, + pub collateral: String, + pub entry_funding_index: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RootResponse { + pub root: String, +} + +/// Register the perpetuals module's query RPC methods into a downstream router. +pub fn register( + router: &mut RpcRouter, + rpc: PerpetualsRpc, +) -> Result<(), RegisterMethodError> +where + Q: PerpetualQuery, +{ + router.merge(rpc.into_rpc()) +} + +fn rpc_error(error: PerpetualError) -> jsonrpsee::types::ErrorObjectOwned { + module_error(error.to_string()) +} + +impl From for MarketResponse { + fn from(market: Market) -> Self { + Self { + id: encode_hex(&market.id), + base_asset: encode_hex(&market.base_asset), + quote_asset: encode_hex(&market.quote_asset), + collateral_asset: encode_hex(&market.collateral_asset), + oracle_namespace: encode_hex(&market.oracle_namespace), + oracle_writer: encode_hex(&market.oracle_writer), + clob_market: market.clob_market.as_ref().map(encode_hex), + oracle_interval_ms: market.oracle_interval_ms, + max_oracle_staleness_ms: market.max_oracle_staleness_ms, + price_decimals: market.price_decimals, + max_leverage_bps: market.max_leverage_bps, + maintenance_margin_bps: market.maintenance_margin_bps, + funding_interval_ms: market.funding_interval_ms, + max_funding_rate_bps: market.max_funding_rate_bps, + liquidation_reward_bps: market.liquidation_reward_bps, + mark_price: market.mark_price.to_string(), + index_price: market.index_price.to_string(), + long_open_interest: market.long_open_interest.to_string(), + short_open_interest: market.short_open_interest.to_string(), + matched_open_interest: market.matched_open_interest().to_string(), + last_oracle_interval: market.last_oracle_interval, + last_oracle_update_ms: market.last_oracle_update_ms, + last_funding_ms: market.last_funding_ms, + cumulative_funding_long: market.cumulative_funding_long.to_string(), + cumulative_funding_short: market.cumulative_funding_short.to_string(), + } + } +} + +impl From for PositionResponse { + fn from(position: Position) -> Self { + Self { + id: encode_hex(&position.id), + market: encode_hex(&position.market), + owner: encode_hex(&position.owner), + side: match position.side { + crate::Side::Long => "long", + crate::Side::Short => "short", + } + .to_string(), + quantity: position.quantity.to_string(), + entry_price: position.entry_price.to_string(), + collateral: position.collateral.to_string(), + entry_funding_index: position.entry_funding_index.to_string(), + } + } +} diff --git a/perpetuals/src/rpc/mempool.rs b/perpetuals/src/rpc/mempool.rs new file mode 100644 index 0000000..d8e5bea --- /dev/null +++ b/perpetuals/src/rpc/mempool.rs @@ -0,0 +1,144 @@ +//! Transaction ingress and status RPC methods for perpetuals transactions. + +use commonware_cryptography::sha256::Digest; +use jsonrpsee::{ + core::{async_trait, RegisterMethodError, RpcResult}, + proc_macros::rpc, + types::ErrorObjectOwned, +}; +use nunchi_mempool::{AdmissionError, MempoolHandle, TxStatus}; +use nunchi_rpc::{decode_hex, encode_hex, invalid_params, module_error, RpcRouter}; +use serde::{Deserialize, Serialize}; + +use crate::Transaction; + +/// Mempool ingress required by the perps RPC server's write surface. +#[async_trait] +pub trait MempoolIngress: Clone + Send + Sync + 'static { + async fn submit(&self, transaction: Transaction) -> Result; + + async fn status(&self, digest: Digest) -> Option; +} + +#[async_trait] +impl MempoolIngress for MempoolHandle { + async fn submit(&self, transaction: Transaction) -> Result { + MempoolHandle::submit(self, transaction).await + } + + async fn status(&self, digest: Digest) -> Option { + MempoolHandle::status(self, digest).await + } +} + +/// Concrete perps mempool RPC server over an ingress backend. +#[derive(Clone)] +pub struct PerpetualsMempoolRpc { + ingress: I, +} + +impl PerpetualsMempoolRpc { + pub fn new(ingress: I) -> Self { + Self { ingress } + } +} + +#[rpc(server, namespace = "perpetuals", namespace_separator = ".")] +pub trait PerpetualMempool { + #[method(name = "submit_transaction", param_kind = map)] + async fn submit_transaction(&self, transaction: String) + -> RpcResult; + + #[method(name = "transaction_status", param_kind = map)] + async fn transaction_status(&self, hash: String) -> RpcResult; +} + +#[async_trait] +impl PerpetualMempoolServer for PerpetualsMempoolRpc +where + I: MempoolIngress, +{ + async fn submit_transaction( + &self, + transaction: String, + ) -> RpcResult { + let transaction: Transaction = decode_hex(&transaction, "perpetuals transaction")?; + let hash = self + .ingress + .submit(transaction) + .await + .map_err(admission_error)?; + Ok(SubmitTransactionResponse { + hash: encode_hex(&hash), + }) + } + + async fn transaction_status(&self, hash: String) -> RpcResult { + let digest: Digest = decode_hex(&hash, "transaction hash")?; + let status = self.ingress.status(digest).await; + Ok(TransactionStatusResponse::new(encode_hex(&digest), status)) + } +} + +/// Client-side parameter struct for `perpetuals.submit_transaction`. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SubmitTransactionParams { + /// Hex-encoded perpetuals transaction bytes. + pub transaction: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SubmitTransactionResponse { + pub hash: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TransactionStatusResponse { + pub hash: String, + /// One of `pending`, `finalized`, `dropped`, or `unknown`. + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub drop_reason: Option, +} + +impl TransactionStatusResponse { + fn new(hash: String, status: Option) -> Self { + let (status, height, drop_reason) = match status { + Some(TxStatus::Pending) => ("pending", None, None), + Some(TxStatus::Finalized { height }) => ("finalized", Some(height), None), + Some(TxStatus::Dropped { reason }) => ("dropped", None, Some(reason.as_str())), + None => ("unknown", None, None), + }; + Self { + hash, + status: status.to_string(), + height, + drop_reason: drop_reason.map(str::to_string), + } + } +} + +/// Register the perpetuals module's mempool RPC methods into a downstream router. +pub fn register_mempool( + router: &mut RpcRouter, + rpc: PerpetualsMempoolRpc, +) -> Result<(), RegisterMethodError> +where + I: MempoolIngress, +{ + router.merge(rpc.into_rpc()) +} + +fn admission_error(error: AdmissionError) -> ErrorObjectOwned { + match error { + AdmissionError::InvalidSignature(_) + | AdmissionError::TxTooLarge { .. } + | AdmissionError::Duplicate + | AdmissionError::StaleNonce { .. } => invalid_params(error.to_string()), + AdmissionError::AccountQueueFull | AdmissionError::PoolFull | AdmissionError::Shutdown => { + module_error(error.to_string()) + } + } +} diff --git a/perpetuals/src/tests/genesis.rs b/perpetuals/src/tests/genesis.rs new file mode 100644 index 0000000..1aa6678 --- /dev/null +++ b/perpetuals/src/tests/genesis.rs @@ -0,0 +1,113 @@ +use std::collections::BTreeMap; + +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use futures::executor::block_on; +use nunchi_coins::{CoinDB, CoinSpec, TokenDefinition, TokenName, TokenSymbol}; +use nunchi_common::{Address, StateError, StateStore}; +use nunchi_crypto::PrivateKey; +use nunchi_oracle::NamespaceId; + +use crate::{ + CoinId, MarketGenesis, PerpetualLedger, PerpetualsGenesis, BPS_DENOMINATOR, + DEFAULT_LIQUIDATION_REWARD_BPS, +}; + +#[derive(Default)] +struct MemoryStore { + values: BTreeMap>>, +} + +impl StateStore for MemoryStore { + async fn get(&self, key: &Digest) -> Result>, StateError> { + Ok(self.values.get(key).cloned().flatten()) + } + + fn set(&mut self, key: Digest, value: Vec) { + self.values.insert(key, Some(value)); + } + + fn remove(&mut self, key: Digest) { + self.values.insert(key, None); + } +} + +fn digest(seed: &'static [u8]) -> Digest { + Sha256::hash(seed) +} + +fn coin(seed: &'static [u8]) -> CoinId { + CoinId(digest(seed)) +} + +#[test] +fn perpetuals_genesis_json_roundtrips() { + let writer = PrivateKey::from_seed(1); + let genesis = PerpetualsGenesis { + markets: vec![MarketGenesis { + base_asset: coin(b"btc"), + quote_asset: coin(b"usd"), + collateral_asset: coin(b"usdc"), + oracle_namespace: NamespaceId(digest(b"oracle")), + oracle_writer: Address::external(&writer.public_key()), + clob_market: Some(digest(b"clob-market")), + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + price_decimals: 2, + max_leverage_bps: 50_000, + maintenance_margin_bps: 1_000, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: DEFAULT_LIQUIDATION_REWARD_BPS, + }], + }; + + let raw = serde_json::to_string(&genesis).expect("serialize genesis"); + let decoded: PerpetualsGenesis = serde_json::from_str(&raw).expect("deserialize genesis"); + assert_eq!(decoded, genesis); +} + +#[test] +fn apply_genesis_seeds_markets() { + let writer = PrivateKey::from_seed(2); + let issuer = PrivateKey::from_seed(3); + let mut store = MemoryStore::default(); + store.set_token(&TokenDefinition::from_spec( + coin(b"usdc"), + Address::external(&issuer.public_key()), + CoinSpec::new( + TokenSymbol::new("USDC").unwrap(), + TokenName::new("USD Coin").unwrap(), + 6, + 1_000_000, + None, + ), + )); + + let genesis = PerpetualsGenesis { + markets: vec![MarketGenesis { + base_asset: coin(b"btc"), + quote_asset: coin(b"usd"), + collateral_asset: coin(b"usdc"), + oracle_namespace: NamespaceId(digest(b"oracle")), + oracle_writer: Address::external(&writer.public_key()), + clob_market: None, + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + price_decimals: 0, + max_leverage_bps: 10 * BPS_DENOMINATOR, + maintenance_margin_bps: 500, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: DEFAULT_LIQUIDATION_REWARD_BPS, + }], + }; + + let ids = block_on(PerpetualLedger::new(&mut store).apply_genesis(&genesis)).unwrap(); + assert_eq!(ids.len(), 1); + + let market = block_on(PerpetualLedger::new(&mut store).market(&ids[0])) + .unwrap() + .expect("market exists"); + assert_eq!(market.collateral_asset, coin(b"usdc")); + assert_eq!(market.max_leverage_bps, 10 * BPS_DENOMINATOR); +} diff --git a/perpetuals/src/tests/mod.rs b/perpetuals/src/tests/mod.rs new file mode 100644 index 0000000..44698b8 --- /dev/null +++ b/perpetuals/src/tests/mod.rs @@ -0,0 +1,452 @@ +mod genesis; +mod rpc; +mod transaction; + +use std::collections::BTreeMap; + +use commonware_codec::Encode; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use futures::executor::block_on; +use nunchi_coins::{CoinDB, CoinSpec, TokenDefinition, TokenName, TokenSymbol}; +use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; +use nunchi_crypto::PrivateKey; +use nunchi_oracle::{ + IntervalKey, NamespaceId, OracleLedger, OracleOperation, Transaction as OracleTransaction, +}; + +use crate::{ + collateral_escrow_account, insurance_fund_account, CoinId, OraclePricePayload, PerpetualDB, + PerpetualError, PerpetualLedger, PositionId, Side, BPS_DENOMINATOR, + DEFAULT_LIQUIDATION_REWARD_BPS, +}; + +#[derive(Default)] +struct MemoryStore { + values: BTreeMap>>, +} + +impl StateStore for MemoryStore { + async fn get(&self, key: &Digest) -> Result>, StateError> { + Ok(self.values.get(key).cloned().flatten()) + } + + fn set(&mut self, key: Digest, value: Vec) { + self.values.insert(key, Some(value)); + } + + fn remove(&mut self, key: Digest) { + self.values.insert(key, None); + } +} + +fn digest(seed: &'static [u8]) -> Digest { + Sha256::hash(seed) +} + +fn coin(seed: &'static [u8]) -> CoinId { + CoinId(digest(seed)) +} + +fn namespace() -> NamespaceId { + NamespaceId(digest(b"perps-price-feed")) +} + +fn context(timestamp_ms: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height: timestamp_ms / 100, + timestamp_ms, + block_digest: None, + } +} + +fn address(signer: &PrivateKey) -> Address { + Address::external(&signer.public_key()) +} + +fn append_price( + ledger: &mut PerpetualLedger, + writer: &PrivateKey, + nonce: u64, + market: Digest, + price: u128, + price_decimals: u8, + timestamp_ms: u64, +) { + let payload = OraclePricePayload { + market, + price, + price_decimals, + source_timestamp_ms: timestamp_ms, + }; + let append = OracleTransaction::sign( + writer, + nonce, + OracleOperation::AppendRecord { + namespace: namespace(), + interval: IntervalKey::new(timestamp_ms / 1_000), + payload: payload.encode().as_ref().to_vec(), + proof: None, + }, + ); + let mut oracle = OracleLedger::new(ledger.db_mut()); + block_on(oracle.apply_transaction(&append, context(timestamp_ms))).unwrap(); +} + +fn oracle_writer() -> PrivateKey { + PrivateKey::from_seed(2) +} + +fn create_market(ledger: &mut PerpetualLedger) -> Digest { + block_on(ledger.create_market( + coin(b"btc"), + coin(b"usd"), + coin(b"usdc"), + namespace(), + address(&oracle_writer()), + None, + 1_000, + 10_000, + 2, + 10 * BPS_DENOMINATOR, + 500, + 3_600_000, + 100, + DEFAULT_LIQUIDATION_REWARD_BPS, + )) + .unwrap() +} + +fn seed_collateral(ledger: &mut PerpetualLedger, owner: &Address, amount: u128) { + let issuer = address(&PrivateKey::from_seed(999)); + let token = TokenDefinition::from_spec( + coin(b"usdc"), + issuer, + CoinSpec::new( + TokenSymbol::new("USDC").unwrap(), + TokenName::new("USD Coin").unwrap(), + 6, + 1_000_000_000, + None, + ), + ); + ledger.db_mut().set_token(&token); + ledger.db_mut().set_balance(owner, &coin(b"usdc"), amount); +} + +fn balance(ledger: &PerpetualLedger, account: &Address) -> u128 { + block_on(CoinDB::balance(ledger.db(), account, &coin(b"usdc"))).unwrap() +} + +fn escrow_balance(ledger: &PerpetualLedger) -> u128 { + balance(ledger, &collateral_escrow_account()) +} + +fn set_insurance_balance(ledger: &mut PerpetualLedger, amount: u128) { + ledger + .db_mut() + .set_balance(&insurance_fund_account(), &coin(b"usdc"), amount); +} + +fn skew_market_prices( + ledger: &mut PerpetualLedger, + market: Digest, + mark_price: u128, + index_price: u128, +) { + let mut market_state = block_on(ledger.market(&market)).unwrap().unwrap(); + market_state.mark_price = mark_price; + market_state.index_price = index_price; + market_state.max_oracle_staleness_ms = 10_000_000; + ledger.db_mut().set_market(&market_state); +} + +fn open_long( + ledger: &mut PerpetualLedger, + owner: &PrivateKey, + market: Digest, + timestamp_ms: u64, +) -> PositionId { + block_on(ledger.open_position( + address(owner), + market, + Side::Long, + 1_000, + 5 * BPS_DENOMINATOR, + context(timestamp_ms), + )) + .unwrap() +} + +fn open_short( + ledger: &mut PerpetualLedger, + owner: &PrivateKey, + market: Digest, + timestamp_ms: u64, +) -> PositionId { + block_on(ledger.open_position( + address(owner), + market, + Side::Short, + 1_000, + 5 * BPS_DENOMINATOR, + context(timestamp_ms), + )) + .unwrap() +} + +#[test] +fn refresh_market_from_oracle_decodes_mock_price_payload() { + let writer = oracle_writer(); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + + let market = block_on(ledger.market(&market)).unwrap().unwrap(); + assert_eq!(market.mark_price, 5_000_000); + assert_eq!(market.index_price, 5_000_000); + assert_eq!(market.last_oracle_interval, 1); +} + +#[test] +fn refresh_market_skips_malformed_and_untrusted_oracle_records() { + let writer = oracle_writer(); + let untrusted = PrivateKey::from_seed(99); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &untrusted, 0, market, 900_000_000, 4, 1_000); + let append = OracleTransaction::sign( + &writer, + 0, + OracleOperation::AppendRecord { + namespace: namespace(), + interval: IntervalKey::new(1), + payload: b"not-a-price-payload".to_vec(), + proof: None, + }, + ); + let mut oracle = OracleLedger::new(ledger.db_mut()); + block_on(oracle.apply_transaction(&append, context(1_000))).unwrap(); + append_price(&mut ledger, &writer, 1, market, 500_000_000, 4, 1_500); + + block_on(ledger.refresh_market_from_oracle(market, context(2_000))).unwrap(); + let market = block_on(ledger.market(&market)).unwrap().unwrap(); + assert_eq!(market.index_price, 5_000_000); +} + +#[test] +fn long_position_blocks_unsafe_withdrawal_then_liquidates_after_price_drop() { + let writer = oracle_writer(); + let trader = PrivateKey::from_seed(12); + let trader_address = address(&trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &trader_address, 10_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_long(&mut ledger, &trader, market, 1_600); + assert_eq!(balance(&ledger, &trader_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_000); + + append_price(&mut ledger, &writer, 1, market, 430_000_000, 4, 2_000); + block_on(ledger.refresh_market_from_oracle(market, context(2_500))).unwrap(); + let reduction = + block_on(ledger.reduce_collateral(&address(&trader), position, 100, context(2_600))); + assert_eq!( + reduction.unwrap_err(), + PerpetualError::CollateralReductionWouldCauseLiquidation + ); + assert_eq!(balance(&ledger, &trader_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_000); + + append_price(&mut ledger, &writer, 2, market, 400_000_000, 4, 3_000); + block_on(ledger.refresh_market_from_oracle(market, context(3_500))).unwrap(); + let liquidator = address(&PrivateKey::from_seed(13)); + let reward = block_on(ledger.liquidate(&liquidator, position, context(3_600))).unwrap(); + assert_eq!(reward, 50); + assert!(block_on(ledger.position(&position)).unwrap().is_none()); + assert_eq!(balance(&ledger, &trader_address), 9_000); + assert_eq!(balance(&ledger, &liquidator), reward); + assert_eq!(escrow_balance(&ledger), 0); + assert_eq!(balance(&ledger, &insurance_fund_account()), 950); +} + +#[cfg(feature = "mock-execution")] +#[test] +fn collateral_moves_through_escrow_on_open_adjust_and_close() { + let writer = oracle_writer(); + let trader = PrivateKey::from_seed(32); + let trader_address = address(&trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &trader_address, 5_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_long(&mut ledger, &trader, market, 1_600); + assert_eq!(balance(&ledger, &trader_address), 4_000); + assert_eq!(escrow_balance(&ledger), 1_000); + + block_on(ledger.add_collateral(&trader_address, position, 500)).unwrap(); + assert_eq!(balance(&ledger, &trader_address), 3_500); + assert_eq!(escrow_balance(&ledger), 1_500); + + block_on(ledger.reduce_collateral(&trader_address, position, 250, context(1_700))).unwrap(); + assert_eq!(balance(&ledger, &trader_address), 3_750); + assert_eq!(escrow_balance(&ledger), 1_250); + + let payout = + block_on(ledger.close_position(&trader_address, position, context(1_800))).unwrap(); + assert_eq!(payout, 1_250); + assert_eq!(balance(&ledger, &trader_address), 5_000); + assert_eq!(escrow_balance(&ledger), 0); + assert!(block_on(ledger.position(&position)).unwrap().is_none()); +} + +#[test] +fn funding_accrual_is_capped_and_interval_based() { + let writer = oracle_writer(); + let long_trader = PrivateKey::from_seed(42); + let short_trader = PrivateKey::from_seed(43); + let long_address = address(&long_trader); + let short_address = address(&short_trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &long_address, 5_000); + seed_collateral(&mut ledger, &short_address, 5_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + open_long(&mut ledger, &long_trader, market, 1_600); + open_short(&mut ledger, &short_trader, market, 1_600); + skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); + + block_on(ledger.settle_funding(market, context(7_201_500))).unwrap(); + let market_state = block_on(ledger.market(&market)).unwrap().unwrap(); + + assert_eq!(market_state.cumulative_funding_long, 100_000); + assert_eq!(market_state.cumulative_funding_short, -100_000); + assert_eq!(market_state.last_funding_ms, 7_201_500); +} + +#[cfg(feature = "mock-execution")] +#[test] +fn funding_reduces_long_close_payout_when_mark_exceeds_index() { + let writer = oracle_writer(); + let long_trader = PrivateKey::from_seed(52); + let short_trader = PrivateKey::from_seed(53); + let long_address = address(&long_trader); + let short_address = address(&short_trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &long_address, 10_000); + seed_collateral(&mut ledger, &short_address, 10_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_long(&mut ledger, &long_trader, market, 1_600); + open_short(&mut ledger, &short_trader, market, 1_600); + skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); + + let payout = + block_on(ledger.close_position(&long_address, position, context(3_601_500))).unwrap(); + + assert_eq!(payout, 950); + assert_eq!(balance(&ledger, &long_address), 9_950); + assert_eq!(balance(&ledger, &short_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_050); +} + +#[cfg(feature = "mock-execution")] +#[test] +fn funding_increases_short_close_payout_when_mark_exceeds_index() { + let writer = oracle_writer(); + let long_trader = PrivateKey::from_seed(61); + let short_trader = PrivateKey::from_seed(62); + let long_address = address(&long_trader); + let short_address = address(&short_trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &long_address, 10_000); + seed_collateral(&mut ledger, &short_address, 10_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + open_long(&mut ledger, &long_trader, market, 1_600); + let position = open_short(&mut ledger, &short_trader, market, 1_600); + skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); + + let payout = + block_on(ledger.close_position(&short_address, position, context(3_601_500))).unwrap(); + + assert_eq!(payout, 1_050); + assert_eq!(balance(&ledger, &short_address), 10_050); + assert_eq!(balance(&ledger, &long_address), 9_000); + assert_eq!(escrow_balance(&ledger), 950); +} + +#[test] +fn update_mark_price_keeps_index_from_oracle() { + let writer = oracle_writer(); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + block_on(ledger.update_mark_price(market, 4_500_000, context(1_600))).unwrap(); + + let market = block_on(ledger.market(&market)).unwrap().unwrap(); + assert_eq!(market.index_price, 5_000_000); + assert_eq!(market.mark_price, 4_500_000); +} + +#[cfg(feature = "mock-execution")] +#[test] +fn profitable_close_draws_from_insurance_when_escrow_is_insufficient() { + let writer = oracle_writer(); + let winner = PrivateKey::from_seed(71); + let loser = PrivateKey::from_seed(72); + let winner_address = address(&winner); + let loser_address = address(&loser); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &winner_address, 10_000); + seed_collateral(&mut ledger, &loser_address, 10_000); + set_insurance_balance(&mut ledger, 500); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + open_short(&mut ledger, &loser, market, 1_600); + let winner_position = open_long(&mut ledger, &winner, market, 1_600); + skew_market_prices(&mut ledger, market, 6_000_000, 5_000_000); + + let payout = + block_on(ledger.close_position(&winner_address, winner_position, context(1_700))).unwrap(); + assert!(payout > 1_000); + assert_eq!(balance(&ledger, &winner_address), 10_000 - 1_000 + payout); +} + +#[test] +fn stale_oracle_price_blocks_trading() { + let writer = oracle_writer(); + let trader = PrivateKey::from_seed(22); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let err = block_on(ledger.open_position( + address(&trader), + market, + Side::Long, + 1_000, + 5 * BPS_DENOMINATOR, + context(20_000), + )) + .unwrap_err(); + assert_eq!(err, PerpetualError::StaleOraclePrice); +} diff --git a/perpetuals/src/tests/rpc.rs b/perpetuals/src/tests/rpc.rs new file mode 100644 index 0000000..f40c395 --- /dev/null +++ b/perpetuals/src/tests/rpc.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use commonware_cryptography::{Hasher, Sha256}; +use commonware_runtime::Runner as _; +use nunchi_oracle::NamespaceId; +use nunchi_rpc::{encode_hex, RpcRouter}; + +use nunchi_crypto::PrivateKey; + +use crate::{ + Address, CoinId, Market, MarketId, PerpetualError, Position, PositionId, Side, + DEFAULT_LIQUIDATION_REWARD_BPS, +}; + +#[derive(Clone)] +struct MockQuery { + inner: Arc, +} + +struct MockState { + account: Address, + market: Market, + position: Position, +} + +impl MockQuery { + fn new() -> Self { + let account = Address::external(&PrivateKey::from_seed(1).public_key()); + let market_id = Sha256::hash(b"btc-usd-perp"); + let market = Market { + id: market_id, + base_asset: CoinId(Sha256::hash(b"btc")), + quote_asset: CoinId(Sha256::hash(b"usd")), + collateral_asset: CoinId(Sha256::hash(b"usdc")), + oracle_namespace: NamespaceId(Sha256::hash(b"oracle")), + oracle_writer: account.clone(), + clob_market: Some(Sha256::hash(b"clob")), + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + price_decimals: 2, + max_leverage_bps: 50_000, + maintenance_margin_bps: 1_000, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: DEFAULT_LIQUIDATION_REWARD_BPS, + mark_price: 100_000, + index_price: 99_500, + long_open_interest: 4_000_000_000, + short_open_interest: 4_000_000_000, + last_oracle_interval: 1, + last_oracle_update_ms: 1_000, + last_funding_ms: 0, + cumulative_funding_long: 0, + cumulative_funding_short: 0, + }; + let position = Position { + id: Sha256::hash(b"position"), + market: market_id, + owner: account.clone(), + side: Side::Long, + quantity: 2_000_000_000, + entry_price: 100_000, + collateral: 10_000, + entry_funding_index: 0, + }; + Self { + inner: Arc::new(MockState { + account, + market, + position, + }), + } + } +} + +#[async_trait] +impl crate::rpc::PerpetualQuery for MockQuery { + async fn nonce(&self, account: Address) -> Result { + assert_eq!(account, self.inner.account); + Ok(9) + } + + async fn market(&self, market: MarketId) -> Result, PerpetualError> { + assert_eq!(market, self.inner.market.id); + Ok(Some(self.inner.market.clone())) + } + + async fn position(&self, position: PositionId) -> Result, PerpetualError> { + assert_eq!(position, self.inner.position.id); + Ok(Some(self.inner.position.clone())) + } + + async fn state_root(&self) -> Result { + Ok(Sha256::hash(b"perps-root")) + } +} + +#[test] +fn perpetuals_rpc_queries() { + commonware_runtime::deterministic::Runner::default().start(|_| async move { + let query = MockQuery::new(); + let mut router = RpcRouter::new(()); + crate::rpc::register(&mut router, crate::rpc::PerpetualsRpc::new(query.clone())) + .expect("register perpetuals RPC"); + let module = router.into_module(); + let account = encode_hex(&query.inner.account); + let market = encode_hex(&query.inner.market.id); + let position = encode_hex(&query.inner.position.id); + + let mut nonce_params = jsonrpsee::core::params::ObjectParams::new(); + nonce_params + .insert("account", account.clone()) + .expect("serialize nonce params"); + let nonce: crate::rpc::NonceResponse = module + .call("perpetuals.nonce", nonce_params) + .await + .expect("nonce response"); + assert_eq!(nonce.account, account); + assert_eq!(nonce.nonce, 9); + + let mut market_params = jsonrpsee::core::params::ObjectParams::new(); + market_params + .insert("market", market.clone()) + .expect("serialize market params"); + let market_resp: Option = module + .call("perpetuals.market", market_params) + .await + .expect("market response"); + let market_resp = market_resp.expect("market exists"); + assert_eq!(market_resp.id, market); + assert_eq!(market_resp.matched_open_interest, "4000000000"); + + let mut position_params = jsonrpsee::core::params::ObjectParams::new(); + position_params + .insert("position", position.clone()) + .expect("serialize position params"); + let position_resp: Option = module + .call("perpetuals.position", position_params) + .await + .expect("position response"); + let position_resp = position_resp.expect("position exists"); + assert_eq!(position_resp.id, position); + assert_eq!(position_resp.side, "long"); + + let root: crate::rpc::RootResponse = module + .call( + "perpetuals.state_root", + jsonrpsee::core::EmptyServerParams::new(), + ) + .await + .expect("root response"); + assert_eq!(root.root, encode_hex(&Sha256::hash(b"perps-root"))); + }); +} diff --git a/perpetuals/src/tests/transaction.rs b/perpetuals/src/tests/transaction.rs new file mode 100644 index 0000000..f294ec5 --- /dev/null +++ b/perpetuals/src/tests/transaction.rs @@ -0,0 +1,104 @@ +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{Hasher, Sha256}; +use nunchi_oracle::NamespaceId; + +use nunchi_crypto::PrivateKey; + +use crate::{ + derive_market_id, derive_position_id, Address, CoinId, PerpetualOperation, Side, Transaction, +}; + +fn coin(seed: &'static [u8]) -> CoinId { + CoinId(Sha256::hash(seed)) +} + +fn writer() -> PrivateKey { + PrivateKey::from_seed(1) +} + +fn roundtrip(operation: PerpetualOperation, nonce: u64) { + let tx = Transaction::sign(&writer(), nonce, operation.clone()); + let decoded = Transaction::decode(tx.encode().as_ref()).expect("decode transaction"); + assert_eq!(decoded.payload.operation, operation); + assert_eq!(decoded.payload.nonce, nonce); +} + +#[test] +fn perpetual_operation_codec_roundtrips() { + let market = derive_market_id(coin(b"btc"), coin(b"usd"), coin(b"usdc"), 0); + let position = derive_position_id(&Address::external(&writer().public_key()), &market, 0); + let writer_addr = Address::external(&writer().public_key()); + + roundtrip( + PerpetualOperation::CreateMarket { + base_asset: coin(b"btc"), + quote_asset: coin(b"usd"), + collateral_asset: coin(b"usdc"), + oracle_namespace: NamespaceId(Sha256::hash(b"oracle")), + oracle_writer: writer_addr.clone(), + clob_market: Some(Sha256::hash(b"clob")), + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + price_decimals: 2, + max_leverage_bps: 50_000, + maintenance_margin_bps: 1_000, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: crate::DEFAULT_LIQUIDATION_REWARD_BPS, + }, + 0, + ); + roundtrip( + PerpetualOperation::CreateMarket { + base_asset: coin(b"btc"), + quote_asset: coin(b"usd"), + collateral_asset: coin(b"usdc"), + oracle_namespace: NamespaceId(Sha256::hash(b"oracle")), + oracle_writer: writer_addr, + clob_market: None, + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + price_decimals: 0, + max_leverage_bps: 10_000, + maintenance_margin_bps: 500, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: crate::DEFAULT_LIQUIDATION_REWARD_BPS, + }, + 1, + ); + roundtrip(PerpetualOperation::RefreshMarketFromOracle { market }, 2); + roundtrip(PerpetualOperation::SettleFunding { market }, 3); + roundtrip( + PerpetualOperation::OpenPosition { + market, + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + 4, + ); + roundtrip( + PerpetualOperation::AddCollateral { + position, + amount: 500, + }, + 5, + ); + roundtrip( + PerpetualOperation::ReduceCollateral { + position, + amount: 100, + }, + 6, + ); + roundtrip(PerpetualOperation::ClosePosition { position }, 7); + roundtrip(PerpetualOperation::Liquidate { position }, 8); + roundtrip( + PerpetualOperation::UpdateMarkPrice { + market, + mark_price: 101_000, + }, + 9, + ); +} diff --git a/perpetuals/src/transaction.rs b/perpetuals/src/transaction.rs new file mode 100644 index 0000000..04c5194 --- /dev/null +++ b/perpetuals/src/transaction.rs @@ -0,0 +1,308 @@ +use crate::{MarketId, PositionId, Side, PERPETUALS_NAMESPACE}; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_coins::CoinId; +use nunchi_common::{Address, Operation}; +use nunchi_oracle::NamespaceId; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OperationTag { + CreateMarket = 0, + RefreshMarketFromOracle = 1, + SettleFunding = 2, + OpenPosition = 3, + AddCollateral = 4, + ReduceCollateral = 5, + ClosePosition = 6, + Liquidate = 7, + UpdateMarkPrice = 8, +} + +impl TryFrom for OperationTag { + type Error = Error; + + fn try_from(tag: u8) -> Result { + match tag { + 0 => Ok(Self::CreateMarket), + 1 => Ok(Self::RefreshMarketFromOracle), + 2 => Ok(Self::SettleFunding), + 3 => Ok(Self::OpenPosition), + 4 => Ok(Self::AddCollateral), + 5 => Ok(Self::ReduceCollateral), + 6 => Ok(Self::ClosePosition), + 7 => Ok(Self::Liquidate), + 8 => Ok(Self::UpdateMarkPrice), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +/// Perpetuals state-machine operation carried by a signed Nunchi transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PerpetualOperation { + CreateMarket { + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + oracle_namespace: NamespaceId, + oracle_writer: Address, + clob_market: Option, + oracle_interval_ms: u64, + max_oracle_staleness_ms: u64, + price_decimals: u8, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + funding_interval_ms: u64, + max_funding_rate_bps: u32, + liquidation_reward_bps: u32, + }, + RefreshMarketFromOracle { + market: MarketId, + }, + SettleFunding { + market: MarketId, + }, + OpenPosition { + market: MarketId, + side: Side, + collateral: u128, + leverage_bps: u32, + }, + AddCollateral { + position: PositionId, + amount: u128, + }, + ReduceCollateral { + position: PositionId, + amount: u128, + }, + ClosePosition { + position: PositionId, + }, + Liquidate { + position: PositionId, + }, + UpdateMarkPrice { + market: MarketId, + mark_price: u128, + }, +} + +impl Write for PerpetualOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_writer, + clob_market, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + liquidation_reward_bps, + } => { + (OperationTag::CreateMarket as u8).write(buf); + base_asset.write(buf); + quote_asset.write(buf); + collateral_asset.write(buf); + oracle_namespace.write(buf); + oracle_writer.write(buf); + match clob_market { + Some(id) => { + 1u8.write(buf); + id.write(buf); + } + None => 0u8.write(buf), + } + oracle_interval_ms.write(buf); + max_oracle_staleness_ms.write(buf); + price_decimals.write(buf); + max_leverage_bps.write(buf); + maintenance_margin_bps.write(buf); + funding_interval_ms.write(buf); + max_funding_rate_bps.write(buf); + liquidation_reward_bps.write(buf); + } + Self::RefreshMarketFromOracle { market } => { + (OperationTag::RefreshMarketFromOracle as u8).write(buf); + market.write(buf); + } + Self::SettleFunding { market } => { + (OperationTag::SettleFunding as u8).write(buf); + market.write(buf); + } + Self::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + (OperationTag::OpenPosition as u8).write(buf); + market.write(buf); + side.write(buf); + collateral.write(buf); + leverage_bps.write(buf); + } + Self::AddCollateral { position, amount } => { + (OperationTag::AddCollateral as u8).write(buf); + position.write(buf); + amount.write(buf); + } + Self::ReduceCollateral { position, amount } => { + (OperationTag::ReduceCollateral as u8).write(buf); + position.write(buf); + amount.write(buf); + } + Self::ClosePosition { position } => { + (OperationTag::ClosePosition as u8).write(buf); + position.write(buf); + } + Self::Liquidate { position } => { + (OperationTag::Liquidate as u8).write(buf); + position.write(buf); + } + Self::UpdateMarkPrice { market, mark_price } => { + (OperationTag::UpdateMarkPrice as u8).write(buf); + market.write(buf); + mark_price.write(buf); + } + } + } +} + +impl Read for PerpetualOperation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match OperationTag::try_from(u8::read(buf)?)? { + OperationTag::CreateMarket => Ok(Self::CreateMarket { + base_asset: CoinId::read(buf)?, + quote_asset: CoinId::read(buf)?, + collateral_asset: CoinId::read(buf)?, + oracle_namespace: NamespaceId::read(buf)?, + oracle_writer: Address::read(buf)?, + clob_market: match u8::read(buf)? { + 0 => None, + 1 => Some(MarketId::read(buf)?), + tag => return Err(Error::InvalidEnum(tag)), + }, + oracle_interval_ms: u64::read(buf)?, + max_oracle_staleness_ms: u64::read(buf)?, + price_decimals: u8::read(buf)?, + max_leverage_bps: u32::read(buf)?, + maintenance_margin_bps: u32::read(buf)?, + funding_interval_ms: u64::read(buf)?, + max_funding_rate_bps: u32::read(buf)?, + liquidation_reward_bps: u32::read(buf)?, + }), + OperationTag::RefreshMarketFromOracle => Ok(Self::RefreshMarketFromOracle { + market: MarketId::read(buf)?, + }), + OperationTag::SettleFunding => Ok(Self::SettleFunding { + market: MarketId::read(buf)?, + }), + OperationTag::OpenPosition => Ok(Self::OpenPosition { + market: MarketId::read(buf)?, + side: Side::read(buf)?, + collateral: u128::read(buf)?, + leverage_bps: u32::read(buf)?, + }), + OperationTag::AddCollateral => Ok(Self::AddCollateral { + position: PositionId::read(buf)?, + amount: u128::read(buf)?, + }), + OperationTag::ReduceCollateral => Ok(Self::ReduceCollateral { + position: PositionId::read(buf)?, + amount: u128::read(buf)?, + }), + OperationTag::ClosePosition => Ok(Self::ClosePosition { + position: PositionId::read(buf)?, + }), + OperationTag::Liquidate => Ok(Self::Liquidate { + position: PositionId::read(buf)?, + }), + OperationTag::UpdateMarkPrice => Ok(Self::UpdateMarkPrice { + market: MarketId::read(buf)?, + mark_price: u128::read(buf)?, + }), + } + } +} + +impl EncodeSize for PerpetualOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_writer, + clob_market, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + liquidation_reward_bps, + } => { + base_asset.encode_size() + + quote_asset.encode_size() + + collateral_asset.encode_size() + + oracle_namespace.encode_size() + + oracle_writer.encode_size() + + 1 + + clob_market.as_ref().map(MarketId::encode_size).unwrap_or(0) + + oracle_interval_ms.encode_size() + + max_oracle_staleness_ms.encode_size() + + price_decimals.encode_size() + + max_leverage_bps.encode_size() + + maintenance_margin_bps.encode_size() + + funding_interval_ms.encode_size() + + max_funding_rate_bps.encode_size() + + liquidation_reward_bps.encode_size() + } + Self::RefreshMarketFromOracle { market } | Self::SettleFunding { market } => { + market.encode_size() + } + Self::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + market.encode_size() + + side.encode_size() + + collateral.encode_size() + + leverage_bps.encode_size() + } + Self::AddCollateral { position, amount } + | Self::ReduceCollateral { position, amount } => { + position.encode_size() + amount.encode_size() + } + Self::ClosePosition { position } | Self::Liquidate { position } => { + position.encode_size() + } + Self::UpdateMarkPrice { market, mark_price } => { + market.encode_size() + mark_price.encode_size() + } + } + } +} + +impl Operation for PerpetualOperation { + const NAMESPACE: &'static [u8] = PERPETUALS_NAMESPACE; +} + +/// Signed perpetuals transaction payload. +pub type TransactionPayload = nunchi_common::TransactionPayload; +/// Signed perpetuals transaction. +pub type Transaction = nunchi_common::Transaction; diff --git a/perpetuals/src/types.rs b/perpetuals/src/types.rs new file mode 100644 index 0000000..74ce9b6 --- /dev/null +++ b/perpetuals/src/types.rs @@ -0,0 +1,350 @@ +use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use nunchi_coins::CoinId; +use nunchi_common::Address; +use nunchi_oracle::NamespaceId; + +/// Basis-point denominator used by leverage, funding, and margin fields. +pub const BPS_DENOMINATOR: u32 = 10_000; +/// Fixed-point scale used for position quantities and funding indices. +pub const PRICE_SCALE: u128 = 1_000_000_000; +/// Largest decimal precision accepted by the perps price decoder. +pub const MAX_PRICE_DECIMALS: u8 = 38; + +/// Stable market identifier. +pub type MarketId = Digest; +/// Stable position identifier. +pub type PositionId = Digest; + +/// Direction of a perpetual position. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Side { + Long, + Short, +} + +impl Side { + pub fn opposite(self) -> Self { + match self { + Self::Long => Self::Short, + Self::Short => Self::Long, + } + } +} + +/// Derive a deterministic position id for one `(owner, market, side)` tuple. +pub fn derive_position_id_for_side(owner: &Address, market: &MarketId, side: Side) -> PositionId { + let mut hasher = Sha256::new(); + hasher.update(super::PERPETUALS_NAMESPACE); + hasher.update(b"/position-side/"); + hasher.update(owner.encode().as_ref()); + hasher.update(market.encode().as_ref()); + match side { + Side::Long => { + hasher.update(&[0]); + } + Side::Short => { + hasher.update(&[1]); + } + } + hasher.finalize() +} + +/// Default liquidator reward when a market is created without an explicit value. +pub const DEFAULT_LIQUIDATION_REWARD_BPS: u32 = 500; + +/// Market-level state and configuration owned by the perps module. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Market { + pub id: MarketId, + pub base_asset: CoinId, + pub quote_asset: CoinId, + pub collateral_asset: CoinId, + pub oracle_namespace: NamespaceId, + /// Trusted oracle writer for index-price records in `oracle_namespace`. + pub oracle_writer: Address, + /// Optional linked CLOB market id used by off-chain actors to publish mark prices. + pub clob_market: Option, + pub oracle_interval_ms: u64, + pub max_oracle_staleness_ms: u64, + pub price_decimals: u8, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub funding_interval_ms: u64, + pub max_funding_rate_bps: u32, + /// Reward paid to the liquidator as a fraction of position collateral. + pub liquidation_reward_bps: u32, + pub mark_price: u128, + pub index_price: u128, + pub long_open_interest: u128, + pub short_open_interest: u128, + pub last_oracle_interval: u64, + pub last_oracle_update_ms: u64, + pub last_funding_ms: u64, + pub cumulative_funding_long: i128, + pub cumulative_funding_short: i128, +} + +impl Market { + /// Open interest matched between long and short sides. + pub fn matched_open_interest(&self) -> u128 { + self.long_open_interest.min(self.short_open_interest) + } +} + +/// Isolated-margin position state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Position { + pub id: PositionId, + pub market: MarketId, + pub owner: Address, + pub side: Side, + pub quantity: u128, + pub entry_price: u128, + pub collateral: u128, + pub entry_funding_index: i128, +} + +/// Payload schema interpreted by this module from opaque Oracle records. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OraclePricePayload { + pub market: MarketId, + pub price: u128, + pub price_decimals: u8, + pub source_timestamp_ms: u64, +} + +/// Derive a market id from its configured assets and module-local nonce. +pub fn derive_market_id( + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + nonce: u64, +) -> MarketId { + let mut hasher = Sha256::new(); + hasher.update(super::PERPETUALS_NAMESPACE); + hasher.update(b"/market/"); + hasher.update(base_asset.encode().as_ref()); + hasher.update(quote_asset.encode().as_ref()); + hasher.update(collateral_asset.encode().as_ref()); + hasher.update(nonce.encode().as_ref()); + hasher.finalize() +} + +/// Derive a position id from its owner, market, and module-local nonce. +pub fn derive_position_id(owner: &Address, market: &MarketId, nonce: u64) -> PositionId { + let mut hasher = Sha256::new(); + hasher.update(super::PERPETUALS_NAMESPACE); + hasher.update(b"/position/"); + hasher.update(owner.encode().as_ref()); + hasher.update(market.encode().as_ref()); + hasher.update(nonce.encode().as_ref()); + hasher.finalize() +} + +impl Write for Side { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Long => 0u8.write(buf), + Self::Short => 1u8.write(buf), + } + } +} + +impl Read for Side { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::Long), + 1 => Ok(Self::Short), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for Side { + fn encode_size(&self) -> usize { + 1 + } +} + +impl Write for Market { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.base_asset.write(buf); + self.quote_asset.write(buf); + self.collateral_asset.write(buf); + self.oracle_namespace.write(buf); + self.oracle_writer.write(buf); + match &self.clob_market { + Some(id) => { + 1u8.write(buf); + id.write(buf); + } + None => 0u8.write(buf), + } + self.oracle_interval_ms.write(buf); + self.max_oracle_staleness_ms.write(buf); + self.price_decimals.write(buf); + self.max_leverage_bps.write(buf); + self.maintenance_margin_bps.write(buf); + self.funding_interval_ms.write(buf); + self.max_funding_rate_bps.write(buf); + self.liquidation_reward_bps.write(buf); + self.mark_price.write(buf); + self.index_price.write(buf); + self.long_open_interest.write(buf); + self.short_open_interest.write(buf); + self.last_oracle_interval.write(buf); + self.last_oracle_update_ms.write(buf); + self.last_funding_ms.write(buf); + self.cumulative_funding_long.write(buf); + self.cumulative_funding_short.write(buf); + } +} + +impl Read for Market { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: MarketId::read(buf)?, + base_asset: CoinId::read(buf)?, + quote_asset: CoinId::read(buf)?, + collateral_asset: CoinId::read(buf)?, + oracle_namespace: NamespaceId::read(buf)?, + oracle_writer: Address::read(buf)?, + clob_market: match u8::read(buf)? { + 0 => None, + 1 => Some(MarketId::read(buf)?), + tag => return Err(Error::InvalidEnum(tag)), + }, + oracle_interval_ms: u64::read(buf)?, + max_oracle_staleness_ms: u64::read(buf)?, + price_decimals: u8::read(buf)?, + max_leverage_bps: u32::read(buf)?, + maintenance_margin_bps: u32::read(buf)?, + funding_interval_ms: u64::read(buf)?, + max_funding_rate_bps: u32::read(buf)?, + liquidation_reward_bps: u32::read(buf)?, + mark_price: u128::read(buf)?, + index_price: u128::read(buf)?, + long_open_interest: u128::read(buf)?, + short_open_interest: u128::read(buf)?, + last_oracle_interval: u64::read(buf)?, + last_oracle_update_ms: u64::read(buf)?, + last_funding_ms: u64::read(buf)?, + cumulative_funding_long: i128::read(buf)?, + cumulative_funding_short: i128::read(buf)?, + }) + } +} + +impl EncodeSize for Market { + fn encode_size(&self) -> usize { + self.id.encode_size() + + self.base_asset.encode_size() + + self.quote_asset.encode_size() + + self.collateral_asset.encode_size() + + self.oracle_namespace.encode_size() + + self.oracle_writer.encode_size() + + 1 + + self + .clob_market + .as_ref() + .map(MarketId::encode_size) + .unwrap_or(0) + + self.oracle_interval_ms.encode_size() + + self.max_oracle_staleness_ms.encode_size() + + self.price_decimals.encode_size() + + self.max_leverage_bps.encode_size() + + self.maintenance_margin_bps.encode_size() + + self.funding_interval_ms.encode_size() + + self.max_funding_rate_bps.encode_size() + + self.liquidation_reward_bps.encode_size() + + self.mark_price.encode_size() + + self.index_price.encode_size() + + self.long_open_interest.encode_size() + + self.short_open_interest.encode_size() + + self.last_oracle_interval.encode_size() + + self.last_oracle_update_ms.encode_size() + + self.last_funding_ms.encode_size() + + self.cumulative_funding_long.encode_size() + + self.cumulative_funding_short.encode_size() + } +} + +impl Write for Position { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.market.write(buf); + self.owner.write(buf); + self.side.write(buf); + self.quantity.write(buf); + self.entry_price.write(buf); + self.collateral.write(buf); + self.entry_funding_index.write(buf); + } +} + +impl Read for Position { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: PositionId::read(buf)?, + market: MarketId::read(buf)?, + owner: Address::read(buf)?, + side: Side::read(buf)?, + quantity: u128::read(buf)?, + entry_price: u128::read(buf)?, + collateral: u128::read(buf)?, + entry_funding_index: i128::read(buf)?, + }) + } +} + +impl EncodeSize for Position { + fn encode_size(&self) -> usize { + self.id.encode_size() + + self.market.encode_size() + + self.owner.encode_size() + + self.side.encode_size() + + self.quantity.encode_size() + + self.entry_price.encode_size() + + self.collateral.encode_size() + + self.entry_funding_index.encode_size() + } +} + +impl Write for OraclePricePayload { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.market.write(buf); + self.price.write(buf); + self.price_decimals.write(buf); + self.source_timestamp_ms.write(buf); + } +} + +impl Read for OraclePricePayload { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + market: MarketId::read(buf)?, + price: u128::read(buf)?, + price_decimals: u8::read(buf)?, + source_timestamp_ms: u64::read(buf)?, + }) + } +} + +impl EncodeSize for OraclePricePayload { + fn encode_size(&self) -> usize { + self.market.encode_size() + + self.price.encode_size() + + self.price_decimals.encode_size() + + self.source_timestamp_ms.encode_size() + } +}