diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 52d796941..93161276a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -74,6 +74,12 @@ jobs: - name: Build run: cargo build --verbose + # Install pocket-ic for ICP Contract tests + - name: Install PocketIC server + uses: dfinity/pocketic@main + with: + pocket-ic-server-version: "7.0.0" + - name: Run tests run: | chmod +x $GITHUB_WORKSPACE/scripts/build-all-apps.sh diff --git a/Cargo.lock b/Cargo.lock index 5cf06ebd5..c3a5b1202 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,7 +267,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "synstructure 0.13.1", ] @@ -290,7 +290,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -352,7 +352,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -363,7 +363,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -417,7 +417,7 @@ checksum = "3c87f3f15e7794432337fc718554eaa4dc8f04c9677a950ffe366f20a162ae42" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -454,7 +454,7 @@ dependencies = [ "serde_path_to_error", "serde_urlencoded", "sha1", - "sync_wrapper 1.0.1", + "sync_wrapper 1.0.2", "tokio", "tokio-tungstenite", "tower 0.5.1", @@ -478,7 +478,7 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "sync_wrapper 1.0.1", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -611,7 +611,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -720,7 +720,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -780,9 +780,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "bytesize" @@ -1045,7 +1045,7 @@ dependencies = [ "prettyplease 0.2.25", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "thiserror 1.0.69", ] @@ -1152,7 +1152,7 @@ dependencies = [ "calimero-sdk", "calimero-storage", "quote", - "syn 2.0.87", + "syn 2.0.89", "trybuild", ] @@ -1164,7 +1164,7 @@ dependencies = [ "calimero-primitives", "camino", "eyre", - "generic-array 1.1.0", + "generic-array 1.1.1", "rocksdb", "serde", "serde_json", @@ -1215,14 +1215,14 @@ dependencies = [ "lazy_static", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] name = "cc" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" dependencies = [ "jobserver", "libc", @@ -1357,7 +1357,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -1440,6 +1440,23 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "context_contract" +version = "0.1.0" +dependencies = [ + "bs58 0.5.1", + "calimero-context-config", + "candid", + "ed25519-dalek", + "ic-cdk 0.16.0", + "ic-cdk-macros 0.16.0", + "pocket-ic", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -1473,6 +1490,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1503,9 +1530,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" +checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" dependencies = [ "libc", ] @@ -1706,7 +1733,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -1730,7 +1757,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -1741,7 +1768,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -1851,7 +1878,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -1864,7 +1891,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -1937,7 +1964,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -2023,7 +2050,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -2063,7 +2090,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -2084,7 +2111,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -2093,14 +2120,23 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + [[package]] name = "errno" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2439,7 +2475,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -2459,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" dependencies = [ "futures-io", - "rustls 0.23.17", + "rustls 0.23.19", "rustls-pki-types", ] @@ -2540,9 +2576,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96512db27971c2c3eece70a1e106fbe6c87760234e31e8f7e5634912fe52794a" +checksum = "2cb8bc4c28d15ade99c7e90b219f30da4be5c88e586277e8cbe886beeb868ab2" dependencies = [ "typenum", ] @@ -2669,9 +2705,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.1" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" dependencies = [ "allocator-api2", "equivalent", @@ -2883,9 +2919,9 @@ dependencies = [ [[package]] name = "http-range-header" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08a397c49fec283e3d6211adbe480be95aae5f304cfb923e9970e08956d5168a" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" [[package]] name = "httparse" @@ -2968,11 +3004,13 @@ dependencies = [ "http 1.1.0", "hyper 1.5.1", "hyper-util", - "rustls 0.23.17", + "rustls 0.23.19", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls 0.26.0", "tower-service", + "webpki-roots 0.26.7", ] [[package]] @@ -3054,7 +3092,7 @@ checksum = "5db33deb06e0edb366d8d86ef67d7bc1e1759bc7046b0323a33b85b21b8d8d87" dependencies = [ "candid", "hex", - "ic-cdk", + "ic-cdk 0.14.1", "ic-certification", "ic-representation-independent-hash", "lazy_static", @@ -3072,7 +3110,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cff1a3c3db565e3384c9c9d6d676b0a3f89a0886f4f787294d9c946d844369f" dependencies = [ "candid", - "ic-cdk-macros", + "ic-cdk-macros 0.14.0", + "ic0", + "serde", + "serde_bytes", +] + +[[package]] +name = "ic-cdk" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8ecacd682fa05a985253592963306cb9799622d7b1cce4b1edb89c6ec85be1" +dependencies = [ + "candid", + "ic-cdk-macros 0.16.0", + "ic0", + "serde", + "serde_bytes", +] + +[[package]] +name = "ic-cdk" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2abdf9341da9f9f6b451a40609cb69645a05a8e9eb7784c16209f16f2c0f76f" +dependencies = [ + "candid", + "ic-cdk-macros 0.17.0", "ic0", "serde", "serde_bytes", @@ -3089,7 +3153,35 @@ dependencies = [ "quote", "serde", "serde_tokenstream", - "syn 2.0.87", + "syn 2.0.89", +] + +[[package]] +name = "ic-cdk-macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4d857135deef20cc7ea8f3869a30cd9cfeb1392b3a81043790b2cd82adc3e0" +dependencies = [ + "candid", + "proc-macro2", + "quote", + "serde", + "serde_tokenstream", + "syn 2.0.89", +] + +[[package]] +name = "ic-cdk-macros" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8df41980e95dead28735ab0f748c75477b0c5eab37a09a5641c78ec406a1db0" +dependencies = [ + "candid", + "proc-macro2", + "quote", + "serde", + "serde_tokenstream", + "syn 2.0.89", ] [[package]] @@ -3104,6 +3196,21 @@ dependencies = [ "sha2", ] +[[package]] +name = "ic-ledger-types" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7103dea96107c8b1e49d6e80ab452ab48e9f3594d618dbb46449381f4f0a01" +dependencies = [ + "candid", + "crc32fast", + "hex", + "ic-cdk 0.17.0", + "serde", + "serde_bytes", + "sha2", +] + [[package]] name = "ic-representation-independent-hash" version = "2.6.0" @@ -3130,6 +3237,23 @@ dependencies = [ "sha2", ] +[[package]] +name = "ic-transport-types" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875dc4704780383112e8e8b5063a1b98de114321d0c7d3e7f635dcf360a57fba" +dependencies = [ + "candid", + "hex", + "ic-certification", + "leb128", + "serde", + "serde_bytes", + "serde_repr", + "sha2", + "thiserror 1.0.69", +] + [[package]] name = "ic-verify-bls-signature" version = "0.6.0" @@ -3291,7 +3415,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -3348,7 +3472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdf9d64cfcf380606e64f9a0bcf493616b65331199f984151a6fa11a7b3cde38" dependencies = [ "async-io", - "core-foundation", + "core-foundation 0.9.4", "fnv", "futures", "if-addrs", @@ -3412,13 +3536,13 @@ dependencies = [ [[package]] name = "impl-trait-for-tuples" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d7a9f6330b71fea57921c9b61c47ee6e84f72d394754eff6163ae67e7395eb" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.89", ] [[package]] @@ -3445,7 +3569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.15.1", + "hashbrown 0.15.2", "serde", ] @@ -3540,9 +3664,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.12" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a73e9fe3c49d7afb2ace819fa181a287ce54a0983eda4e0eb05c22f82ffe534" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "jobserver" @@ -3703,9 +3827,9 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" [[package]] name = "libc" -version = "0.2.164" +version = "0.2.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" +checksum = "09d6582e104315a817dff97f75133544b2e094ee22447d2acf4a74e189ba06fc" [[package]] name = "libloading" @@ -3903,9 +4027,9 @@ dependencies = [ [[package]] name = "libp2p-identity" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cca1eb2bc1fd29f099f3daaab7effd01e1a54b7c577d0ed082521034d912e8" +checksum = "257b5621d159b32282eac446bed6670c39c7dc68a200a992d8f056afa0066f6d" dependencies = [ "bs58 0.5.1", "ed25519-dalek", @@ -4052,7 +4176,7 @@ dependencies = [ "quinn", "rand 0.8.5", "ring 0.17.8", - "rustls 0.23.17", + "rustls 0.23.19", "socket2", "thiserror 1.0.69", "tokio", @@ -4176,7 +4300,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -4227,7 +4351,7 @@ dependencies = [ "libp2p-identity", "rcgen 0.11.3", "ring 0.17.8", - "rustls 0.23.17", + "rustls 0.23.19", "rustls-webpki 0.101.7", "thiserror 1.0.69", "x509-parser 0.16.0", @@ -4317,9 +4441,9 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "litemap" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "local-ip-address" @@ -4365,7 +4489,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.1", + "hashbrown 0.15.2", ] [[package]] @@ -4571,6 +4695,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mock_external" +version = "0.1.0" +dependencies = [ + "candid", + "ic-cdk 0.16.0", + "ic-cdk-macros 0.16.0", +] + +[[package]] +name = "mock_ledger" +version = "0.1.0" +dependencies = [ + "candid", + "ic-cdk 0.16.0", + "ic-cdk-macros 0.16.0", + "ic-ledger-types", + "serde", +] + [[package]] name = "more-asserts" version = "0.2.2" @@ -4643,7 +4787,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -4984,7 +5128,7 @@ dependencies = [ "serde_json", "strum 0.26.3", "strum_macros 0.26.4", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -5410,7 +5554,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -5471,7 +5615,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -5519,7 +5663,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -5613,7 +5757,7 @@ checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -5644,6 +5788,35 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "pocket-ic" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "124a2380ca6f557adf8b02517cbfd2f564113230e14cda6f6aadd3dfe156293c" +dependencies = [ + "base64 0.13.1", + "candid", + "hex", + "ic-certification", + "ic-transport-types", + "reqwest 0.12.9", + "schemars", + "serde", + "serde_bytes", + "serde_cbor", + "serde_json", + "sha2", + "slog", + "strum 0.26.3", + "strum_macros 0.26.4", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "wslpath", +] + [[package]] name = "polling" version = "3.7.4" @@ -5725,7 +5898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" dependencies = [ "proc-macro2", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -5786,9 +5959,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -5801,7 +5974,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "version_check", "yansi", ] @@ -5826,7 +5999,7 @@ checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -5846,6 +6019,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "proxy_contract" +version = "0.1.0" +dependencies = [ + "bs58 0.5.1", + "calimero-context-config", + "candid", + "ed25519-dalek", + "hex", + "ic-cdk 0.16.0", + "ic-cdk-macros 0.16.0", + "ic-ledger-types", + "pocket-ic", + "rand 0.8.5", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "psm" version = "0.1.24" @@ -5928,7 +6119,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.0.0", - "rustls 0.23.17", + "rustls 0.23.19", "socket2", "thiserror 2.0.3", "tokio", @@ -5946,7 +6137,7 @@ dependencies = [ "rand 0.8.5", "ring 0.17.8", "rustc-hash 2.0.0", - "rustls 0.23.17", + "rustls 0.23.19", "rustls-pki-types", "slab", "thiserror 2.0.3", @@ -6257,6 +6448,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.7", @@ -6271,18 +6463,25 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.19", + "rustls-native-certs", "rustls-pemfile 2.2.0", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper 1.0.2", "system-configuration 0.6.1", "tokio", "tokio-native-tls", + "tokio-rustls 0.26.0", + "tokio-socks", "tokio-util", "tower-service", "url", @@ -6290,6 +6489,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 0.26.7", "windows-registry", ] @@ -6440,7 +6640,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.87", + "syn 2.0.89", "walkdir", ] @@ -6524,9 +6724,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.17" +version = "0.23.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f1a745511c54ba6d4465e8d5dfbd81b45791756de28d4981af70d6dca128f1e" +checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1" dependencies = [ "log", "once_cell", @@ -6537,6 +6737,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.0.1", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -6656,7 +6868,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -6719,7 +6931,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.6.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1415a607e92bec364ea2cf9264646dcce0f91e6d65281bd6f2819cca3bf39c8" +dependencies = [ + "bitflags 2.6.0", + "core-foundation 0.10.0", "core-foundation-sys", "libc", "security-framework-sys", @@ -6794,7 +7019,7 @@ checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -6805,7 +7030,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -6849,7 +7074,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -6870,7 +7095,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -6912,7 +7137,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -7055,6 +7280,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7" +[[package]] +name = "slog" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8347046d4ebd943127157b94d63abb990fcf729dc4e9978927fdf4ac3c998d06" +dependencies = [ + "erased-serde", +] + [[package]] name = "smallvec" version = "1.13.2" @@ -7091,9 +7325,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", @@ -7225,7 +7459,7 @@ source = "git+https://github.com/xJonathanLEI/starknet-rs?rev=5c676a6#5c676a6403 dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -7288,7 +7522,7 @@ version = "0.2.1" source = "git+https://github.com/xJonathanLEI/starknet-rs?rev=5c676a6#5c676a64031901b5a203168fd8ef8d6b40a5862f" dependencies = [ "starknet-core", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -7394,7 +7628,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -7416,9 +7650,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.87" +version = "2.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" dependencies = [ "proc-macro2", "quote", @@ -7433,9 +7667,9 @@ checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] name = "sync_wrapper" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ "futures-core", ] @@ -7460,7 +7694,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -7470,7 +7704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys 0.5.0", ] @@ -7481,7 +7715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.6.0", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -7602,7 +7836,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -7613,7 +7847,7 @@ checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -7732,7 +7966,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -7772,11 +8006,23 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.17", + "rustls 0.23.19", "rustls-pki-types", "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.16" @@ -7999,9 +8245,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -8009,22 +8255,34 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror 1.0.69", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -8051,6 +8309,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.18" @@ -8061,12 +8329,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -8225,10 +8496,10 @@ dependencies = [ "flate2", "log", "once_cell", - "rustls 0.23.17", + "rustls 0.23.19", "rustls-pki-types", "url", - "webpki-roots 0.26.6", + "webpki-roots 0.26.7", ] [[package]] @@ -8243,9 +8514,9 @@ dependencies = [ [[package]] name = "url" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna 1.0.3", @@ -8402,7 +8673,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "wasm-bindgen-shared", ] @@ -8436,7 +8707,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -8714,9 +8985,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "0.26.6" +version = "0.26.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" dependencies = [ "rustls-pki-types", ] @@ -9060,6 +9331,12 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "wslpath" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04a2ecdf2cc4d33a6a93d71bcfbc00bb1f635cdb8029a2cc0709204a045ec7a3" + [[package]] name = "wyz" version = "0.5.1" @@ -9195,9 +9472,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", @@ -9207,13 +9484,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "synstructure 0.13.1", ] @@ -9235,27 +9512,27 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] name = "zerofrom" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "synstructure 0.13.1", ] @@ -9276,7 +9553,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -9311,7 +9588,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cfc9a7d98..8537a3438 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,10 @@ members = [ "./contracts/registry", "./contracts/proxy-lib", "./contracts/test-counter", + "./contracts/icp/context-config", + "./contracts/icp/proxy-contract", + "./contracts/icp/proxy-contract/mock/ledger", + "./contracts/icp/proxy-contract/mock/external", "./e2e-tests", ] diff --git a/contracts/icp/context-config/.env b/contracts/icp/context-config/.env new file mode 100644 index 000000000..a2a7da06e --- /dev/null +++ b/contracts/icp/context-config/.env @@ -0,0 +1,8 @@ + +# DFX CANISTER ENVIRONMENT VARIABLES +DFX_VERSION='0.24.2' +DFX_NETWORK='local' +CANISTER_ID_CONTEXT_CONTRACT='bw4dl-smaaa-aaaaa-qaacq-cai' +CANISTER_ID='bw4dl-smaaa-aaaaa-qaacq-cai' +CANISTER_CANDID_PATH='/Users/alen/www/calimero/core/contracts/icp/context-config/context_contract.did' +# END DFX CANISTER ENVIRONMENT VARIABLES \ No newline at end of file diff --git a/contracts/icp/context-config/.gitignore b/contracts/icp/context-config/.gitignore new file mode 100644 index 000000000..0730dc676 --- /dev/null +++ b/contracts/icp/context-config/.gitignore @@ -0,0 +1,28 @@ +# Various IDEs and Editors +.vscode/ +.idea/ +**/*~ + +# Mac OSX temporary files +.DS_Store +**/.DS_Store + +# dfx temporary files +.dfx/ + +# generated files +**/declarations/ + +# rust +target/ + +# frontend code +node_modules/ +dist/ +.svelte-kit/ + +# environment variables +.env + +# testing library +pocket-ic diff --git a/contracts/icp/context-config/Cargo.toml b/contracts/icp/context-config/Cargo.toml new file mode 100644 index 000000000..ea8bb60ff --- /dev/null +++ b/contracts/icp/context-config/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "context_contract" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +bs58.workspace = true +calimero-context-config = { path = "../../../crates/context/config" } +candid = "0.10" +ed25519-dalek.workspace = true +ic-cdk = "0.16" +ic-cdk-macros = "0.16" +serde = { version = "1.0", features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +pocket-ic = "6.0.0" +rand = "0.8" +ed25519-dalek = "2.0" \ No newline at end of file diff --git a/contracts/icp/context-config/build.sh b/contracts/icp/context-config/build.sh new file mode 100755 index 000000000..4ce43aa1e --- /dev/null +++ b/contracts/icp/context-config/build.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +cd "$(dirname $0)" + +TARGET="${CARGO_TARGET_DIR:-../../../target}" + +rustup target add wasm32-unknown-unknown + +cargo build --target wasm32-unknown-unknown --profile app-release + +mkdir -p res + +cp $TARGET/wasm32-unknown-unknown/app-release/context_contract.wasm ./res/ + +if command -v wasm-opt > /dev/null; then + wasm-opt -Oz ./res/context_contract.wasm -o ./res/context_contract.wasm +fi diff --git a/contracts/icp/context-config/context_contract.did b/contracts/icp/context-config/context_contract.did new file mode 100644 index 000000000..af9edb43c --- /dev/null +++ b/contracts/icp/context-config/context_contract.did @@ -0,0 +1,23 @@ +type ICApplication = record { + id : blob; + source : text; + metadata : blob; + "blob" : blob; + size : nat64; +}; +type ICCapability = variant { Proxy; ManageMembers; ManageApplication }; +type ICPSigned = record { signature : blob; _phantom : null; payload : blob }; +type Result = variant { Ok; Err : text }; +service : () -> { + application : (blob) -> (ICApplication) query; + application_revision : (blob) -> (nat64) query; + has_member : (blob, blob) -> (bool) query; + members : (blob, nat64, nat64) -> (vec blob) query; + members_revision : (blob) -> (nat64) query; + mutate : (ICPSigned) -> (Result); + privileges : (blob, vec blob) -> ( + vec record { blob; vec ICCapability }, + ) query; + proxy_contract : (blob) -> (principal) query; + set_proxy_code : (blob) -> (Result); +} diff --git a/contracts/icp/context-config/deploy_devnet.sh b/contracts/icp/context-config/deploy_devnet.sh new file mode 100644 index 000000000..4174ccade --- /dev/null +++ b/contracts/icp/context-config/deploy_devnet.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Build the contract +bash ./build.sh + +# Generate the candid interface +candid-extractor res/context_contract.wasm > context_contract.did + +# Stop the replica +dfx stop + +# Start the replica +dfx start --background + +# Deploy the contract +dfx deploy \ No newline at end of file diff --git a/contracts/icp/context-config/dfx.json b/contracts/icp/context-config/dfx.json new file mode 100644 index 000000000..b29207e8f --- /dev/null +++ b/contracts/icp/context-config/dfx.json @@ -0,0 +1,17 @@ +{ + "canisters": { + "context_contract": { + "candid": "context_contract.did", + "package": "context_contract", + "type": "rust" + } + }, + "defaults": { + "build": { + "args": "", + "packtool": "" + } + }, + "output_env_file": ".env", + "version": 1 +} \ No newline at end of file diff --git a/contracts/icp/context-config/src/guard.rs b/contracts/icp/context-config/src/guard.rs new file mode 100644 index 000000000..7004f9b2b --- /dev/null +++ b/contracts/icp/context-config/src/guard.rs @@ -0,0 +1,134 @@ +use std::collections::BTreeSet; +use std::fmt; +use std::ops::{Deref, DerefMut}; + +use calimero_context_config::types::Revision; +use candid::CandidType; +use serde::{Deserialize, Serialize}; + +use crate::types::ICSignerId; + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct Guard { + inner: T, + revision: Revision, + privileged: BTreeSet, +} + +#[derive(Debug)] +pub struct UnauthorizedAccess { + _priv: (), +} + +impl fmt::Display for UnauthorizedAccess { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad("unauthorized access") + } +} + +impl Guard { + pub fn new(creator: ICSignerId, inner: T) -> Self { + Self { + inner, + revision: 0, + privileged: BTreeSet::from([creator]), + } + } + + pub fn get( + &mut self, + signer_id: &ICSignerId, + ) -> Result, UnauthorizedAccess> { + if !self.privileged.contains(signer_id) { + return Err(UnauthorizedAccess { _priv: () }); + } + Ok(GuardHandle { inner: self }) + } + + pub fn into_inner(self) -> T { + self.inner + } + + pub fn privileged(&self) -> &BTreeSet { + &self.privileged + } + + pub fn privileges(&mut self) -> Privileges<'_> { + Privileges { + inner: &mut self.privileged, + } + } + + pub const fn revision(&self) -> Revision { + self.revision + } +} + +impl Deref for Guard { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +#[derive(Debug)] +pub struct GuardHandle<'a, T> { + inner: &'a mut Guard, +} + +impl<'a, T> GuardHandle<'a, T> { + pub fn get_mut(self) -> GuardMut<'a, T> { + GuardMut { inner: self.inner } + } + + pub fn privileges(&mut self) -> Privileges<'_> { + self.inner.privileges() + } +} + +#[derive(Debug)] +pub struct GuardMut<'a, T> { + inner: &'a mut Guard, +} + +impl GuardMut<'_, T> { + pub fn privileges(&mut self) -> Privileges<'_> { + self.inner.privileges() + } +} + +impl Deref for GuardMut<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.inner + } +} + +impl DerefMut for GuardMut<'_, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner.inner + } +} + +impl Drop for GuardMut<'_, T> { + fn drop(&mut self) { + self.inner.revision = self.inner.revision.wrapping_add(1); + } +} + +#[derive(Debug)] +pub struct Privileges<'a> { + inner: &'a mut BTreeSet, +} + +impl Privileges<'_> { + pub fn grant(&mut self, signer_id: ICSignerId) { + self.inner.insert(signer_id); + } + + pub fn revoke(&mut self, signer_id: &ICSignerId) { + self.inner.remove(signer_id); + } +} diff --git a/contracts/icp/context-config/src/lib.rs b/contracts/icp/context-config/src/lib.rs new file mode 100644 index 000000000..b3283e6d9 --- /dev/null +++ b/contracts/icp/context-config/src/lib.rs @@ -0,0 +1,55 @@ +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; + +use candid::{CandidType, Principal}; +use guard::Guard; +use serde::{Deserialize, Serialize}; + +use crate::types::{ + ICApplication, ICCapability, ICContextId, ICContextIdentity, ICPSigned, ICSignerId, Request, +}; + +pub mod guard; +pub mod mutate; +pub mod query; +pub mod sys; +pub mod types; + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct Context { + pub application: Guard, + pub members: Guard>, + pub proxy: Guard, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct ContextConfigs { + pub contexts: HashMap, + pub proxy_code: Option>, + pub owner: Principal, + pub ledger_id: Principal, +} + +impl Default for ContextConfigs { + fn default() -> Self { + Self { + contexts: HashMap::new(), + proxy_code: None, + owner: ic_cdk::api::caller(), + ledger_id: Principal::anonymous(), + } + } +} + +thread_local! { + pub static CONTEXT_CONFIGS: RefCell = RefCell::new(ContextConfigs::default()); +} + +#[ic_cdk::init] +fn init() { + CONTEXT_CONFIGS.with(|state| { + *state.borrow_mut() = ContextConfigs::default(); + }); +} + +ic_cdk::export_candid!(); diff --git a/contracts/icp/context-config/src/mutate.rs b/contracts/icp/context-config/src/mutate.rs new file mode 100644 index 000000000..8e5c4148f --- /dev/null +++ b/contracts/icp/context-config/src/mutate.rs @@ -0,0 +1,374 @@ +use std::ops::Deref; + +use calimero_context_config::repr::{ReprBytes, ReprTransmute}; +use candid::Principal; +use ic_cdk::api::management_canister::main::{ + create_canister, install_code, CanisterSettings, CreateCanisterArgument, InstallCodeArgument, +}; + +use crate::guard::Guard; +use crate::types::{ + ContextRequest, ContextRequestKind, ICApplication, ICCapability, ICContextId, + ICContextIdentity, ICPSigned, ICSignerId, Request, RequestKind, +}; +use crate::{Context, CONTEXT_CONFIGS}; + +#[ic_cdk::update] +pub async fn mutate(signed_request: ICPSigned) -> Result<(), String> { + let request = signed_request + .parse(|r| r.signer_id) + .map_err(|e| format!("Failed to verify signature: {}", e))?; + + // Check request timestamp + let current_time = ic_cdk::api::time(); + if current_time.saturating_sub(request.timestamp_ms) > 1000 * 5 { + // 5 seconds threshold + return Err("request expired".to_string()); + } + + match request.kind { + RequestKind::Context(ContextRequest { context_id, kind }) => match kind { + ContextRequestKind::Add { + author_id, + application, + } => add_context(&request.signer_id, context_id, author_id, application).await, + ContextRequestKind::UpdateApplication { application } => { + update_application(&request.signer_id, &context_id, application) + } + ContextRequestKind::AddMembers { members } => { + add_members(&request.signer_id, &context_id, members) + } + ContextRequestKind::RemoveMembers { members } => { + remove_members(&request.signer_id, &context_id, members) + } + ContextRequestKind::Grant { capabilities } => { + grant(&request.signer_id, &context_id, capabilities) + } + ContextRequestKind::Revoke { capabilities } => { + revoke(&request.signer_id, &context_id, capabilities) + } + ContextRequestKind::UpdateProxyContract => { + update_proxy_contract(&request.signer_id, context_id).await + } + }, + } +} + +async fn add_context( + signer_id: &ICSignerId, + context_id: ICContextId, + author_id: ICContextIdentity, + application: ICApplication, +) -> Result<(), String> { + if signer_id.as_bytes() != context_id.as_bytes() { + return Err("context addition must be signed by the context itself".into()); + } + + let proxy_canister_id = deploy_proxy_contract(&context_id) + .await + .unwrap_or_else(|e| panic!("Failed to deploy proxy contract: {}", e)); + + CONTEXT_CONFIGS.with(|configs| { + let mut configs = configs.borrow_mut(); + + // Create context with guards + let context = Context { + application: Guard::new(author_id.rt().expect("infallible conversion"), application), + members: Guard::new( + author_id.rt().expect("infallible conversion"), + vec![author_id.rt().expect("infallible conversion")], + ), + proxy: Guard::new( + author_id.rt().expect("infallible conversion"), + proxy_canister_id, + ), + }; + + // Store context + if configs.contexts.insert(context_id, context).is_some() { + return Err("context already exists".into()); + } + + Ok(()) + }) +} + +async fn deploy_proxy_contract(context_id: &ICContextId) -> Result { + // Get the proxy code + let proxy_code = CONTEXT_CONFIGS + .with(|configs| configs.borrow().proxy_code.clone()) + .ok_or("proxy code not set")?; + + // Get the ledger ID + let ledger_id = CONTEXT_CONFIGS.with(|configs| configs.borrow().ledger_id.clone()); + // Create canister with cycles + let create_args = CreateCanisterArgument { + settings: Some(CanisterSettings { + controllers: Some(vec![ic_cdk::api::id()]), + compute_allocation: None, + memory_allocation: None, + freezing_threshold: None, + reserved_cycles_limit: None, + log_visibility: None, + wasm_memory_limit: None, + }), + }; + + let (canister_record,) = create_canister(create_args, 500_000_000_000_000u128) + .await + .map_err(|e| format!("Failed to create canister: {:?}", e))?; + + let canister_id = canister_record.canister_id; + + // Encode init args matching the proxy's init(context_id: ICContextId, ledger_id: Principal) + let init_args = candid::encode_args((context_id.clone(), ledger_id)) + .map_err(|e| format!("Failed to encode init args: {}", e))?; + + let install_args = InstallCodeArgument { + mode: ic_cdk::api::management_canister::main::CanisterInstallMode::Install, + canister_id, + wasm_module: proxy_code, + arg: init_args, + }; + + install_code(install_args) + .await + .map_err(|e| format!("Failed to install code: {:?}", e))?; + + Ok(canister_id) +} + +fn update_application( + signer_id: &ICSignerId, + context_id: &ICContextId, + application: ICApplication, +) -> Result<(), String> { + CONTEXT_CONFIGS.with(|configs| { + let mut configs = configs.borrow_mut(); + + // Get the context or return error if it doesn't exist + let context = configs + .contexts + .get_mut(context_id) + .ok_or_else(|| "context does not exist".to_string())?; + + // Get mutable access to the application through the Guard + let guard_ref = context + .application + .get(signer_id) + .map_err(|e| e.to_string())?; + let mut app_ref = guard_ref.get_mut(); + + // Replace the application with the new one + *app_ref = application; + + Ok(()) + }) +} + +fn add_members( + signer_id: &ICSignerId, + context_id: &ICContextId, + members: Vec, +) -> Result<(), String> { + CONTEXT_CONFIGS.with(|configs| { + let mut configs = configs.borrow_mut(); + + // Get the context or return error if it doesn't exist + let context = configs + .contexts + .get_mut(context_id) + .ok_or_else(|| "context does not exist".to_string())?; + + // Get mutable access to the members through the Guard + let guard_ref = context.members.get(signer_id).map_err(|e| e.to_string())?; + let mut ctx_members = guard_ref.get_mut(); + + // Add each member + for member in members { + ctx_members.push(member); + } + + Ok(()) + }) +} + +fn remove_members( + signer_id: &ICSignerId, + context_id: &ICContextId, + members: Vec, +) -> Result<(), String> { + CONTEXT_CONFIGS.with(|configs| { + let mut configs = configs.borrow_mut(); + + // Get the context or return error if it doesn't exist + let context = configs + .contexts + .get_mut(context_id) + .ok_or_else(|| "context does not exist".to_string())?; + + // Get mutable access to the members through the Guard + let mut ctx_members = context + .members + .get(signer_id) + .map_err(|e| e.to_string())? + .get_mut(); + + for member in members { + // Remove member from the list + if let Some(pos) = ctx_members.iter().position(|x| x == &member) { + ctx_members.remove(pos); + } + + // Revoke privileges + ctx_members + .privileges() + .revoke(&member.rt().expect("infallible conversion")); + context + .application + .privileges() + .revoke(&member.rt().expect("infallible conversion")); + } + + Ok(()) + }) +} + +fn grant( + signer_id: &ICSignerId, + context_id: &ICContextId, + capabilities: Vec<(ICContextIdentity, ICCapability)>, +) -> Result<(), String> { + CONTEXT_CONFIGS.with(|configs| { + let mut configs = configs.borrow_mut(); + + let context = configs + .contexts + .get_mut(context_id) + .ok_or_else(|| "context does not exist".to_string())?; + + for (identity, capability) in capabilities { + let is_member = context.members.deref().contains(&identity); + + if !is_member { + return Err("unable to grant privileges to non-member".to_string()); + } + + match capability { + ICCapability::ManageApplication => { + context + .application + .get(signer_id) + .map_err(|e| e.to_string())? + .privileges() + .grant(identity.rt().expect("infallible conversion")); + } + ICCapability::ManageMembers => { + context + .members + .get(signer_id) + .map_err(|e| e.to_string())? + .privileges() + .grant(identity.rt().expect("infallible conversion")); + } + ICCapability::Proxy => { + context + .proxy + .get(signer_id) + .map_err(|e| e.to_string())? + .privileges() + .grant(identity.rt().expect("infallible conversion")); + } + } + } + + Ok(()) + }) +} + +fn revoke( + signer_id: &ICSignerId, + context_id: &ICContextId, + capabilities: Vec<(ICContextIdentity, ICCapability)>, +) -> Result<(), String> { + CONTEXT_CONFIGS.with(|configs| { + let mut configs = configs.borrow_mut(); + + let context = configs + .contexts + .get_mut(context_id) + .ok_or_else(|| "context does not exist".to_string())?; + + for (identity, capability) in capabilities { + match capability { + ICCapability::ManageApplication => { + context + .application + .get(signer_id) + .map_err(|e| e.to_string())? + .privileges() + .revoke(&identity.rt().expect("infallible conversion")); + } + ICCapability::ManageMembers => { + context + .members + .get(signer_id) + .map_err(|e| e.to_string())? + .privileges() + .revoke(&identity.rt().expect("infallible conversion")); + } + ICCapability::Proxy => { + context + .proxy + .get(signer_id) + .map_err(|e| e.to_string())? + .privileges() + .revoke(&identity.rt().expect("infallible conversion")); + } + } + } + + Ok(()) + }) +} + +async fn update_proxy_contract( + signer_id: &ICSignerId, + context_id: ICContextId, +) -> Result<(), String> { + let mut context = CONTEXT_CONFIGS.with(|configs| { + let configs = configs.borrow(); + configs + .contexts + .get(&context_id) + .ok_or_else(|| "context does not exist".to_string()) + .cloned() + })?; + + // Get proxy canister ID + let proxy_canister_id = context + .proxy + .get(signer_id) + .map_err(|_| "unauthorized: Proxy capability required".to_string())? + .get_mut() + .clone(); + + // Get the proxy code + let proxy_code = CONTEXT_CONFIGS + .with(|configs| configs.borrow().proxy_code.clone()) + .ok_or("proxy code not set")?; + + // Update the proxy contract code + let install_args = InstallCodeArgument { + mode: ic_cdk::api::management_canister::main::CanisterInstallMode::Upgrade(None), + canister_id: proxy_canister_id, + wasm_module: proxy_code, + arg: candid::encode_one(&context_id).map_err(|e| format!("Encoding error: {}", e))?, + }; + + install_code(install_args) + .await + .map_err(|e| format!("Failed to update proxy contract: {:?}", e))?; + + Ok(()) +} diff --git a/contracts/icp/context-config/src/query.rs b/contracts/icp/context-config/src/query.rs new file mode 100644 index 000000000..47205de9f --- /dev/null +++ b/contracts/icp/context-config/src/query.rs @@ -0,0 +1,137 @@ +use std::collections::BTreeMap; + +use calimero_context_config::repr::ReprTransmute; +use candid::Principal; +use ic_cdk_macros::query; + +use crate::types::*; +use crate::CONTEXT_CONFIGS; + +#[query] +fn application(context_id: ICContextId) -> ICApplication { + CONTEXT_CONFIGS.with(|configs| { + let configs = configs.borrow(); + let context = configs + .contexts + .get(&context_id) + .expect("context does not exist"); + + (*context.application).clone() + }) +} + +#[query] +fn application_revision(context_id: ICContextId) -> u64 { + CONTEXT_CONFIGS.with(|configs| { + let configs = configs.borrow(); + let context = configs + .contexts + .get(&context_id) + .expect("context does not exist"); + + context.application.revision() + }) +} + +#[query] +fn proxy_contract(context_id: ICContextId) -> Principal { + CONTEXT_CONFIGS.with(|configs| { + let configs = configs.borrow(); + let context = configs + .contexts + .get(&context_id) + .expect("context does not exist"); + + (*context.proxy).clone() + }) +} + +#[query] +fn members(context_id: ICContextId, offset: usize, length: usize) -> Vec { + CONTEXT_CONFIGS.with(|configs| { + let configs = configs.borrow(); + let context = configs + .contexts + .get(&context_id) + .expect("context does not exist"); + + let members = &*context.members; + members.iter().skip(offset).take(length).cloned().collect() + }) +} + +#[query] +fn has_member(context_id: ICContextId, identity: ICContextIdentity) -> bool { + CONTEXT_CONFIGS.with(|configs| { + let configs = configs.borrow(); + let context = configs + .contexts + .get(&context_id) + .expect("context does not exist"); + + context.members.contains(&identity) + }) +} + +#[query] +fn members_revision(context_id: ICContextId) -> u64 { + CONTEXT_CONFIGS.with(|configs| { + let configs = configs.borrow(); + let context = configs + .contexts + .get(&context_id) + .expect("context does not exist"); + + context.members.revision() + }) +} + +#[query] +fn privileges( + context_id: ICContextId, + identities: Vec, +) -> BTreeMap> { + CONTEXT_CONFIGS.with(|configs| { + let configs = configs.borrow(); + let context = configs + .contexts + .get(&context_id) + .expect("context does not exist"); + + let mut privileges: BTreeMap> = BTreeMap::new(); + + let application_privileges = context.application.privileged(); + let member_privileges = context.members.privileged(); + + if identities.is_empty() { + for signer_id in application_privileges { + privileges + .entry(*signer_id) + .or_default() + .push(ICCapability::ManageApplication); + } + + for signer_id in member_privileges { + privileges + .entry(*signer_id) + .or_default() + .push(ICCapability::ManageMembers); + } + } else { + for identity in identities { + let entry = privileges + .entry(identity.rt().expect("infallible conversion")) + .or_default(); + + if application_privileges.contains(&identity.rt().expect("infallible conversion")) { + entry.push(ICCapability::ManageApplication); + } + if member_privileges.contains(&identity.rt().expect("infallible conversion")) { + entry.push(ICCapability::ManageMembers); + } + } + } + + privileges + }) +} diff --git a/contracts/icp/context-config/src/sys.rs b/contracts/icp/context-config/src/sys.rs new file mode 100644 index 000000000..67437d930 --- /dev/null +++ b/contracts/icp/context-config/src/sys.rs @@ -0,0 +1,60 @@ +use candid::{CandidType, Deserialize, Principal}; +use ic_cdk; + +use crate::CONTEXT_CONFIGS; + +#[derive(CandidType, Deserialize)] +struct StableStorage { + configs: crate::ContextConfigs, +} + +#[ic_cdk::pre_upgrade] +fn pre_upgrade() { + // Verify caller is the owner + CONTEXT_CONFIGS.with(|configs| { + let configs = configs.borrow(); + if ic_cdk::api::caller() != configs.owner { + ic_cdk::trap("unauthorized: only owner can upgrade context contract"); + } + }); + + // Store the contract state + let state = CONTEXT_CONFIGS.with(|configs| StableStorage { + configs: configs.borrow().clone(), + }); + + // Write state to stable storage + match ic_cdk::storage::stable_save((state,)) { + Ok(_) => (), + Err(err) => ic_cdk::trap(&format!("Failed to save stable storage: {}", err)), + } +} + +#[ic_cdk::post_upgrade] +fn post_upgrade() { + // Restore the contract state + match ic_cdk::storage::stable_restore::<(StableStorage,)>() { + Ok((state,)) => { + CONTEXT_CONFIGS.with(|configs| { + *configs.borrow_mut() = state.configs; + }); + } + Err(err) => ic_cdk::trap(&format!("Failed to restore stable storage: {}", err)), + } +} + +#[ic_cdk::update] +pub fn set_proxy_code(proxy_code: Vec, ledger_id: Principal) -> Result<(), String> { + CONTEXT_CONFIGS.with(|configs| { + let mut configs = configs.borrow_mut(); + + // Check if caller is the owner + if ic_cdk::api::caller() != configs.owner { + return Err("Unauthorized: only owner can set proxy code".to_string()); + } + + configs.ledger_id = ledger_id; + configs.proxy_code = Some(proxy_code); + Ok(()) + }) +} diff --git a/contracts/icp/context-config/src/types.rs b/contracts/icp/context-config/src/types.rs new file mode 100644 index 000000000..7c60d49a9 --- /dev/null +++ b/contracts/icp/context-config/src/types.rs @@ -0,0 +1,371 @@ +use std::borrow::Cow; +use std::marker::PhantomData; + +use bs58::decode::Result as Bs58Result; +use calimero_context_config::repr::{self, LengthMismatch, Repr, ReprBytes, ReprTransmute}; +use calimero_context_config::types::{ + Application, ApplicationMetadata, ApplicationSource, Capability, IntoResult, +}; +use candid::CandidType; +use ed25519_dalek::{Verifier, VerifyingKey}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error as ThisError; + +#[derive( + CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Hash, +)] +pub struct Identity([u8; 32]); + +impl ReprBytes for Identity { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0 + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Self::DecodeBytes::from_bytes(f).map(Self) + } +} + +#[derive( + CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Copy, +)] +pub struct ICSignerId(Identity); + +impl ICSignerId { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Identity(bytes)) + } +} + +impl ReprBytes for ICSignerId { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0.as_bytes() + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Identity::from_bytes(f).map(Self) + } +} + +#[derive( + CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Copy, +)] +pub struct ICContextIdentity(Identity); + +impl ICContextIdentity { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Identity(bytes)) + } +} + +impl ReprBytes for ICContextIdentity { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0 .0 + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Identity::from_bytes(f).map(Self) + } +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] +pub struct ICContextId(Identity); + +impl ICContextId { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Identity(bytes)) + } +} + +impl ReprBytes for ICContextId { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0.as_bytes() + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Identity::from_bytes(f).map(Self) + } +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ICApplicationId(Identity); + +impl ICApplicationId { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Identity(bytes)) + } +} + +impl ReprBytes for ICApplicationId { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0.as_bytes() + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Identity::from_bytes(f).map(Self) + } +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ICBlobId(Identity); + +impl ICBlobId { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Identity(bytes)) + } +} + +impl ReprBytes for ICBlobId { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0.as_bytes() + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Identity::from_bytes(f).map(Self) + } +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ICApplication { + pub id: ICApplicationId, + pub blob: ICBlobId, + pub size: u64, + pub source: String, + pub metadata: Vec, +} + +impl From> for ICApplication { + fn from(value: Application) -> Self { + ICApplication { + id: value.id.rt().expect("infallible conversion"), + blob: value.blob.rt().expect("infallible conversion"), + size: value.size, + source: value.source.0.into_owned(), + metadata: value.metadata.0.into_inner().into_owned(), + } + } +} + +impl<'a> From for Application<'a> { + fn from(value: ICApplication) -> Self { + Application::new( + value.id.rt().expect("infallible conversion"), + value.blob.rt().expect("infallible conversion"), + value.size, + ApplicationSource(Cow::Owned(value.source)), + ApplicationMetadata(Repr::new(Cow::Owned(value.metadata))), + ) + } +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub struct ContextRequest { + pub context_id: ICContextId, + pub kind: ContextRequestKind, +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum ICCapability { + ManageApplication, + ManageMembers, + Proxy, +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub enum ContextRequestKind { + Add { + author_id: ICContextIdentity, + application: ICApplication, + }, + UpdateApplication { + application: ICApplication, + }, + AddMembers { + members: Vec, + }, + RemoveMembers { + members: Vec, + }, + Grant { + capabilities: Vec<(ICContextIdentity, ICCapability)>, + }, + Revoke { + capabilities: Vec<(ICContextIdentity, ICCapability)>, + }, + UpdateProxyContract, +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub enum RequestKind { + Context(ContextRequest), +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub struct Request { + pub kind: RequestKind, + pub signer_id: ICSignerId, + pub timestamp_ms: u64, +} + +impl Request { + pub fn new(signer_id: ICSignerId, kind: RequestKind) -> Self { + Self { + signer_id, + kind, + timestamp_ms: 0, // Default timestamp for tests + } + } +} + +#[derive(Debug, ThisError)] +pub enum ICPSignedError { + #[error("invalid signature")] + InvalidSignature, + #[error("json error: {0}")] + ParseError(#[from] serde_json::Error), + #[error("derivation error: {0}")] + DerivationError(E), + #[error("invalid public key")] + InvalidPublicKey, + #[error("signature error: {0}")] + SignatureError(#[from] ed25519_dalek::ed25519::Error), + #[error("serialization error: {0}")] + SerializationError(String), + #[error("deserialization error: {0}")] + DeserializationError(String), +} + +#[derive(Deserialize, Debug, Clone)] +struct Phantom(#[serde(skip)] std::marker::PhantomData); + +impl CandidType for Phantom { + fn _ty() -> candid::types::Type { + candid::types::TypeInner::Null.into() + } + + fn idl_serialize(&self, serializer: S) -> Result<(), S::Error> + where + S: candid::types::Serializer, + { + serializer.serialize_null(()) + } +} + +#[derive(CandidType, Deserialize, Debug, Clone)] +pub struct ICPSigned { + payload: Vec, + signature: Vec, + _phantom: Phantom, +} + +impl ICPSigned { + pub fn new(payload: T, sign: F) -> Result> + where + R: IntoResult, + F: FnOnce(&[u8]) -> R, + { + let bytes = candid::encode_one(payload) + .map_err(|e| ICPSignedError::SerializationError(e.to_string()))?; + + let signature = sign(&bytes) + .into_result() + .map_err(ICPSignedError::DerivationError)?; + + Ok(Self { + payload: bytes, + signature: signature.to_vec(), + _phantom: Phantom(PhantomData), + }) + } + + pub fn parse(&self, f: F) -> Result> + where + R: IntoResult, + F: FnOnce(&T) -> R, + { + let parsed: T = candid::decode_one(&self.payload) + .map_err(|e| ICPSignedError::DeserializationError(e.to_string()))?; + + let signer_id = f(&parsed) + .into_result() + .map_err(ICPSignedError::DerivationError)?; + + let key = signer_id + .rt::() + .map_err(|_| ICPSignedError::InvalidPublicKey)?; + + let signature_bytes: [u8; 64] = + self.signature.as_slice().try_into().map_err(|_| { + ICPSignedError::SignatureError(ed25519_dalek::ed25519::Error::new()) + })?; + let signature = ed25519_dalek::Signature::from_bytes(&signature_bytes); + + key.verify(&self.payload, &signature) + .map_err(|_| ICPSignedError::InvalidSignature)?; + + Ok(parsed) + } +} + +impl From for ICCapability { + fn from(value: Capability) -> Self { + match value { + Capability::ManageApplication => ICCapability::ManageApplication, + Capability::ManageMembers => ICCapability::ManageMembers, + Capability::Proxy => ICCapability::Proxy, + } + } +} + +impl From for Capability { + fn from(value: ICCapability) -> Self { + match value { + ICCapability::ManageApplication => Capability::ManageApplication, + ICCapability::ManageMembers => Capability::ManageMembers, + ICCapability::Proxy => Capability::Proxy, + } + } +} diff --git a/contracts/icp/context-config/tests/integration.rs b/contracts/icp/context-config/tests/integration.rs new file mode 100644 index 000000000..a662e72e2 --- /dev/null +++ b/contracts/icp/context-config/tests/integration.rs @@ -0,0 +1,970 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use calimero_context_config::repr::ReprBytes; +use candid::Principal; +use context_contract::types::{ + ContextRequest, ContextRequestKind, ICApplication, ICApplicationId, ICBlobId, ICCapability, + ICContextId, ICContextIdentity, ICPSigned, ICSignerId, Request, RequestKind, +}; +use ed25519_dalek::{Signer, SigningKey}; +use pocket_ic::{PocketIc, UserError, WasmResult}; +use rand::Rng; + +fn setup() -> (PocketIc, Principal) { + let pic = PocketIc::new(); + let wasm = std::fs::read("res/context_contract.wasm").expect("failed to read wasm"); + let canister = pic.create_canister(); + pic.add_cycles(canister, 1_000_000_000_000_000); + pic.install_canister( + canister, + wasm, + vec![], + None, // No controller + ); + + // Set the proxy code + let proxy_code = std::fs::read("../proxy-contract/res/proxy_contract.wasm") + .expect("failed to read proxy wasm"); + let ledger_id = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap(); + pic.update_call( + canister, + Principal::anonymous(), + "set_proxy_code", + candid::encode_args((proxy_code, ledger_id)).unwrap(), + ) + .expect("Failed to set proxy code"); + + (pic, canister) +} + +fn create_signed_request(signer_key: &SigningKey, request: Request) -> ICPSigned { + ICPSigned::new(request, |bytes| signer_key.sign(bytes)) + .expect("Failed to create signed request") +} + +fn get_time_nanos(pic: &PocketIc) -> u64 { + pic.get_time() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_nanos() as u64 +} + +fn handle_response( + response: Result, + expected_success: bool, + operation_name: &str, +) { + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result<(), String> = candid::decode_one(&bytes).unwrap_or_else(|e| { + panic!("Failed to decode response for {}: {}", operation_name, e) + }); + + match (result, expected_success) { + (Ok(_), true) => println!("{} succeeded as expected", operation_name), + (Ok(_), false) => panic!("{} succeeded when it should have failed", operation_name), + (Err(e), true) => panic!( + "{} failed when it should have succeeded: {}", + operation_name, e + ), + (Err(e), false) => println!("{} failed as expected: {}", operation_name, e), + } + } + Ok(WasmResult::Reject(msg)) => { + if expected_success { + panic!("{}: Unexpected canister rejection: {}", operation_name, msg); + } else { + println!("{}: Expected canister rejection: {}", operation_name, msg); + } + } + Err(e) => panic!("{}: Call failed: {:?}", operation_name, e), + } +} + +#[test] +fn test_proxy_management() { + let (pic, canister) = setup(); + let mut rng = rand::thread_rng(); + + // Advance IC time + let current_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + pic.advance_time(Duration::from_nanos(current_nanos)); + + // Create test identities + let context_sk = SigningKey::from_bytes(&rng.gen()); + let context_pk = context_sk.verifying_key(); + let context_id = ICContextId::new(context_pk.to_bytes()); + + let alice_sk = SigningKey::from_bytes(&rng.gen()); + let alice_pk = alice_sk.verifying_key(); + let alice_id = ICContextIdentity::new(alice_pk.to_bytes()); + + // Create context with initial application + let create_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Add { + author_id: alice_id.clone(), + application: ICApplication { + id: ICApplicationId::new(rng.gen()), + blob: ICBlobId::new(rng.gen()), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(context_id.as_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&context_sk, create_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "mutate"); + + // Try to update proxy contract without Proxy capability (should fail) + let bob_sk = SigningKey::from_bytes(&rng.gen()); + let bob_pk = bob_sk.verifying_key(); + let update_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::UpdateProxyContract, + }), + signer_id: ICSignerId::new(bob_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&bob_sk, update_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, false, "mutate"); + + // Update proxy contract with proper capability (Alice has it by default) + let update_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::UpdateProxyContract, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&alice_sk, update_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "mutate"); +} + +#[test] +fn test_mutate_success_cases() { + let (pic, canister) = setup(); + let mut rng = rand::thread_rng(); + + // Advance IC time to current time + let current_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + pic.advance_time(Duration::from_nanos(current_nanos)); + + // Create context keys and ID + let context_sk = SigningKey::from_bytes(&rng.gen()); + let context_pk = context_sk.verifying_key(); + let context_id = ICContextId::new(context_pk.to_bytes()); + + // Get current IC time in nanoseconds + let current_time = get_time_nanos(&pic); + + // Create the request with IC time in nanoseconds + let request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Add { + author_id: ICContextIdentity::new(rng.gen()), + application: ICApplication { + id: ICApplicationId::new(rng.gen()), + blob: ICBlobId::new(rng.gen()), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(context_id.as_bytes()), + timestamp_ms: current_time, + }; + + let signed_request = create_signed_request(&context_sk, request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Context creation"); +} + +#[test] +fn test_member_management() { + let (pic, canister) = setup(); + let mut rng = rand::thread_rng(); + + // Advance IC time + let current_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + pic.advance_time(Duration::from_nanos(current_nanos)); + + // Create test identities + let context_sk = SigningKey::from_bytes(&rng.gen()); + let context_pk = context_sk.verifying_key(); + let context_id = ICContextId::new(context_pk.to_bytes()); + + let alice_sk = SigningKey::from_bytes(&rng.gen()); + let alice_pk = alice_sk.verifying_key(); + let alice_id = ICContextIdentity::new(alice_pk.to_bytes()); + + let bob_sk = SigningKey::from_bytes(&rng.gen()); + let bob_pk = bob_sk.verifying_key(); + let bob_id = ICContextIdentity::new(bob_pk.to_bytes()); + + // First create the context with Alice as author + let create_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Add { + author_id: alice_id.clone(), + application: ICApplication { + id: ICApplicationId::new(rng.gen()), + blob: ICBlobId::new(rng.gen()), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(context_id.as_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&context_sk, create_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Context creation"); + + // Add Bob as a member (signed by Alice who has management rights) + let add_member_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::AddMembers { + members: vec![bob_id.clone()], + }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&alice_sk, add_member_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Member addition"); + + // Verify members through query call + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "members", + candid::encode_args((context_id.clone(), 0_usize, 10_usize)).unwrap(), + ); + + match query_response { + Ok(WasmResult::Reply(bytes)) => { + let members: Vec = candid::decode_one(&bytes).unwrap(); + assert_eq!( + members.len(), + 2, + "Should have both Alice and Bob as members" + ); + assert!(members.contains(&alice_id), "Alice should be a member"); + assert!(members.contains(&bob_id), "Bob should be a member"); + } + Ok(_) => panic!("Unexpected response format"), + Err(err) => panic!("Failed to query members: {}", err), + } + + // Try to remove Bob (signed by Alice) + let remove_member_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::RemoveMembers { + members: vec![bob_id.clone()], + }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&alice_sk, remove_member_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Member removal"); + + // Verify members again + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "members", + candid::encode_args((context_id.clone(), 0_usize, 10_usize)).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let members: Vec = candid::decode_one(&bytes).unwrap(); + assert_eq!(members.len(), 1, "Should have one member (Alice)"); + assert!( + members.contains(&alice_id), + "Alice should still be a member" + ); + assert!(!members.contains(&bob_id), "Bob should not be a member"); + } else { + panic!("Failed to query members"); + } +} + +#[test] +fn test_capability_management() { + let (pic, canister) = setup(); + let mut rng = rand::thread_rng(); + + // Advance IC time + let current_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + pic.advance_time(Duration::from_nanos(current_nanos)); + + // Create test identities + let context_sk = SigningKey::from_bytes(&rng.gen()); + let context_pk = context_sk.verifying_key(); + let context_id = ICContextId::new(context_pk.to_bytes()); + + let alice_sk = SigningKey::from_bytes(&rng.gen()); + let alice_pk = alice_sk.verifying_key(); + let alice_id = ICContextIdentity::new(alice_pk.to_bytes()); + + let bob_sk = SigningKey::from_bytes(&rng.gen()); + let bob_pk = bob_sk.verifying_key(); + let bob_id = ICContextIdentity::new(bob_pk.to_bytes()); + + // First create the context with Alice as author + let create_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Add { + author_id: alice_id.clone(), + application: ICApplication { + id: ICApplicationId::new(rng.gen()), + blob: ICBlobId::new(rng.gen()), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(context_id.as_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&context_sk, create_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Context creation"); + + // Add Bob as a member before granting capabilities + let add_member_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::AddMembers { + members: vec![bob_id.clone()], + }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&alice_sk, add_member_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Member addition"); + + // Grant capabilities to Bob + let grant_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Grant { + capabilities: vec![(bob_id.clone(), ICCapability::ManageMembers)], + }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&alice_sk, grant_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Capability granting"); + + // Verify Bob's capabilities + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "privileges", + candid::encode_one((context_id.clone(), vec![bob_id.clone()])).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let privileges: std::collections::BTreeMap> = + candid::decode_one(&bytes).unwrap(); + let bob_capabilities = privileges + .get(&ICSignerId::new(bob_pk.to_bytes())) + .expect("Bob should have capabilities"); + assert_eq!(bob_capabilities, &[ICCapability::ManageMembers]); + } + + // Revoke Bob's capabilities + let revoke_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Revoke { + capabilities: vec![(bob_id.clone(), ICCapability::ManageMembers)], + }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&alice_sk, revoke_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Capability revoking"); + + // Verify Bob's capabilities are gone + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "privileges", + candid::encode_one((context_id.clone(), vec![bob_id.clone()])).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let privileges: std::collections::BTreeMap> = + candid::decode_one(&bytes).unwrap(); + assert!( + privileges + .get(&ICSignerId::new(bob_pk.to_bytes())) + .is_none() + || privileges + .get(&ICSignerId::new(bob_pk.to_bytes())) + .unwrap() + .is_empty() + ); + } +} + +#[test] +fn test_application_update() { + let (pic, canister) = setup(); + let mut rng = rand::thread_rng(); + + // Advance IC time + let current_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + pic.advance_time(Duration::from_nanos(current_nanos)); + + // Create test identities + let context_sk = SigningKey::from_bytes(&rng.gen()); + let context_pk = context_sk.verifying_key(); + let context_id = ICContextId::new(context_pk.to_bytes()); + + let alice_sk = SigningKey::from_bytes(&rng.gen()); + let alice_pk = alice_sk.verifying_key(); + let alice_id = ICContextIdentity::new(alice_pk.to_bytes()); + + let bob_sk = SigningKey::from_bytes(&rng.gen()); + let bob_pk = bob_sk.verifying_key(); + // let bob_id = ICContextIdentity::new(bob_pk.to_bytes()); + + // Initial application IDs + let initial_app_id = ICApplicationId::new(rng.gen()); + let initial_blob_id = ICBlobId::new(rng.gen()); + + // Create context with initial application + let create_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Add { + author_id: alice_id.clone(), + application: ICApplication { + id: initial_app_id.clone(), + blob: initial_blob_id.clone(), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(context_id.as_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&context_sk, create_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Context creation"); + + // Verify initial application state + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "application", + candid::encode_one(context_id.clone()).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let app: ICApplication = candid::decode_one(&bytes).unwrap(); + assert_eq!(app.id, initial_app_id); + assert_eq!(app.blob, initial_blob_id); + } + + // Check initial application revision + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "application_revision", + candid::encode_one(context_id.clone()).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let revision: u64 = candid::decode_one(&bytes).unwrap(); + assert_eq!(revision, 0, "Initial application revision should be 0"); + } + + // Try unauthorized application update (Bob) + let new_app_id = ICApplicationId::new(rng.gen()); + let new_blob_id = ICBlobId::new(rng.gen()); + + let update_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::UpdateApplication { + application: ICApplication { + id: new_app_id.clone(), + blob: new_blob_id.clone(), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(bob_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&bob_sk, update_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result<(), String> = candid::decode_one(&bytes).unwrap(); + assert!( + result.is_err(), + "Unauthorized application update should fail" + ); + } + Ok(_) => panic!("Expected Reply variant"), + Err(err) => panic!("Unexpected error: {}", err), + } + + // Verify application hasn't changed + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "application", + candid::encode_one(context_id.clone()).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let app: ICApplication = candid::decode_one(&bytes).unwrap(); + assert_eq!(app.id, initial_app_id); + assert_eq!(app.blob, initial_blob_id); + } + + // Authorized application update (Alice) + let update_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::UpdateApplication { + application: ICApplication { + id: new_app_id.clone(), + blob: new_blob_id.clone(), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&alice_sk, update_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Authorized update"); + + // Verify application has been updated + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "application", + candid::encode_one(context_id.clone()).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let app: ICApplication = candid::decode_one(&bytes).unwrap(); + assert_eq!(app.id, new_app_id); + assert_eq!(app.blob, new_blob_id); + } + + // Check final application revision + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "application_revision", + candid::encode_one(context_id.clone()).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let revision: u64 = candid::decode_one(&bytes).unwrap(); + assert_eq!(revision, 1, "Application revision should be 1 after update"); + } +} + +#[test] +fn test_edge_cases() { + let (pic, canister) = setup(); + let mut rng = rand::thread_rng(); + + // Setup context and identities + let context_sk = SigningKey::from_bytes(&rng.gen()); + let context_pk = context_sk.verifying_key(); + let context_id = ICContextId::new(context_pk.to_bytes()); + let alice_sk = SigningKey::from_bytes(&rng.gen()); + let alice_pk = alice_sk.verifying_key(); + let alice_id = ICContextIdentity::new(alice_pk.to_bytes()); + + // Create initial context + let create_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Add { + author_id: alice_id.clone(), + application: ICApplication { + id: ICApplicationId::new(rng.gen()), + blob: ICBlobId::new(rng.gen()), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(context_id.as_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&context_sk, create_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Context creation"); + + // Test 1: Adding empty member list + let add_empty_members = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::AddMembers { members: vec![] }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&alice_sk, add_empty_members); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Empty member list addition"); + + // Test 2: Adding duplicate members + let bob_id = ICContextIdentity::new(rng.gen()); + let add_duplicate_members = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::AddMembers { + members: vec![bob_id.clone(), bob_id.clone()], + }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&alice_sk, add_duplicate_members); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Duplicate member addition"); + + // Verify only one instance was added + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "members", + candid::encode_one((context_id.clone(), 0_usize, 10_usize)).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let members: Vec = candid::decode_one(&bytes).unwrap(); + assert_eq!( + members.iter().filter(|&m| m == &bob_id).count(), + 1, + "Member should only appear once" + ); + } +} + +#[test] +fn test_timestamp_scenarios() { + let (pic, canister) = setup(); + let mut rng = rand::thread_rng(); + + // Setup initial context + let context_sk = SigningKey::from_bytes(&rng.gen()); + let context_pk = context_sk.verifying_key(); + let context_id = ICContextId::new(context_pk.to_bytes()); + let alice_sk = SigningKey::from_bytes(&rng.gen()); + let alice_pk = alice_sk.verifying_key(); + let alice_id = ICContextIdentity::new(alice_pk.to_bytes()); + + // Create initial context with current timestamp + let current_time = get_time_nanos(&pic); + let create_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Add { + author_id: alice_id.clone(), + application: ICApplication { + id: ICApplicationId::new(rng.gen()), + blob: ICBlobId::new(rng.gen()), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(context_id.as_bytes()), + timestamp_ms: current_time, + }; + + let signed_request = create_signed_request(&context_sk, create_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, true, "Context creation"); + + // Try with expired timestamp (more than 5 seconds old) + let expired_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::AddMembers { + members: vec![ICContextIdentity::new(rng.gen())], + }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: current_time - 6_000_000_000, // 6 seconds ago + }; + + let signed_request = create_signed_request(&alice_sk, expired_request); + let response = pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + handle_response(response, false, "Expired timestamp request"); +} + +#[test] +fn test_concurrent_operations() { + let (pic, canister) = setup(); + let mut rng = rand::thread_rng(); + + // Setup initial context + let context_sk = SigningKey::from_bytes(&rng.gen()); + let context_pk = context_sk.verifying_key(); + let context_id = ICContextId::new(context_pk.to_bytes()); + let alice_sk = SigningKey::from_bytes(&rng.gen()); + let alice_pk = alice_sk.verifying_key(); + let alice_id = ICContextIdentity::new(alice_pk.to_bytes()); + + // Create initial context + let create_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Add { + author_id: alice_id.clone(), + application: ICApplication { + id: ICApplicationId::new(rng.gen()), + blob: ICBlobId::new(rng.gen()), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(context_id.as_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_request = create_signed_request(&context_sk, create_request); + pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ) + .expect("Context creation should succeed"); + + // Create multiple member additions with same timestamp + let timestamp = get_time_nanos(&pic); + let mut requests = Vec::new(); + for _ in 0..3 { + let new_member = ICContextIdentity::new(rng.gen()); + let request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::AddMembers { + members: vec![new_member], + }, + }), + signer_id: ICSignerId::new(alice_pk.to_bytes()), + timestamp_ms: timestamp, + }; + requests.push(create_signed_request(&alice_sk, request)); + } + + // Submit requests "concurrently" + let responses: Vec<_> = requests + .into_iter() + .map(|signed_request| { + pic.update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ) + }) + .collect(); + + // Verify all operations succeeded + assert!( + responses.iter().all(|r| r.is_ok()), + "All concurrent operations should succeed" + ); + + // Verify final state + let query_response = pic.query_call( + canister, + Principal::anonymous(), + "members", + candid::encode_one((context_id.clone(), 0_usize, 10_usize)).unwrap(), + ); + + if let Ok(WasmResult::Reply(bytes)) = query_response { + let members: Vec = candid::decode_one(&bytes).unwrap(); + assert_eq!( + members.len(), + 4, + "Should have all members (Alice + 3 new members)" + ); + } +} diff --git a/contracts/icp/proxy-contract/.gitignore b/contracts/icp/proxy-contract/.gitignore new file mode 100644 index 000000000..0730dc676 --- /dev/null +++ b/contracts/icp/proxy-contract/.gitignore @@ -0,0 +1,28 @@ +# Various IDEs and Editors +.vscode/ +.idea/ +**/*~ + +# Mac OSX temporary files +.DS_Store +**/.DS_Store + +# dfx temporary files +.dfx/ + +# generated files +**/declarations/ + +# rust +target/ + +# frontend code +node_modules/ +dist/ +.svelte-kit/ + +# environment variables +.env + +# testing library +pocket-ic diff --git a/contracts/icp/proxy-contract/Cargo.toml b/contracts/icp/proxy-contract/Cargo.toml new file mode 100644 index 000000000..a851e8d8d --- /dev/null +++ b/contracts/icp/proxy-contract/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "proxy_contract" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +bs58.workspace = true +calimero-context-config = { path = "../../../crates/context/config" } +candid = { version = "0.10", features = ["value"] } +ed25519-dalek.workspace = true +hex.workspace = true +ic-cdk = "0.16" +ic-cdk-macros = "0.16" +ic-ledger-types = "0.14.0" +serde = { version = "1.0", features = ["derive"] } +thiserror.workspace = true + +[dev-dependencies] +pocket-ic = "6.0.0" +rand = "0.8" + \ No newline at end of file diff --git a/contracts/icp/proxy-contract/build.sh b/contracts/icp/proxy-contract/build.sh new file mode 100755 index 000000000..efa237d9c --- /dev/null +++ b/contracts/icp/proxy-contract/build.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +cd "$(dirname $0)" + +TARGET="${CARGO_TARGET_DIR:-../../../target}" + +rustup target add wasm32-unknown-unknown + +cargo build --target wasm32-unknown-unknown --profile app-release + +mkdir -p res + +cp $TARGET/wasm32-unknown-unknown/app-release/proxy_contract.wasm ./res/ + +if command -v wasm-opt > /dev/null; then + wasm-opt -Oz ./res/proxy_contract.wasm -o ./res/proxy_contract.wasm +fi diff --git a/contracts/icp/proxy-contract/build_contracts.sh b/contracts/icp/proxy-contract/build_contracts.sh new file mode 100755 index 000000000..a8d9cecd8 --- /dev/null +++ b/contracts/icp/proxy-contract/build_contracts.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +cd "$(dirname $0)" + +echo "Building proxy contract..." +./build.sh + +echo "Building mock ledger contract..." +./mock/ledger/build.sh + +echo "Building mock external contract..." +./mock/external/build.sh + +echo "Building context-config contract..." +../context-config/build.sh diff --git a/contracts/icp/proxy-contract/dfx.json b/contracts/icp/proxy-contract/dfx.json new file mode 100644 index 000000000..6f1cf6bfb --- /dev/null +++ b/contracts/icp/proxy-contract/dfx.json @@ -0,0 +1,29 @@ +{ + "canisters": { + "proxy_contract": { + "candid": "proxy_contract.did", + "package": "proxy_contract", + "type": "rust" + }, + "mock_ledger": { + "type": "rust", + "package": "mock_ledger", + "candid": "mock/ledger/ledger.did", + "path": "mock/ledger" + }, + "mock_external": { + "type": "rust", + "package": "mock_external", + "candid": "mock/external/external.did", + "path": "mock/external" + } + }, + "defaults": { + "build": { + "args": "", + "packtool": "" + } + }, + "output_env_file": ".env", + "version": 1 +} \ No newline at end of file diff --git a/contracts/icp/proxy-contract/mock/external/Cargo.toml b/contracts/icp/proxy-contract/mock/external/Cargo.toml new file mode 100644 index 000000000..4b14dd556 --- /dev/null +++ b/contracts/icp/proxy-contract/mock/external/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mock_external" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +candid = "0.10" +ic-cdk = "0.16" +ic-cdk-macros = "0.16" \ No newline at end of file diff --git a/contracts/icp/proxy-contract/mock/external/build.sh b/contracts/icp/proxy-contract/mock/external/build.sh new file mode 100755 index 000000000..cbf636c21 --- /dev/null +++ b/contracts/icp/proxy-contract/mock/external/build.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +cd "$(dirname $0)" + +TARGET="${CARGO_TARGET_DIR:-../../../../../target}" + +rustup target add wasm32-unknown-unknown + +cargo build --target wasm32-unknown-unknown --profile app-release + +mkdir -p res + +cp $TARGET/wasm32-unknown-unknown/app-release/mock_external.wasm ./res/ + +if command -v wasm-opt > /dev/null; then + wasm-opt -Oz ./res/mock_external.wasm -o ./res/mock_external.wasm +fi \ No newline at end of file diff --git a/contracts/icp/proxy-contract/mock/external/mock_external.did b/contracts/icp/proxy-contract/mock/external/mock_external.did new file mode 100644 index 000000000..608f976b7 --- /dev/null +++ b/contracts/icp/proxy-contract/mock/external/mock_external.did @@ -0,0 +1 @@ +service : { get_calls : () -> (vec blob) query; test_method : (blob) -> () } diff --git a/contracts/icp/proxy-contract/mock/external/src/lib.rs b/contracts/icp/proxy-contract/mock/external/src/lib.rs new file mode 100644 index 000000000..ca10b9152 --- /dev/null +++ b/contracts/icp/proxy-contract/mock/external/src/lib.rs @@ -0,0 +1,20 @@ +use std::cell::RefCell; + +thread_local! { + static CALLS: RefCell>> = RefCell::new(Vec::new()); +} + +#[ic_cdk::update] +fn test_method(args: Vec) -> Vec { + CALLS.with(|calls| { + calls.borrow_mut().push(args.clone()); + args // Return the same args back + }) +} + +#[ic_cdk::query] +fn get_calls() -> Vec> { + CALLS.with(|calls| calls.borrow().clone()) +} + +ic_cdk::export_candid!(); diff --git a/contracts/icp/proxy-contract/mock/ledger/Cargo.toml b/contracts/icp/proxy-contract/mock/ledger/Cargo.toml new file mode 100644 index 000000000..3736d2fff --- /dev/null +++ b/contracts/icp/proxy-contract/mock/ledger/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "mock_ledger" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +candid = "0.10" +serde = { version = "1.0", features = ["derive"] } +ic-cdk = "0.16" +ic-cdk-macros = "0.16" +ic-ledger-types = "0.14.0" diff --git a/contracts/icp/proxy-contract/mock/ledger/build.sh b/contracts/icp/proxy-contract/mock/ledger/build.sh new file mode 100755 index 000000000..d5e056364 --- /dev/null +++ b/contracts/icp/proxy-contract/mock/ledger/build.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +cd "$(dirname $0)" + +TARGET="${CARGO_TARGET_DIR:-../../../../../target}" + +rustup target add wasm32-unknown-unknown + +cargo build --target wasm32-unknown-unknown --profile app-release + +mkdir -p res + +cp $TARGET/wasm32-unknown-unknown/app-release/mock_ledger.wasm ./res/ + +if command -v wasm-opt > /dev/null; then + wasm-opt -Oz ./res/mock_ledger.wasm -o ./res/mock_ledger.wasm +fi diff --git a/contracts/icp/proxy-contract/mock/ledger/mock_ledger.did b/contracts/icp/proxy-contract/mock/ledger/mock_ledger.did new file mode 100644 index 000000000..20313456b --- /dev/null +++ b/contracts/icp/proxy-contract/mock/ledger/mock_ledger.did @@ -0,0 +1 @@ +service : { balance : () -> (nat) query; transfer : (blob) -> () } diff --git a/contracts/icp/proxy-contract/mock/ledger/src/lib.rs b/contracts/icp/proxy-contract/mock/ledger/src/lib.rs new file mode 100644 index 000000000..ca175156e --- /dev/null +++ b/contracts/icp/proxy-contract/mock/ledger/src/lib.rs @@ -0,0 +1,60 @@ +use std::cell::RefCell; + +use candid::{CandidType, Deserialize}; +use ic_ledger_types::{AccountIdentifier, BlockIndex, Tokens, TransferArgs, TransferError}; + +thread_local! { + static BALANCE: RefCell = RefCell::new(1_000_000_000); +} + +type TransferResult = Result; + +#[ic_cdk::update] +fn transfer(args: TransferArgs) -> TransferResult { + ic_cdk::println!( + "Mock ledger received transfer: to={:?}, amount={}", + args.to, + args.amount + ); + + // Verify fee + if args.fee.e8s() != 10_000 { + return Err(TransferError::BadFee { + expected_fee: Tokens::from_e8s(10_000), + }); + } + + let amount_e8s = args.amount.e8s(); + + BALANCE.with(|balance| { + let mut bal = balance.borrow_mut(); + + // Check if we have enough balance + if amount_e8s > *bal { + return Err(TransferError::InsufficientFunds { + balance: Tokens::from_e8s(*bal), + }); + } + + // Subtract amount and fee + *bal = bal.saturating_sub(amount_e8s); + *bal = bal.saturating_sub(args.fee.e8s()); + + ic_cdk::println!("New balance: {}", *bal); + + // Return mock block index + Ok(1) + }) +} + +#[ic_cdk::query] +fn account_balance(args: AccountBalanceArgs) -> Tokens { + BALANCE.with(|balance| Tokens::from_e8s(*balance.borrow())) +} + +#[derive(CandidType, Deserialize)] +struct AccountBalanceArgs { + account: AccountIdentifier, +} + +ic_cdk::export_candid!(); diff --git a/contracts/icp/proxy-contract/proxy_contract.did b/contracts/icp/proxy-contract/proxy_contract.did new file mode 100644 index 000000000..fe3387d2d --- /dev/null +++ b/contracts/icp/proxy-contract/proxy_contract.did @@ -0,0 +1,42 @@ +type ICPSigned = record { signature : blob; _phantom : null; payload : blob }; +type ICProposal = record { + id : blob; + actions : vec ICProposalAction; + author_id : blob; +}; +type ICProposalAction = variant { + SetNumApprovals : record { num_approvals : nat32 }; + SetContextValue : record { key : blob; value : blob }; + Transfer : record { receiver_id : principal; amount : nat }; + SetActiveProposalsLimit : record { active_proposals_limit : nat32 }; + ExternalFunctionCall : record { + receiver_id : principal; + args : text; + deposit : nat; + method_name : text; + }; +}; +type ICProposalApprovalWithSigner = record { + added_timestamp : nat64; + signer_id : blob; + proposal_id : blob; +}; +type ICProposalWithApprovals = record { + num_approvals : nat64; + proposal_id : blob; +}; +type Result = variant { Ok : opt ICProposalWithApprovals; Err : text }; +service : (blob, principal) -> { + context_storage_entries : (nat64, nat64) -> (vec record { blob; blob }) query; + get_active_proposals_limit : () -> (nat32) query; + get_confirmations_count : (blob) -> (opt ICProposalWithApprovals) query; + get_context_value : (blob) -> (opt blob) query; + get_num_approvals : () -> (nat32) query; + get_proposal_approvals_with_signer : (blob) -> ( + vec ICProposalApprovalWithSigner, + ) query; + get_proposal_approvers : (blob) -> (opt vec blob) query; + mutate : (ICPSigned) -> (Result); + proposal : (blob) -> (opt ICProposal) query; + proposals : (nat64, nat64) -> (vec ICProposal) query; +} diff --git a/contracts/icp/proxy-contract/src/lib.rs b/contracts/icp/proxy-contract/src/lib.rs new file mode 100644 index 000000000..4904704b2 --- /dev/null +++ b/contracts/icp/proxy-contract/src/lib.rs @@ -0,0 +1,58 @@ +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use candid::{CandidType, Principal}; +use serde::Deserialize; +use types::{ICContextId, LedgerId}; + +use crate::types::{ + ICPSigned, ICProposal, ICProposalApprovalWithSigner, ICProposalId, ICProposalWithApprovals, + ICRequest, ICSignerId, +}; + +pub mod mutate; +pub mod query; +pub mod sys; +pub mod types; + +#[derive(Default, CandidType, Deserialize, Clone)] +pub struct ICProxyContract { + pub context_id: ICContextId, + pub context_config_id: String, + pub num_approvals: u32, + pub proposals: BTreeMap, + pub approvals: BTreeMap>, + pub num_proposals_pk: BTreeMap, + pub active_proposals_limit: u32, + pub context_storage: HashMap, Vec>, + pub ledger_id: LedgerId, +} + +impl ICProxyContract { + pub fn new(context_id: ICContextId, ledger_id: Principal) -> Self { + Self { + context_id, + context_config_id: ic_cdk::caller().to_string(), + num_approvals: 3, + proposals: BTreeMap::new(), + approvals: BTreeMap::new(), + num_proposals_pk: BTreeMap::new(), + active_proposals_limit: 10, + context_storage: HashMap::new(), + ledger_id: ledger_id.into(), + } + } +} + +thread_local! { + static PROXY_CONTRACT: RefCell = RefCell::new(ICProxyContract::default()); +} + +#[ic_cdk::init] +fn init(context_id: types::ICContextId, ledger_id: Principal) { + PROXY_CONTRACT.with(|contract| { + *contract.borrow_mut() = ICProxyContract::new(context_id, ledger_id); + }); +} + +ic_cdk::export_candid!(); diff --git a/contracts/icp/proxy-contract/src/mutate.rs b/contracts/icp/proxy-contract/src/mutate.rs new file mode 100644 index 000000000..eaa267eb5 --- /dev/null +++ b/contracts/icp/proxy-contract/src/mutate.rs @@ -0,0 +1,286 @@ +use std::collections::BTreeSet; + +use calimero_context_config::repr::ReprTransmute; +use candid::Principal; +use ic_cdk::api::call::CallResult; +use ic_ledger_types::{AccountIdentifier, Memo, Subaccount, Tokens, TransferArgs, TransferError}; + +use crate::types::*; +use crate::{ICProxyContract, PROXY_CONTRACT}; + +async fn check_member(signer_id: &ICSignerId) -> Result { + let (context_canister_id, context_id) = PROXY_CONTRACT.with(|contract| { + ( + contract.borrow().context_config_id.clone(), + contract.borrow().context_id.clone(), + ) + }); + + let identity = ICContextIdentity::new(signer_id.rt().expect("Invalid signer id")); + + let call_result: CallResult<(bool,)> = ic_cdk::call( + Principal::from_text(&context_canister_id) + .map_err(|e| format!("Invalid context canister ID: {}", e))?, + "has_member", + (context_id, identity), + ) + .await; + + match call_result { + Ok((is_member,)) => Ok(is_member), + Err(e) => Err(format!("Error checking membership: {:?}", e)), + } +} + +#[ic_cdk::update] +async fn mutate( + signed_request: ICPSigned, +) -> Result, String> { + let request = signed_request + .parse(|r| r.signer_id) + .map_err(|e| format!("Failed to verify signature: {}", e))?; + + // Check request timestamp + let current_time = ic_cdk::api::time(); + if current_time.saturating_sub(request.timestamp_ms) > 1000 * 5 { + return Err("request expired".to_string()); + } + + // Check membership + if !check_member(&request.signer_id).await? { + return Err("signer is not a member".to_string()); + } + + match request.kind { + ICRequestKind::Propose { proposal } => internal_create_proposal(proposal), + ICRequestKind::Approve { approval } => { + internal_approve_proposal( + approval.signer_id, + approval.proposal_id, + approval.added_timestamp, + ) + .await + } + } +} + +async fn internal_approve_proposal( + signer_id: ICSignerId, + proposal_id: ICProposalId, + _added_timestamp: u64, +) -> Result, String> { + // First phase: Update approvals and check if we need to execute + let should_execute = PROXY_CONTRACT.with(|contract| { + let mut contract = contract.borrow_mut(); + + // Check if proposal exists + if !contract.proposals.contains_key(&proposal_id) { + return Err("proposal does not exist".to_string()); + } + + let approvals = contract.approvals.entry(proposal_id).or_default(); + + if !approvals.insert(signer_id) { + return Err("proposal already approved".to_string()); + } + + Ok(approvals.len() as u32 >= contract.num_approvals) + })?; + + // Execute if needed + if should_execute { + execute_proposal(&proposal_id).await?; + } + + // Build final response + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + build_proposal_response(&*contract, proposal_id) + }) +} + +async fn execute_proposal(proposal_id: &ICProposalId) -> Result<(), String> { + let proposal = PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + contract + .proposals + .get(proposal_id) + .cloned() + .ok_or_else(|| "proposal does not exist".to_string()) + })?; + + // Execute each action + for action in proposal.actions { + match action { + ICProposalAction::ExternalFunctionCall { + receiver_id, + method_name, + args, + deposit: _, + } => { + let args_bytes = candid::encode_one(args) + .map_err(|e| format!("Failed to encode args: {}", e))?; + + let _: () = ic_cdk::call(receiver_id, method_name.as_str(), (args_bytes,)) + .await + .map_err(|e| format!("Inter-canister call failed: {:?}", e))?; + } + ICProposalAction::Transfer { + receiver_id, + amount, + } => { + let ledger_id = PROXY_CONTRACT.with(|contract| contract.borrow().ledger_id.clone()); + + let transfer_args = TransferArgs { + memo: Memo(0), + amount: Tokens::from_e8s( + amount + .try_into() + .map_err(|e| format!("Amount conversion error: {}", e))?, + ), + fee: Tokens::from_e8s(10_000), // Standard fee is 0.0001 ICP + from_subaccount: None, + to: AccountIdentifier::new(&receiver_id, &Subaccount([0; 32])), + created_at_time: None, + }; + + let _: (Result,) = + ic_cdk::call(Principal::from(ledger_id), "transfer", (transfer_args,)) + .await + .map_err(|e| format!("Transfer failed: {:?}", e))?; + } + ICProposalAction::SetNumApprovals { num_approvals } => { + PROXY_CONTRACT.with(|contract| { + let mut contract = contract.borrow_mut(); + contract.num_approvals = num_approvals; + }); + } + ICProposalAction::SetActiveProposalsLimit { + active_proposals_limit, + } => { + PROXY_CONTRACT.with(|contract| { + let mut contract = contract.borrow_mut(); + contract.active_proposals_limit = active_proposals_limit; + }); + } + ICProposalAction::SetContextValue { key, value } => { + PROXY_CONTRACT.with(|contract| { + let mut contract = contract.borrow_mut(); + contract.context_storage.insert(key.clone(), value.clone()); + }); + } + } + } + + remove_proposal(proposal_id); + Ok(()) +} + +fn internal_create_proposal( + proposal: ICProposal, +) -> Result, String> { + if proposal.actions.is_empty() { + return Err("proposal cannot have empty actions".to_string()); + } + + PROXY_CONTRACT.with(|contract| { + let mut contract = contract.borrow_mut(); + + let num_proposals = contract + .num_proposals_pk + .get(&proposal.author_id) + .copied() + .unwrap_or(0); + + // Check proposal limit + if num_proposals >= contract.active_proposals_limit { + return Err( + "Account has too many active proposals. Confirm or delete some.".to_string(), + ); + } + + // Validate proposal actions + for action in &proposal.actions { + validate_proposal_action(action)?; + } + + // Store proposal + let proposal_id = proposal.id; + let author_id = proposal.author_id; + contract.proposals.insert(proposal_id, proposal); + + // Initialize approvals set with author's approval + let approvals = BTreeSet::from([author_id]); + contract.approvals.insert(proposal_id, approvals); + + // Update proposal count + *contract.num_proposals_pk.entry(author_id).or_insert(0) += 1; + + build_proposal_response(&*contract, proposal_id) + }) +} + +fn validate_proposal_action(action: &ICProposalAction) -> Result<(), String> { + match action { + ICProposalAction::ExternalFunctionCall { + receiver_id: _, + method_name, + args: _, + deposit: _, + } => { + if method_name.is_empty() { + return Err("method name cannot be empty".to_string()); + } + } + ICProposalAction::Transfer { + receiver_id: _, + amount, + } => { + if *amount == 0 { + return Err("transfer amount cannot be zero".to_string()); + } + } + ICProposalAction::SetNumApprovals { num_approvals } => { + if *num_approvals == 0 { + return Err("num approvals cannot be zero".to_string()); + } + } + ICProposalAction::SetActiveProposalsLimit { + active_proposals_limit, + } => { + if *active_proposals_limit == 0 { + return Err("active proposals limit cannot be zero".to_string()); + } + } + ICProposalAction::SetContextValue { .. } => {} + } + Ok(()) +} + +fn remove_proposal(proposal_id: &ICProposalId) { + PROXY_CONTRACT.with(|contract| { + let mut contract = contract.borrow_mut(); + contract.approvals.remove(&proposal_id); + if let Some(proposal) = contract.proposals.remove(&proposal_id) { + let author_id = proposal.author_id; + if let Some(count) = contract.num_proposals_pk.get_mut(&author_id) { + *count = count.saturating_sub(1); + if *count == 0 { + contract.num_proposals_pk.remove(&author_id); + } + } + } + }); +} + +fn build_proposal_response( + contract: &ICProxyContract, + proposal_id: ICProposalId, +) -> Result, String> { + let approvals = contract.approvals.get(&proposal_id); + + Ok(approvals.map(|approvals| ICProposalWithApprovals { + proposal_id, + num_approvals: approvals.len(), + })) +} diff --git a/contracts/icp/proxy-contract/src/query.rs b/contracts/icp/proxy-contract/src/query.rs new file mode 100644 index 000000000..c9e4eaba6 --- /dev/null +++ b/contracts/icp/proxy-contract/src/query.rs @@ -0,0 +1,111 @@ +use crate::types::*; +use crate::PROXY_CONTRACT; + +#[ic_cdk::query] +pub fn get_num_approvals() -> u32 { + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + contract.num_approvals + }) +} + +#[ic_cdk::query] +pub fn get_active_proposals_limit() -> u32 { + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + contract.active_proposals_limit + }) +} + +#[ic_cdk::query] +pub fn proposal(proposal_id: ICProposalId) -> Option { + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + contract.proposals.get(&proposal_id).cloned() + }) +} + +#[ic_cdk::query] +pub fn proposals(from_index: usize, limit: usize) -> Vec { + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + contract + .proposals + .values() + .skip(from_index) + .take(limit) + .cloned() + .collect() + }) +} + +#[ic_cdk::query] +pub fn get_confirmations_count(proposal_id: ICProposalId) -> Option { + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + contract.proposals.get(&proposal_id).map(|_| { + let num_approvals = contract + .approvals + .get(&proposal_id) + .map_or(0, |approvals| approvals.len()); + ICProposalWithApprovals { + proposal_id, + num_approvals, + } + }) + }) +} + +#[ic_cdk::query] +pub fn get_proposal_approvers(proposal_id: ICProposalId) -> Option> { + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + contract + .approvals + .get(&proposal_id) + .map(|approvals| approvals.iter().cloned().collect()) + }) +} + +#[ic_cdk::query] +pub fn get_proposal_approvals_with_signer( + proposal_id: ICProposalId, +) -> Vec { + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + if let Some(approvals) = contract.approvals.get(&proposal_id) { + approvals + .iter() + .map(|signer_id| ICProposalApprovalWithSigner { + proposal_id: proposal_id.clone(), + signer_id: signer_id.clone(), + added_timestamp: 0, // TODO: We need to store approval timestamps + }) + .collect() + } else { + vec![] + } + }) +} + +#[ic_cdk::query] +pub fn get_context_value(key: Vec) -> Option> { + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + contract.context_storage.get(&key).cloned() + }) +} + +#[ic_cdk::query] +pub fn context_storage_entries(from_index: usize, limit: usize) -> Vec<(Vec, Vec)> { + PROXY_CONTRACT.with(|contract| { + let contract = contract.borrow(); + contract + .context_storage + .iter() + .skip(from_index) + .take(limit) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) +} diff --git a/contracts/icp/proxy-contract/src/sys.rs b/contracts/icp/proxy-contract/src/sys.rs new file mode 100644 index 000000000..dcc6d2de5 --- /dev/null +++ b/contracts/icp/proxy-contract/src/sys.rs @@ -0,0 +1,47 @@ +use candid::{CandidType, Deserialize, Principal}; +use ic_cdk; + +use crate::{ICProxyContract, PROXY_CONTRACT}; + +#[derive(CandidType, Deserialize)] +struct StableStorage { + proxy_contract: ICProxyContract, +} + +#[ic_cdk::pre_upgrade] +fn pre_upgrade() { + // Verify caller is the context contract that created this proxy + let caller = ic_cdk::caller(); + let context_canister = PROXY_CONTRACT.with(|contract| { + Principal::from_text(&contract.borrow().context_config_id) + .expect("Invalid context canister ID") + }); + + if caller != context_canister { + ic_cdk::trap("unauthorized: only context contract can upgrade proxy"); + } + + // Store the contract state + let state = PROXY_CONTRACT.with(|contract| StableStorage { + proxy_contract: contract.borrow().clone(), + }); + + // Write state to stable storage + match ic_cdk::storage::stable_save((state,)) { + Ok(_) => (), + Err(err) => ic_cdk::trap(&format!("Failed to save stable storage: {}", err)), + } +} + +#[ic_cdk::post_upgrade] +fn post_upgrade() { + // Restore the contract state + match ic_cdk::storage::stable_restore::<(StableStorage,)>() { + Ok((state,)) => { + PROXY_CONTRACT.with(|contract| { + *contract.borrow_mut() = state.proxy_contract; + }); + } + Err(err) => ic_cdk::trap(&format!("Failed to restore stable storage: {}", err)), + } +} diff --git a/contracts/icp/proxy-contract/src/types.rs b/contracts/icp/proxy-contract/src/types.rs new file mode 100644 index 000000000..ca9d9bf97 --- /dev/null +++ b/contracts/icp/proxy-contract/src/types.rs @@ -0,0 +1,327 @@ +use std::marker::PhantomData; + +use bs58::decode::Result as Bs58Result; +use calimero_context_config::repr; +use calimero_context_config::repr::{LengthMismatch, ReprBytes, ReprTransmute}; +use calimero_context_config::types::IntoResult; +use candid::{CandidType, Principal}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error as ThisError; + +/// Base identity type +#[derive( + CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Hash, +)] +pub struct Identity([u8; 32]); + +impl Identity { + pub fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn as_bytes(&self) -> [u8; 32] { + self.0 + } + + pub fn as_slice(&self) -> &[u8] { + &self.0[..] + } +} + +impl Default for Identity { + fn default() -> Self { + Self([0; 32]) + } +} + +impl ReprBytes for Identity { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0 + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Self::DecodeBytes::from_bytes(f).map(Self) + } +} + +#[derive( + CandidType, Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq, Copy, Ord, PartialOrd, +)] +pub struct ICSignerId(Identity); + +impl ICSignerId { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Identity(bytes)) + } +} + +impl Default for ICSignerId { + fn default() -> Self { + Self(Identity::default()) + } +} + +impl ReprBytes for ICSignerId { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0.as_bytes() + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Identity::from_bytes(f).map(Self) + } +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] +pub struct ICContextId(Identity); + +impl ICContextId { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Identity(bytes)) + } +} + +impl Default for ICContextId { + fn default() -> Self { + Self(Identity::default()) + } +} + +impl ReprBytes for ICContextId { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0.as_bytes() + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Identity::from_bytes(f).map(Self) + } +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] +pub struct ICContextIdentity(Identity); + +impl ICContextIdentity { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Identity(bytes)) + } +} + +impl ReprBytes for ICContextIdentity { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0.as_bytes() + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + Identity::from_bytes(f).map(Self) + } +} + +#[derive( + CandidType, Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct ICProposalId(pub [u8; 32]); + +impl ICProposalId { + pub fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq)] +pub enum ICProposalAction { + ExternalFunctionCall { + receiver_id: Principal, + method_name: String, + args: String, + deposit: u128, + }, + Transfer { + receiver_id: Principal, + amount: u128, + }, + SetNumApprovals { + num_approvals: u32, + }, + SetActiveProposalsLimit { + active_proposals_limit: u32, + }, + SetContextValue { + key: Vec, + value: Vec, + }, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct ICProposal { + pub id: ICProposalId, + pub author_id: ICSignerId, + pub actions: Vec, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct ICProposalWithApprovals { + pub proposal_id: ICProposalId, + pub num_approvals: usize, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct ICProposalApprovalWithSigner { + pub proposal_id: ICProposalId, + pub signer_id: ICSignerId, + pub added_timestamp: u64, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub enum ICRequestKind { + Propose { + proposal: ICProposal, + }, + Approve { + approval: ICProposalApprovalWithSigner, + }, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct ICRequest { + pub kind: ICRequestKind, + pub signer_id: ICSignerId, + pub timestamp_ms: u64, +} + +#[derive(CandidType, Deserialize, Debug, Clone)] +pub struct ICPSigned { + payload: Vec, + signature: Vec, + _phantom: Phantom, +} + +impl ICPSigned { + pub fn new(payload: T, sign: F) -> Result> + where + R: IntoResult, + F: FnOnce(&[u8]) -> R, + { + let bytes = candid::encode_one(payload) + .map_err(|e| ICPSignedError::SerializationError(e.to_string()))?; + + let signature = sign(&bytes) + .into_result() + .map_err(ICPSignedError::DerivationError)?; + + Ok(Self { + payload: bytes, + signature: signature.to_vec(), + _phantom: Phantom(PhantomData), + }) + } + + pub fn parse(&self, f: F) -> Result> + where + R: IntoResult, + F: FnOnce(&T) -> R, + { + let parsed: T = candid::decode_one(&self.payload) + .map_err(|e| ICPSignedError::DeserializationError(e.to_string()))?; + + let signer_id = f(&parsed) + .into_result() + .map_err(ICPSignedError::DerivationError)?; + + let key = signer_id + .rt::() + .map_err(|_| ICPSignedError::InvalidPublicKey)?; + + let signature_bytes: [u8; 64] = + self.signature.as_slice().try_into().map_err(|_| { + ICPSignedError::SignatureError(ed25519_dalek::ed25519::Error::new()) + })?; + let signature = ed25519_dalek::Signature::from_bytes(&signature_bytes); + + key.verify(&self.payload, &signature) + .map_err(|_| ICPSignedError::InvalidSignature)?; + + Ok(parsed) + } +} + +#[derive(Debug, ThisError)] +pub enum ICPSignedError { + #[error("invalid signature")] + InvalidSignature, + #[error("derivation error: {0}")] + DerivationError(E), + #[error("invalid public key")] + InvalidPublicKey, + #[error("signature error: {0}")] + SignatureError(#[from] ed25519_dalek::ed25519::Error), + #[error("serialization error: {0}")] + SerializationError(String), + #[error("deserialization error: {0}")] + DeserializationError(String), +} + +#[derive(Deserialize, Debug, Clone)] +struct Phantom(#[serde(skip)] std::marker::PhantomData); + +impl CandidType for Phantom { + fn _ty() -> candid::types::Type { + candid::types::TypeInner::Null.into() + } + + fn idl_serialize(&self, serializer: S) -> Result<(), S::Error> + where + S: candid::types::Serializer, + { + serializer.serialize_null(()) + } +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct LedgerId(Principal); + +impl Default for LedgerId { + fn default() -> Self { + Self(Principal::anonymous()) + } +} + +impl From for LedgerId { + fn from(p: Principal) -> Self { + Self(p) + } +} + +impl From for Principal { + fn from(id: LedgerId) -> Self { + id.0 + } +} diff --git a/contracts/icp/proxy-contract/tests/context_types.rs b/contracts/icp/proxy-contract/tests/context_types.rs new file mode 100644 index 000000000..ea7b964d4 --- /dev/null +++ b/contracts/icp/proxy-contract/tests/context_types.rs @@ -0,0 +1,60 @@ +use candid::CandidType; +use proxy_contract::types::{ICContextId, ICContextIdentity, ICSignerId}; +use serde::{Deserialize, Serialize}; + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub struct Request { + pub kind: RequestKind, + pub signer_id: ICSignerId, + pub timestamp_ms: u64, +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub enum RequestKind { + Context(ContextRequest), +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub struct ContextRequest { + pub context_id: ICContextId, + pub kind: ContextRequestKind, +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub enum ContextRequestKind { + Add { + author_id: ICContextIdentity, + application: ICApplication, + }, + AddMembers { + members: Vec, + }, + UpdateProxyContract, +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub struct ICApplication { + pub id: ICApplicationId, + pub blob: ICBlobId, + pub size: u64, + pub source: String, + pub metadata: Vec, +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ICApplicationId(pub [u8; 32]); + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ICBlobId(pub [u8; 32]); + +impl ICApplicationId { + pub fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +impl ICBlobId { + pub fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} diff --git a/contracts/icp/proxy-contract/tests/integration.rs b/contracts/icp/proxy-contract/tests/integration.rs new file mode 100644 index 000000000..0da011745 --- /dev/null +++ b/contracts/icp/proxy-contract/tests/integration.rs @@ -0,0 +1,1125 @@ +mod context_types; +use context_types::*; + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::time::UNIX_EPOCH; + + use calimero_context_config::repr::ReprBytes; + use candid::Principal; + use ed25519_dalek::{Signer, SigningKey}; + use ic_ledger_types::{AccountBalanceArgs, AccountIdentifier, Subaccount, Tokens}; + use pocket_ic::{PocketIc, WasmResult}; + use proxy_contract::types::{ + ICContextId, ICContextIdentity, ICPSigned, ICProposal, ICProposalAction, + ICProposalApprovalWithSigner, ICProposalId, ICProposalWithApprovals, ICRequest, + ICRequestKind, ICSignerId, + }; + use rand::Rng; + + use crate::{ + ContextRequest, ContextRequestKind, ICApplication, ICApplicationId, ICBlobId, Request, + RequestKind, + }; + + // Mock canister states + thread_local! { + static MOCK_LEDGER_BALANCE: RefCell = RefCell::new(1_000_000_000); + static MOCK_EXTERNAL_CALLS: RefCell)>> = RefCell::new(Vec::new()); + } + + fn create_signed_request(signer_key: &SigningKey, request: ICRequest) -> ICPSigned { + ICPSigned::new(request, |bytes| signer_key.sign(bytes)) + .expect("Failed to create signed request") + } + + fn create_signed_context_request( + signer_key: &SigningKey, + request: Request, + ) -> ICPSigned { + ICPSigned::new(request, |bytes| signer_key.sign(bytes)) + .expect("Failed to create signed request") + } + + fn get_time_nanos(pic: &PocketIc) -> u64 { + pic.get_time() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_nanos() as u64 + } + + // Helper function to create a proposal and verify response + fn create_and_verify_proposal( + pic: &PocketIc, + canister: Principal, + signer_sk: &SigningKey, + signer_id: &ICSignerId, + proposal: ICProposal, + ) -> Result { + let request = ICRequest { + signer_id: signer_id.clone(), + timestamp_ms: get_time_nanos(pic), + kind: ICRequestKind::Propose { + proposal: proposal.clone(), + }, + }; + + let signed_request = create_signed_request(signer_sk, request); + let response = pic + .update_call( + canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ) + .map_err(|e| format!("Failed to call canister: {}", e))?; + + match response { + WasmResult::Reply(bytes) => { + let result: Result, String> = + candid::decode_one(&bytes) + .map_err(|e| format!("Failed to decode response: {}", e))?; + + match result { + Ok(Some(proposal_with_approvals)) => Ok(proposal_with_approvals), + Ok(None) => Err("No proposal returned".to_string()), + Err(e) => Err(e), + } + } + WasmResult::Reject(msg) => Err(format!("Canister rejected the call: {}", msg)), + } + } + + struct ProxyTestContext { + pic: PocketIc, + proxy_canister: Principal, + context_canister: Principal, + mock_ledger: Principal, + mock_external: Principal, + author_sk: SigningKey, + context_id: ICContextId, + } + + fn setup() -> ProxyTestContext { + let pic = PocketIc::new(); + let mut rng = rand::thread_rng(); + + // Setup context contract first + let context_canister = pic.create_canister(); + pic.add_cycles(context_canister, 100_000_000_000_000_000); + let context_wasm = std::fs::read("../context-config/res/context_contract.wasm") + .expect("failed to read context wasm"); + pic.install_canister(context_canister, context_wasm, vec![], None); + + // Setup mock ledger + let mock_ledger = pic.create_canister(); + pic.add_cycles(mock_ledger, 100_000_000_000_000); + let mock_ledger_wasm = std::fs::read("mock/ledger/res/mock_ledger.wasm") + .expect("failed to read mock ledger wasm"); + pic.install_canister(mock_ledger, mock_ledger_wasm, vec![], None); + + // Set proxy code in context contract + set_proxy_code(&pic, context_canister, mock_ledger).expect("Failed to set proxy code"); + + // Setup mock external + let mock_external = pic.create_canister(); + pic.add_cycles(mock_external, 100_000_000_000_000); + let mock_external_wasm = std::fs::read("mock/external/res/mock_external.wasm") + .expect("failed to read mock external wasm"); + pic.install_canister(mock_external, mock_external_wasm, vec![], None); + + // Create initial author key + let author_sk = SigningKey::from_bytes(&rng.gen()); + + // Create context and get proxy canister + let (proxy_canister, context_id) = + create_context_with_proxy(&pic, context_canister, &author_sk) + .expect("Failed to create context and proxy"); + + ProxyTestContext { + pic, + proxy_canister, + context_canister, + mock_ledger, + mock_external, + author_sk, + context_id, + } + } + + // Helper function to set proxy code in context contract + fn set_proxy_code( + pic: &PocketIc, + context_canister: Principal, + ledger_id: Principal, + ) -> Result<(), String> { + // Read proxy contract wasm + let proxy_wasm = + std::fs::read("res/proxy_contract.wasm").expect("failed to read proxy wasm"); + + let response = pic.update_call( + context_canister, + Principal::anonymous(), + "set_proxy_code", + candid::encode_args((proxy_wasm, ledger_id)).unwrap(), + ); + + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result<(), String> = candid::decode_one(&bytes) + .map_err(|e| format!("Failed to decode response: {}", e))?; + result + } + Ok(WasmResult::Reject(msg)) => Err(format!("Setting proxy code rejected: {}", msg)), + Err(e) => Err(format!("Setting proxy code failed: {}", e)), + } + } + + // Helper function to create context and deploy proxy + fn create_context_with_proxy( + pic: &PocketIc, + context_canister: Principal, + author_sk: &SigningKey, + ) -> Result<(Principal, ICContextId), String> { + let mut rng = rand::thread_rng(); + + // Generate context ID + let context_sk = SigningKey::from_bytes(&rng.gen()); + let context_pk = context_sk.verifying_key(); + let context_id = ICContextId::new(context_pk.to_bytes()); + + // Create author identity + let author_pk = author_sk.verifying_key(); + let author_id = ICContextIdentity::new(author_pk.to_bytes()); + + // Create context with initial application + let create_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::Add { + author_id: author_id.clone(), + application: ICApplication { + id: ICApplicationId::new(rng.gen()), + blob: ICBlobId::new(rng.gen()), + size: 0, + source: String::new(), + metadata: vec![], + }, + }, + }), + signer_id: ICSignerId::new(context_id.as_bytes()), + timestamp_ms: get_time_nanos(pic), + }; + + let signed_request = create_signed_context_request(&context_sk, create_request); + let response = pic.update_call( + context_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + // Check if context creation succeeded + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result<(), String> = candid::decode_one(&bytes) + .map_err(|e| format!("Failed to decode response: {}", e))?; + result.map_err(|e| format!("Context creation failed: {}", e))?; + } + Ok(WasmResult::Reject(msg)) => { + return Err(format!("Context creation rejected: {}", msg)) + } + Err(e) => return Err(format!("Context creation failed: {}", e)), + } + + // Query for proxy canister ID + let query_response = pic.query_call( + context_canister, + Principal::anonymous(), + "proxy_contract", + candid::encode_one(context_id.clone()).unwrap(), + ); + + match query_response { + Ok(WasmResult::Reply(bytes)) => { + let proxy_canister: Principal = candid::decode_one(&bytes) + .map_err(|e| format!("Failed to decode proxy canister ID: {}", e))?; + Ok((proxy_canister, context_id)) + } + Ok(WasmResult::Reject(msg)) => Err(format!("Query rejected: {}", msg)), + Err(e) => Err(format!("Query failed: {}", e)), + } + } + + // Helper function to add members to context + fn add_members_to_context( + pic: &PocketIc, + context_canister: Principal, + context_id: &ICContextId, + author_sk: &SigningKey, + members: Vec, + ) -> Result<(), String> { + let author_pk = author_sk.verifying_key(); + let request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::AddMembers { members }, + }), + signer_id: ICSignerId::new(author_pk.to_bytes()), + timestamp_ms: get_time_nanos(pic), + }; + + let signed_request = create_signed_context_request(author_sk, request); + let response = pic.update_call( + context_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + match response { + Ok(WasmResult::Reply(bytes)) => { + candid::decode_one(&bytes).map_err(|e| format!("Failed to decode response: {}", e)) + } + Ok(WasmResult::Reject(msg)) => Err(format!("Adding members rejected: {}", msg)), + Err(e) => Err(format!("Adding members failed: {}", e)), + } + } + + #[test] + fn test_update_proxy_contract() { + let ProxyTestContext { + pic, + proxy_canister, + context_canister, + author_sk, + context_id, + .. + } = setup(); + + // First test: Try direct upgrade (should fail) + let proxy_wasm = + std::fs::read("res/proxy_contract.wasm").expect("failed to read proxy wasm"); + + let unauthorized_result = pic.upgrade_canister( + proxy_canister, + proxy_wasm.clone(), + candid::encode_one::>(vec![]).unwrap(), + Some(Principal::anonymous()), + ); + match unauthorized_result { + Ok(_) => panic!("Direct upgrade should fail"), + Err(e) => { + println!("Got expected unauthorized error: {:?}", e); + } + } + + // Now continue with the rest of the test (authorized upgrade through context) + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let proposal = ICProposal { + id: ICProposalId::new([1; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::Transfer { + receiver_id: Principal::anonymous(), + amount: 1000000, + }], + }; + + create_and_verify_proposal(&pic, proxy_canister, &author_sk, &author_id, proposal) + .expect("Transfer proposal creation should succeed"); + + // Query initial state - get the proposal + let initial_proposal = pic + .query_call( + proxy_canister, + Principal::anonymous(), + "proposal", + candid::encode_one(ICProposalId::new([1; 32])).unwrap(), + ) + .and_then(|r| match r { + WasmResult::Reply(bytes) => { + Ok(candid::decode_one::>(&bytes).unwrap()) + } + _ => panic!("Unexpected response type"), + }) + .expect("Query failed") + .expect("Proposal not found"); + + // Create update request to context contract + let update_request = Request { + kind: RequestKind::Context(ContextRequest { + context_id: context_id.clone(), + kind: ContextRequestKind::UpdateProxyContract, + }), + signer_id: ICSignerId::new(author_pk.to_bytes()), + timestamp_ms: get_time_nanos(&pic), + }; + + let signed_update_request = create_signed_context_request(&author_sk, update_request); + let response = pic.update_call( + context_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_update_request).unwrap(), + ); + + // Handle the response directly + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result<(), String> = + candid::decode_one(&bytes).expect("Failed to decode response"); + assert!(result.is_ok(), "Context update should succeed"); + } + Ok(WasmResult::Reject(msg)) => panic!("Context update was rejected: {}", msg), + Err(e) => panic!("Context update failed: {}", e), + } + + // Verify state was preserved after upgrade + let final_proposal = pic + .query_call( + proxy_canister, + Principal::anonymous(), + "proposal", + candid::encode_one(ICProposalId::new([1; 32])).unwrap(), + ) + .and_then(|r| match r { + WasmResult::Reply(bytes) => { + Ok(candid::decode_one::>(&bytes).unwrap()) + } + _ => panic!("Unexpected response type"), + }) + .expect("Query failed") + .expect("Proposal not found"); + + assert_eq!( + initial_proposal, final_proposal, + "Proposal state not preserved after upgrade" + ); + } + + #[test] + fn test_create_proposal_transfer() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let proposal = ICProposal { + id: ICProposalId::new([1; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::Transfer { + receiver_id: Principal::anonymous(), + amount: 1000000, + }], + }; + + create_and_verify_proposal(&pic, proxy_canister, &author_sk, &author_id, proposal) + .expect("Transfer proposal creation should succeed"); + } + + #[test] + fn test_create_proposal_external_call() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let proposal = ICProposal { + id: ICProposalId::new([3; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::ExternalFunctionCall { + receiver_id: Principal::anonymous(), + method_name: "test_method".to_string(), + args: "deadbeef".to_string(), + deposit: 0, + }], + }; + + create_and_verify_proposal(&pic, proxy_canister, &author_sk, &author_id, proposal) + .expect("External call proposal creation should succeed"); + } + + #[test] + fn test_create_proposal_set_context() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let proposal = ICProposal { + id: ICProposalId::new([5; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::SetContextValue { + key: vec![1, 2, 3], + value: vec![4, 5, 6], + }], + }; + + create_and_verify_proposal(&pic, proxy_canister, &author_sk, &author_id, proposal) + .expect("Setting context value should succeed"); + } + + #[test] + fn test_create_proposal_multiple_actions() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let proposal = ICProposal { + id: ICProposalId::new([6; 32]), + author_id: author_id.clone(), + actions: vec![ + ICProposalAction::SetNumApprovals { num_approvals: 2 }, + ICProposalAction::SetActiveProposalsLimit { + active_proposals_limit: 5, + }, + ], + }; + + create_and_verify_proposal(&pic, proxy_canister, &author_sk, &author_id, proposal) + .expect("Multiple actions proposal creation should succeed"); + } + + #[test] + fn test_create_proposal_invalid_transfer_amount() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let proposal = ICProposal { + id: ICProposalId::new([8; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::Transfer { + receiver_id: Principal::anonymous(), + amount: 0, // Invalid amount + }], + }; + + let request = ICRequest { + signer_id: author_id.clone(), + timestamp_ms: get_time_nanos(&pic), + kind: ICRequestKind::Propose { proposal }, + }; + + let signed_request = create_signed_request(&author_sk, request); + let response = pic.update_call( + proxy_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result, String> = + candid::decode_one(&bytes).expect("Failed to decode response"); + assert!( + result.is_err(), + "Expected error for invalid transfer amount" + ); + } + Ok(WasmResult::Reject(msg)) => { + panic!("Canister rejected the call: {}", msg); + } + Err(err) => { + panic!("Failed to call canister: {}", err); + } + } + } + + #[test] + fn test_create_proposal_invalid_method_name() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let proposal = ICProposal { + id: ICProposalId::new([9; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::ExternalFunctionCall { + receiver_id: Principal::anonymous(), + method_name: "".to_string(), // Invalid method name + args: "deadbeef".to_string(), + deposit: 0, + }], + }; + + let request = ICRequest { + signer_id: author_id.clone(), + timestamp_ms: get_time_nanos(&pic), + kind: ICRequestKind::Propose { proposal }, + }; + + let signed_request = create_signed_request(&author_sk, request); + let response = pic.update_call( + proxy_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result, String> = + candid::decode_one(&bytes).expect("Failed to decode response"); + assert!(result.is_err(), "Expected error for invalid method name"); + } + Ok(WasmResult::Reject(msg)) => { + panic!("Canister rejected the call: {}", msg); + } + Err(err) => { + panic!("Failed to call canister: {}", err); + } + } + } + + #[test] + fn test_approve_own_proposal() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + // Create proposal + let proposal = ICProposal { + id: ICProposalId::new([10; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::SetNumApprovals { num_approvals: 2 }], + }; + + let _ = create_and_verify_proposal( + &pic, + proxy_canister, + &author_sk, + &author_id, + proposal.clone(), + ); + + // Try to approve own proposal + let approval = ICProposalApprovalWithSigner { + signer_id: author_id.clone(), + proposal_id: proposal.id, + added_timestamp: get_time_nanos(&pic), + }; + + let request = ICRequest { + signer_id: author_id.clone(), + timestamp_ms: get_time_nanos(&pic), + kind: ICRequestKind::Approve { approval }, + }; + + let signed_request = create_signed_request(&author_sk, request); + let result = pic.update_call( + proxy_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + match result { + Ok(WasmResult::Reply(bytes)) => { + let result: Result, String> = + candid::decode_one(&bytes).expect("Failed to decode response"); + assert!( + matches!(result, Err(e) if e.contains("already approved")), + "Should not be able to approve own proposal twice" + ); + } + _ => panic!("Unexpected response type"), + } + } + + #[test] + fn test_approve_non_existent_proposal() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let approval = ICProposalApprovalWithSigner { + signer_id: author_id.clone(), + proposal_id: ICProposalId::new([11; 32]), + added_timestamp: get_time_nanos(&pic), + }; + + let request = ICRequest { + signer_id: author_id.clone(), + timestamp_ms: get_time_nanos(&pic), + kind: ICRequestKind::Approve { approval }, + }; + + let signed_request = create_signed_request(&author_sk, request); + let response = pic.update_call( + proxy_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result, String> = + candid::decode_one(&bytes).expect("Failed to decode response"); + assert!( + result.is_err(), + "Should not be able to approve non-existent proposal" + ); + } + _ => panic!("Unexpected response type"), + } + } + + #[test] + fn test_create_proposal_empty_actions() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let proposal = ICProposal { + id: ICProposalId::new([12; 32]), + author_id: author_id.clone(), + actions: vec![], // Empty actions + }; + + let request = ICRequest { + signer_id: author_id.clone(), + timestamp_ms: get_time_nanos(&pic), + kind: ICRequestKind::Propose { proposal }, + }; + + let signed_request = create_signed_request(&author_sk, request); + let response = pic.update_call( + proxy_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result, String> = + candid::decode_one(&bytes).expect("Failed to decode response"); + assert!(result.is_err(), "Should fail with empty actions"); + assert!( + matches!(result, Err(e) if e.contains("empty actions")), + "Error should mention empty actions" + ); + } + _ => panic!("Unexpected response type"), + } + } + + #[test] + fn test_create_proposal_exceeds_limit() { + let ProxyTestContext { + pic, + proxy_canister, + author_sk, + .. + } = setup(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + // Create max number of proposals + for i in 0..10 { + let proposal = ICProposal { + id: ICProposalId::new([i as u8; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::SetNumApprovals { num_approvals: 2 }], + }; + let _ = + create_and_verify_proposal(&pic, proxy_canister, &author_sk, &author_id, proposal); + } + + // Try to create one more + let proposal = ICProposal { + id: ICProposalId::new([11; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::SetNumApprovals { num_approvals: 2 }], + }; + + let request = ICRequest { + signer_id: author_id.clone(), + timestamp_ms: get_time_nanos(&pic), + kind: ICRequestKind::Propose { proposal }, + }; + + let signed_request = create_signed_request(&author_sk, request); + let response = pic.update_call( + proxy_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result, String> = + candid::decode_one(&bytes).expect("Failed to decode response"); + assert!( + result.is_err(), + "Should not be able to exceed proposal limit" + ); + } + _ => panic!("Unexpected response type"), + } + } + + #[test] + fn test_proposal_execution_transfer() { + let ProxyTestContext { + pic, + proxy_canister, + mock_external, + mock_ledger, + author_sk, + context_canister, + context_id, + .. + } = setup(); + + let mut rng = rand::thread_rng(); + + let initial_balance = MOCK_LEDGER_BALANCE.with(|b| *b.borrow()); + + // Setup signers + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let signer2_sk = SigningKey::from_bytes(&rng.gen()); + let signer2_pk = signer2_sk.verifying_key(); + let signer2_id = ICSignerId::new(signer2_pk.to_bytes()); + + let signer3_sk = SigningKey::from_bytes(&rng.gen()); + let signer3_pk = signer3_sk.verifying_key(); + let signer3_id = ICSignerId::new(signer3_pk.to_bytes()); + + let transfer_amount = 1_000; + + // let receiver_id = Principal::from_text("bnz7o-iuaaa-aaaaa-qaaaa-cai").unwrap(); + // Create transfer proposal + let proposal = ICProposal { + id: ICProposalId::new([14; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::Transfer { + receiver_id: mock_external, + amount: transfer_amount, + }], + }; + + // Create and verify initial proposal + let _ = create_and_verify_proposal( + &pic, + proxy_canister, + &author_sk, + &author_id, + proposal.clone(), + ); + + let context_members = vec![ + ICContextIdentity::new(signer2_id.as_bytes()), + ICContextIdentity::new(signer3_id.as_bytes()), + ]; + + let _ = add_members_to_context( + &pic, + context_canister, + &context_id, + &author_sk, + context_members, + ); + + // Add approvals to trigger execution + for (signer_sk, signer_id) in [(signer2_sk, signer2_id), (signer3_sk, signer3_id)] { + let approval = ICProposalApprovalWithSigner { + signer_id: signer_id.clone(), + proposal_id: proposal.id.clone(), + added_timestamp: get_time_nanos(&pic), + }; + + let request = ICRequest { + signer_id, + timestamp_ms: get_time_nanos(&pic), + kind: ICRequestKind::Approve { approval }, + }; + + let signed_request = create_signed_request(&signer_sk, request); + let response = pic.update_call( + proxy_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + // Last approval should trigger execution + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result, String> = + candid::decode_one(&bytes).expect("Failed to decode response"); + match result { + Ok(Some(_proposal_with_approvals)) => {} + Ok(None) => { + // Proposal was executed and removed + // Verify proposal no longer exists + let query_response = pic + .query_call( + proxy_canister, + Principal::anonymous(), + "proposal", + candid::encode_one(proposal.id.clone()).unwrap(), + ) + .expect("Query failed"); + + match query_response { + WasmResult::Reply(bytes) => { + let stored_proposal: Option = + candid::decode_one(&bytes) + .expect("Failed to decode stored proposal"); + assert!( + stored_proposal.is_none(), + "Proposal should be removed after execution" + ); + } + WasmResult::Reject(msg) => { + panic!("Query rejected: {}", msg); + } + } + } + Err(e) => panic!("Unexpected error: {}", e), + } + } + _ => panic!("Unexpected response type"), + } + } + + let args = AccountBalanceArgs { + account: AccountIdentifier::new(&Principal::anonymous(), &Subaccount([0; 32])), + }; + + let response = pic + .query_call( + mock_ledger, + Principal::anonymous(), + "account_balance", + candid::encode_one(args).unwrap(), + ) + .expect("Failed to query balance"); + + match response { + WasmResult::Reply(bytes) => { + let balance: Tokens = candid::decode_one(&bytes).expect("Failed to decode balance"); + let final_balance = balance.e8s(); + // Verify the transfer was executed + assert_eq!( + final_balance, + initial_balance + .saturating_sub(transfer_amount as u64) + .saturating_sub(10_000), // Subtract both transfer amount and fee + "Transfer amount should be deducted from ledger balance" + ); + } + _ => panic!("Unexpected response type"), + } + } + + #[test] + fn test_proposal_execution_external_call() { + let ProxyTestContext { + pic, + proxy_canister, + mock_external, + author_sk, + context_canister, + context_id, + .. + } = setup(); + + let mut rng = rand::thread_rng(); + + let author_pk = author_sk.verifying_key(); + let author_id = ICSignerId::new(author_pk.to_bytes()); + + let signer2_sk = SigningKey::from_bytes(&rng.gen()); + let signer2_pk = signer2_sk.verifying_key(); + let signer2_id = ICSignerId::new(signer2_pk.to_bytes()); + + let signer3_sk = SigningKey::from_bytes(&rng.gen()); + let signer3_pk = signer3_sk.verifying_key(); + let signer3_id = ICSignerId::new(signer3_pk.to_bytes()); + + // Create external call proposal + let test_args = "01020304".to_string(); // Test arguments as string + let proposal = ICProposal { + id: ICProposalId::new([14; 32]), + author_id: author_id.clone(), + actions: vec![ICProposalAction::ExternalFunctionCall { + receiver_id: mock_external, + method_name: "test_method".to_string(), + args: test_args.clone(), + deposit: 0, + }], + }; + + // Create and verify initial proposal + let _ = create_and_verify_proposal( + &pic, + proxy_canister, + &author_sk, + &author_id, + proposal.clone(), + ); + + let context_members = vec![ + ICContextIdentity::new(signer2_id.as_bytes()), + ICContextIdentity::new(signer3_id.as_bytes()), + ]; + + let _ = add_members_to_context( + &pic, + context_canister, + &context_id, + &author_sk, + context_members, + ); + + // Add approvals to trigger execution + for (signer_sk, signer_id) in [(signer2_sk, signer2_id), (signer3_sk, signer3_id)] { + let approval = ICProposalApprovalWithSigner { + signer_id: signer_id.clone(), + proposal_id: proposal.id.clone(), + added_timestamp: get_time_nanos(&pic), + }; + + let request = ICRequest { + signer_id, + timestamp_ms: get_time_nanos(&pic), + kind: ICRequestKind::Approve { approval }, + }; + + let signed_request = create_signed_request(&signer_sk, request); + let response = pic.update_call( + proxy_canister, + Principal::anonymous(), + "mutate", + candid::encode_one(signed_request).unwrap(), + ); + + // Last approval should trigger execution + match response { + Ok(WasmResult::Reply(bytes)) => { + let result: Result, String> = + candid::decode_one(&bytes).expect("Failed to decode response"); + match result { + Ok(Some(_proposal_with_approvals)) => {} + Ok(None) => { + // Proposal was executed and removed + // Verify proposal no longer exists + let query_response = pic + .query_call( + proxy_canister, + Principal::anonymous(), + "proposal", + candid::encode_one(proposal.id.clone()).unwrap(), + ) + .expect("Query failed"); + + match query_response { + WasmResult::Reply(bytes) => { + let stored_proposal: Option = + candid::decode_one(&bytes) + .expect("Failed to decode stored proposal"); + assert!( + stored_proposal.is_none(), + "Proposal should be removed after execution" + ); + } + WasmResult::Reject(msg) => { + panic!("Query rejected: {}", msg); + } + } + } + Err(e) => panic!("Unexpected error: {}", e), + } + } + _ => panic!("Unexpected response type"), + } + } + + // Verify the external call was executed + let response = pic + .query_call( + mock_external, + Principal::anonymous(), + "get_calls", + candid::encode_args(()).unwrap(), + ) + .expect("Query failed"); + + match response { + WasmResult::Reply(bytes) => { + let calls: Vec> = + candid::decode_one(&bytes).expect("Failed to decode calls"); + assert_eq!(calls.len(), 1, "Should have exactly one call"); + + // Decode the Candid-encoded argument + let received_args: String = + candid::decode_one(&calls[0]).expect("Failed to decode call arguments"); + assert_eq!(received_args, test_args, "Call arguments should match"); + } + _ => panic!("Unexpected response type"), + } + } +} diff --git a/scripts/test.sh b/scripts/test.sh index 35438717e..c00ea9e14 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -12,6 +12,7 @@ cd "$(dirname $0)" ../contracts/registry/build.sh ../contracts/context-config/build.sh ../contracts/proxy-lib/build-test-deps.sh - +../contracts/icp/context-config/build.sh +../contracts/icp/proxy-contract/build_contracts.sh # Run cargo test cargo test