commit 118466d9cdb7bde794306a2bc6f3bcbb7acbeed2 Author: viraladmin <00purple@gmail.com> Date: Sun May 24 11:56:57 2026 -0600 Initial Contractless source release diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c96e8d3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2661 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.0", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.11.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a229bfd78e4827c91b9b95784f69492c1b77c1ab75a45a8a037b139215086f94" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blockchain" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "cid", + "colored", + "encrypted_images", + "falcon-rs", + "flexi_logger", + "fn-dsa", + "hex", + "ipnetwork", + "lazy_static", + "log", + "nix 0.27.1", + "once_cell", + "rand 0.8.5", + "rayon", + "ripemd", + "rpassword", + "rust-ini", + "rustyline", + "rustyline-derive", + "serde", + "serde_json", + "shellexpand", + "skein", + "sled", + "tokio", + "tokio-postgres", + "windows-service", + "windows-sys 0.61.2", + "winreg", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytemuck" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" + +[[package]] +name = "cc" +version = "1.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16803a61b81d9eabb7eae2588776c4c1e584b738ede45fdbb4c972cec1e9945" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.6", +] + +[[package]] +name = "cid" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" +dependencies = [ + "core2", + "multibase", + "multihash", + "unsigned-varint", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "const-oid" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a23fa214dea9efd4dacee5a5614646b30216ae0f05d4bb51bafb50e9da1c5be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "data-encoding-macro" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "deflate" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73770f8e1fe7d64df17ca66ad28994a0a623ea497fa69486e14984e715c5d174" +dependencies = [ + "adler32", + "byteorder", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460dd7f37e4950526b54a5a6b1f41b6c8e763c58eb9a8fc8fc05ba5c2f44ca7b" +dependencies = [ + "block-buffer 0.11.0-rc.4", + "const-oid", + "crypto-common 0.2.0-rc.3", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + +[[package]] +name = "encrypted_images" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761fe0fc7059c1e9cb4217f49263b146d269322354aeefb8fc519a60b2e8cef1" +dependencies = [ + "base64 0.21.7", + "encoding", + "hex-literal", + "hmac", + "image", + "native-dialog", + "openssl", + "rand 0.8.5", + "sha2", + "subtle", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "falcon-rs" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5277cc4194542e71f3fe79ff8c981d5ebcb7f4627fd1fa5526ec27518ed600df" +dependencies = [ + "getrandom 0.2.15", + "libm", + "zeroize", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "flexi_logger" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea7feddba9b4e83022270d49a58d4a1b3fdad04b34f78cf1ce471f698e42672" +dependencies = [ + "chrono", + "log", + "nu-ansi-term", + "regex", + "thiserror 2.0.18", +] + +[[package]] +name = "fn-dsa" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afb39d94be3f4538ccfdf23dc69a89904c9b802331b1975538fcc251973593f9" +dependencies = [ + "fn-dsa-comm", + "fn-dsa-kgen", + "fn-dsa-sign", + "fn-dsa-vrfy", +] + +[[package]] +name = "fn-dsa-comm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28104bba227c79bff8ffe34dff7c5a5631fd790dfd4d4f0a84b01952ef7c6f46" +dependencies = [ + "cpufeatures", + "rand_core 0.6.4", +] + +[[package]] +name = "fn-dsa-kgen" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a04fe82799cc1550d32778b415648d1962a7b4b4860ff5f2e6ca32494fc9aa" +dependencies = [ + "fn-dsa-comm", + "zeroize", +] + +[[package]] +name = "fn-dsa-sign" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ffb7d9c94e56de7e64019849b3b6110b1495690da2e9e1decef8f1744b3bab4" +dependencies = [ + "fn-dsa-comm", + "zeroize", +] + +[[package]] +name = "fn-dsa-vrfy" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc96b904b213160c8804cc2642ccd15cb968318a64648fc5ca3edab9153cda2b" +dependencies = [ + "fn-dsa-comm", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gif" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3edd93c6756b4dfaf2709eafcc345ba2636565295c198a9cfbf75fa5e3e00b06" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hybrid-array" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d15931895091dea5c47afa5b3c9a01ba634b311919fd4d41388fa0e3d76af" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "image" +version = "0.23.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ffcb7e7244a9bf19d35bf2883b9c080c4ced3c07a9895572178cdb8f13f6a1" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif", + "jpeg-decoder", + "num-iter", + "num-rational", + "num-traits", + "png", + "scoped_threadpool", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown 0.15.0", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "jpeg-decoder" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" +dependencies = [ + "rayon", +] + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791daaae1ed6889560f8c4359194f56648355540573244a5448a83ba1ecc7435" +dependencies = [ + "adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +dependencies = [ + "adler", + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +dependencies = [ + "core2", + "unsigned-varint", +] + +[[package]] +name = "native-dialog" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bbf55edb2747e4e4b3a9cd3989194b88aae32274b4422635dcf98aa6e84197b" +dependencies = [ + "ascii", + "block", + "cocoa", + "dirs-next", + "objc", + "objc-foundation", + "objc_id", + "once_cell", + "raw-window-handle", + "thiserror 1.0.64", + "wfd", + "which", + "winapi", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "openssl" +version = "0.10.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-src" +version = "300.3.2+3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a211a18d945ef7e648cc6e0058f4c548ee46aab922ea203e0d30e966ea23647b" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed8acf08e98e744e5384c8bc63ceb0364e68a6854187221c18df61c4797690e" +dependencies = [ + "dlv-list", + "hashbrown 0.13.2", +] + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.10", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.7", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "png" +version = "0.16.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3287920cb847dee3de33d301c463fba14dda99db24214ddf93f83d3021f4c6" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "deflate", + "miniz_oxide 0.3.7", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ff0abab4a9b844b93ef7b81f1efc0a366062aaef2cd702c76256b5dc075c54" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.9.1", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "raw-window-handle" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b800beb9b6e7d2df1fe337c9e3d04e3af22a124460fb4c30fcc22c9117cefb41" +dependencies = [ + "cty", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 1.0.64", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ripemd" +version = "0.2.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790779cfa1b78709fe36d030c80731cf8f4829c9afcf8cfe22fd7ca2d844bd43" +dependencies = [ + "digest 0.11.0-rc.0", +] + +[[package]] +name = "rpassword" +version = "7.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.48.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "rust-ini" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e2a3bcec1f113553ef1c88aae6c020a369d03d55b58de9869a0908930385091" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.14", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.28.0", + "radix_trie", + "rustyline-derive", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustyline-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5af959c8bf6af1aff6d2b463a57f71aae53d1332da58419e30ad8dc7011d951" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "scoped_threadpool" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "shellexpand" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "skein" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f412279217fa74b69094bf6b5cde63dd0ece9b85d94fedda9bbfdfb2666125cf" +dependencies = [ + "digest 0.10.7", + "threefish", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "sled" +version = "0.34.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +dependencies = [ + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot 0.11.2", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" +dependencies = [ + "thiserror-impl 1.0.64", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "threefish" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a693d0c8cf16973fac5a93fbe47b8c6452e7097d4fcac49f3d7a18e39c76e62e" + +[[package]] +name = "tiff" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a53f4706d65497df0c4349241deddf35f84cee19c87ed86ea8ca590f4464437" +dependencies = [ + "jpeg-decoder", + "miniz_oxide 0.4.4", + "weezl", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot 0.12.3", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot 0.12.3", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.9.1", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "web-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + +[[package]] +name = "wfd" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e713040b67aae5bf1a0ae3e1ebba8cc29ab2b90da9aa1bff6e09031a8a41d7a8" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.37", +] + +[[package]] +name = "whoami" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +dependencies = [ + "redox_syscall 0.5.7", + "wasite", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-service" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193cae8e647981c35bc947fdd57ba7928b1fa0d4a79305f6dd2dc55221ac35ac" +dependencies = [ + "bitflags 2.11.1", + "widestring", + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b196f12 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "blockchain" +version = "0.1.0" +edition = "2021" + +[features] +default = ["testnet"] +mainnet = [] +testnet = [] + +[[bin]] +name = "contractless-testnet" +path = "src/main.rs" +required-features = ["testnet"] + +[[bin]] +name = "contractless-mainnet" +path = "src/main.rs" +required-features = ["mainnet"] + +[profile.release] +opt-level = "z" +lto = true +panic = "abort" +codegen-units = 1 + +[dependencies] +rpassword = "7.1.0" +encrypted_images = "1.4.0" +skein = "0.1.0" +chrono = "0.4" +serde = { version = "1.0.108", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] } +base64 = "0.22.1" +hex = "0.4" +falcon-rs = "0.2.4" +fn-dsa = "0.3.0" +tokio = { version = "1.45.1", features = ["full", "test-util"] } +tokio-postgres = "0.7" +rand = "0.8" +sled = "0.34" +lazy_static = "1.4" +rust-ini = "0.19" +shellexpand = "3.1.0" +ipnetwork = "0.20.0" +rayon = "1.8.0" +once_cell = "1.8.0" +anyhow = "1.0" +ripemd = { version = "0.2.0-rc.0" } +colored = "2" +cid = "0.11" +flexi_logger = "0.31.8" +log = "0.4" +rustyline = { version = "14.0.0", features = ["derive"] } +rustyline-derive = "0.10.0" + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.27", features = ["user", "process", "signal"] } + +[target.'cfg(windows)'.dependencies] +windows-service = "0.8" +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization"] } +winreg = "0.52" diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd4ac20 --- /dev/null +++ b/README.md @@ -0,0 +1,312 @@ +# Contractless + +Contractless is a peer-to-peer Fair-Proof-of-Work blockchain with native multi-asset transactions, torrent-based block synchronization and deterministic orphan correction. This README contains the minimum information needed to build, configure and start a node. + +For the protocol design, mining rules, balance-sheet model and orphan correction process, read the [Contractless whitepaper](https://contractless.community/contractless_whitepaper.pdf). + +## Table of Contents + +- [Network Requirements](#network-requirements) +- [Build Instructions](#build-instructions) +- [PostgreSQL Setup](#postgresql-setup) +- [Settings Configuration](#settings-configuration) +- [Runtime Paths](#runtime-paths) +- [Startup](#startup) +- [Additional Documentation](#additional-documentation) + +## Network Requirements + +A public node needs to be reachable by other peers. + +- Open the active RPC port in the local firewall. +- Forward the active RPC port through the router or host firewall when behind NAT. +- Set `IP` in `settings.ini` to the reachable public IP or reachable domain name for the node. +- Keep system time synchronized. Any reliable NTP/time server should work because Contractless only requires second-level timestamp agreement. A common default is `pool.ntp.org`. +- Keep PostgreSQL reachable locally by the node process. + +Loopback and private addresses are useful for local testing, but they should not be announced by a public node. + +## Build Instructions + +Install Rust and build from the repository root. + +```bash +cargo build --release +``` + +The default build target is testnet. + +### Build Flags + +Testnet is the default feature: + +```bash +cargo build --release +``` + +or explicitly: + +```bash +cargo build --release --features testnet +``` + +Mainnet has a feature flag, but mainnet builds are intentionally disabled during the testnet phase: + +```bash +cargo build --release --no-default-features --features mainnet +``` + +That command will fail until mainnet is enabled for launch. + +## PostgreSQL Setup + +Contractless uses PostgreSQL for transaction lookup and mempool-style records. PostgreSQL only needs a database, user, password and permissions before the node starts. On startup, the node creates or migrates the required tables and indexes automatically. The node also uses local file and sled storage for chain state, wallets, balance sheets and torrents. + +### Linux PostgreSQL CLI Tool + +Build the release binaries, then run the installer as root so it can install PostgreSQL if needed and create the database, user, password and permissions: + +```bash +sudo ./target/release/postgres_installer +``` + +After the tool completes, copy the printed PostgreSQL values into the active PostgreSQL section of `settings.ini`. + +### Windows PostgreSQL CLI Tool + +Open PowerShell as Administrator, copy the built installer into the install folder, then run it: + +```powershell +& "C:\Program Files\Contractless\postgres_installer.exe" +``` + +The Windows installer downloads and installs PostgreSQL when needed, then creates the configured database, user, password and permissions. + +### Manual PostgreSQL Setup + +Manual PostgreSQL instructions should live in [docs/POSTGRES.md](docs/POSTGRES.md). Until that file is fully written, use the CLI installer unless you already know how to create the database, user and permissions manually. + +## Settings Configuration + +The node loads `settings.ini` in this order: + +1. `--config ` +2. `SETTINGS_PATH` environment variable +3. `./settings.ini` +4. `settings.ini` beside the executable +5. platform fallback path + +On Linux, the fallback path is: + +```text +/etc/contractless/settings.ini +``` + +On Windows, place `settings.ini` beside the executable: + +```text +C:\Program Files\Contractless\settings.ini +``` + +### Basic `settings.ini` Shape + +```ini +[Paths] +BLOCK_PATH = "./blocks" +TORRENT_PATH = "./torrents" +DB_PATH = "./state" +WALLET_PATH = "./wallets" +WALLET_NAME = "contractless.wallet" +BALANCE_SHEET = "./balance_sheet" + +[Settings] +LOG_PATH = "./logs" +LOG_LEVEL = "info" +IP = "127.0.0.1" +RPC_PORT = "2006" +TESTNET_RPC_PORT = "50053" +INCOMING_CONNECTIONS = "10" +OUTGOING_CONNECTIONS = "0" +THREADS = "8" + +[Piggyback] +PIGGYBACK_1 = "127.0.0.1:50050" +PIGGYBACK_2 = "127.0.0.1:50054" +PIGGYBACK_3 = "127.0.0.1:50051" +PIGGYBACK_4 = "127.0.0.1:50055" +PIGGYBACK_5 = "127.0.0.1:50052" + +[Postgres-Testnet] +host = 127.0.0.1 +port = 5432 +user = contractless +password = your_postgres_password_here +dbname = contractless_db + +[Postgres] +host = 127.0.0.1 +port = 5432 +user = contractless +password = your_postgres_password_here +dbname = contractless_db +``` + +`THREADS` must be `1`, `2` or a multiple of `4`, and may not be greater than `256`. + +`LOG_LEVEL` follows normal logging filters: + +- `info` shows info, warning and error logs. +- `warn` shows warning and error logs. +- `error` shows only error logs. +- `off` disables log output. + +Detailed settings notes should live in [docs/SETTINGS.md](docs/SETTINGS.md). + +## Runtime Paths + +Relative paths in `settings.ini` are resolved relative to the location of that `settings.ini` file. The node scopes runtime data by active network internally, so testnet and mainnet paths do not collide when using the same base folders. + +The main runtime folders are: + +- `BLOCK_PATH`: saved block files +- `TORRENT_PATH`: torrent metadata and staged torrents +- `DB_PATH`: sled state +- `WALLET_PATH`: encrypted wallet files +- `BALANCE_SHEET`: balance sheet files +- `LOG_PATH`: runtime logs + +### Linux Copy Instructions + +Create the config folder and copy the sample settings file: + +```bash +sudo mkdir -p /etc/contractless +sudo cp ./settings.ini /etc/contractless/settings.ini +``` + +Copy the node binary: + +```bash +sudo cp ./target/release/contractless-testnet /usr/bin/ +``` + +Copy any CLI tools you want available system-wide: + +```bash +sudo cp ./target/release/postgres_installer /usr/bin/ +sudo cp ./target/release/create_new_wallet /usr/bin/ +sudo cp ./target/release/register_wallet /usr/bin/ +``` + +Repeat the same pattern for any other tools from `target/release`. + +### Windows Copy Instructions + +Open PowerShell as Administrator and create the install folder: + +```powershell +New-Item -ItemType Directory -Force "C:\Program Files\Contractless" | Out-Null +``` + +Copy the node, key-submit tool, PostgreSQL installer and settings file: + +```powershell +Copy-Item ".\target\release\contractless-testnet.exe" "C:\Program Files\Contractless\" +Copy-Item ".\target\release\contractless-submit-key.exe" "C:\Program Files\Contractless\" +Copy-Item ".\target\release\postgres_installer.exe" "C:\Program Files\Contractless\" +Copy-Item ".\settings-windows.ini" "C:\Program Files\Contractless\settings.ini" +``` + +Copy any additional CLI tools the same way. + +## Startup + +Create or restore a wallet before starting a public node, then make sure the wallet is registered and the wallet path/name in `settings.ini` matches the runtime wallet. + +### Linux Startup + +Start the testnet node: + +```bash +contractless-testnet +``` + +Linux prompts for the wallet decryption key, then detaches into the background automatically. + +Run in the foreground for debugging: + +```bash +contractless-testnet --foreground +``` + +Check daemon status: + +```bash +contractless-testnet --status +``` + +Stop the daemon: + +```bash +contractless-testnet --stop +``` + +Use a specific config file: + +```bash +contractless-testnet --config /path/to/settings.ini +``` + +### Windows Startup + +Open PowerShell as Administrator and install the service: + +```powershell +& "C:\Program Files\Contractless\contractless-testnet.exe" --install-service +``` + +Start the service: + +```powershell +& "C:\Program Files\Contractless\contractless-testnet.exe" --start-service +``` + +Submit the wallet decryption key from a normal user shell: + +```powershell +& "C:\Program Files\Contractless\contractless-submit-key.exe" +``` + +Stop the service: + +```powershell +& "C:\Program Files\Contractless\contractless-testnet.exe" --stop-service +``` + +Uninstall the service: + +```powershell +& "C:\Program Files\Contractless\contractless-testnet.exe" --uninstall-service +``` + +### Startup Flags + +Node flags: + +- `--config `: load a specific `settings.ini`. +- `--foreground`: Linux only; keep the process attached to the terminal. +- `--status`: Linux only; check daemon status. +- `--stop`: Linux only; stop the daemon. +- `--install-service`: Windows only; install the Windows service. +- `--start-service`: Windows only; start the Windows service. +- `--stop-service`: Windows only; stop the Windows service. +- `--uninstall-service`: Windows only; uninstall the Windows service. + +## Additional Documentation + +- [Whitepaper](https://contractless.community/contractless_whitepaper.pdf) +- [Manual PostgreSQL setup](docs/POSTGRES.md) +- [Settings reference](docs/SETTINGS.md) +- [CLI tools reference](docs/CLI_TOOLS.md) +- [Transaction reference](docs/TRANSACTIONS.md) +- [Developer guide](docs/DEV.md) diff --git a/binaries/linux-contractless-testnet-0.1.0.tar.gz b/binaries/linux-contractless-testnet-0.1.0.tar.gz new file mode 100644 index 0000000..e6044c9 Binary files /dev/null and b/binaries/linux-contractless-testnet-0.1.0.tar.gz differ diff --git a/binaries/linux-contractless-testnet-0.1.0.zip b/binaries/linux-contractless-testnet-0.1.0.zip new file mode 100644 index 0000000..96d2a33 Binary files /dev/null and b/binaries/linux-contractless-testnet-0.1.0.zip differ diff --git a/docs/CLI_TOOLS.md b/docs/CLI_TOOLS.md new file mode 100644 index 0000000..b7afea7 --- /dev/null +++ b/docs/CLI_TOOLS.md @@ -0,0 +1,1335 @@ +# Contractless CLI Tools + +This file lists the command-line tools built from `src/bin`. On Windows, add `.exe` to each tool name. Most network lookup tools prompt for the wallet decryption key because the RPC handshake is authenticated before the request is sent. + +Transaction creator tools produce signed transaction JSON. After creating a transaction, broadcast it with `broadcast_transaction`. + +## Table of Contents + +- [Wallet Tools](#wallet-tools) +- [Transaction Creation Tools](#transaction-creation-tools) +- [Transaction Signing Tools](#transaction-signing-tools) +- [Lookup Tools](#lookup-tools) +- [Server Owner Tools](#server-owner-tools) +- [Block and Torrent Tools](#block-and-torrent-tools) +- [Utility Tools](#utility-tools) + +## Wallet Tools + +### create_new_wallet + +Creates or loads an encrypted wallet file at the requested path. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_new_wallet +``` + +Expected reply: + +```text +Long Address: +Short Address: +Vanity Address: +Public Key: +``` + +### register_wallet + +Registers the configured wallet short-address to long-address mapping with a peer. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +register_wallet +``` + +Expected reply: + +```text +Wallet registered: +``` + +or: + +```text +Wallet registration failed. +``` + +### recreate_wallet + +Recreates an encrypted wallet file from a private key. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +recreate_wallet +``` + +Expected reply: + +```text + +``` + +### recreate_wallet_from_image + +Recreates an encrypted wallet file from a private-key image. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +recreate_wallet_from_image +``` + +Expected reply: + +```text + +``` + +### save_private_key_image + +Saves the wallet private key into an image file for offline backup. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +save_private_key_image +``` + +Expected reply: + +```text +Private key image saved to +``` + +### private_key_from_image + +Reads a private key back from an image file. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: No + +Usage: + +```text +private_key_from_image +``` + +Expected reply: + +```text + +``` + +### contractless-submit-key + +Submits the wallet decryption key to the Windows service process. This tool is Windows-only. + +Requires: + +- Running node: Windows service +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +contractless-submit-key +contractless-submit-key ping +contractless-submit-key status +``` + +Expected reply: + +```text +Wallet key accepted. +``` + +or: + +```text +Pong +``` + +or: + +```text +Service state: +``` + +## Transaction Creation Tools + +These tools prompt for the fields needed by each transaction type, sign the transaction with the configured wallet and print the signed JSON. The same JSON is also written to the transaction output folder when the tool creates the output file successfully. + +### create_transfer_tx + +Creates a transfer transaction for base currency, tokens or NFTs. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_transfer_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_tokens_tx + +Creates a token definition transaction. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_tokens_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_issue_token_tx + +Creates an issue-token transaction for an existing token. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_issue_token_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_nft_tx + +Creates an NFT transaction. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_nft_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_burn_tx + +Creates a burn transaction. Base currency cannot be burned with this transaction type. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_burn_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_marketing_tx + +Creates a marketing transaction. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_marketing_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_vanity_tx + +Creates a vanity-address registration transaction. The vanity address is registered on chain, while normal block records continue using the real short address. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_vanity_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_swap_tx + +Creates the first side of a swap transaction. The second party signs the transaction with `verify_sign_swap_tx`. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_swap_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_loan_tx + +Creates the first side of a loan contract transaction. The second party signs the transaction with `verify_sign_loan_tx`. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_loan_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_loan_payment_tx + +Creates a loan payment transaction for an existing loan contract. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_loan_payment_tx +``` + +Expected reply: + +```text +transaction: +``` + +### create_collateral_claim_tx + +Creates a collateral claim transaction for an eligible loan contract. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +create_collateral_claim_tx +``` + +Expected reply: + +```text +transaction: +``` + +### broadcast_transaction + +Broadcasts one signed transaction JSON file to the network. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: Only when a successful vanity transaction updates the saved wallet display + +Usage: + +```text +broadcast_transaction +``` + +Expected reply: + +```text +successful_broadcast: true + +``` + +or: + +```text + +``` + +## Transaction Signing Tools + +### verify_sign_swap_tx + +Validates and signs the second side of a swap transaction JSON file. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +verify_sign_swap_tx +``` + +Expected reply: + +```text +Transaction: +``` + +### verify_sign_loan_tx + +Validates and signs the second side of a loan transaction JSON file. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +verify_sign_loan_tx +``` + +Expected reply: + +```text +Transaction: +``` + +## Lookup Tools + +Network lookup tools send binary RPC requests to a peer. Some tools print decoded JSON for readability, but JSON is not sent across the TCP stream. + +### lookup_network_info + +Returns a summary of the connected peer state. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_network_info +``` + +Expected reply: + +```json +{ + "version": 1, + "network": "testnet", + "time": 1234567890, + "wallet_prefix": "CLTC", + "height": 5, + "next_block_difficulty": 1183350000000000, + "total_block_transactions": 6, + "total_mempool_transactions": 0, + "largest_tx_fee": 0 +} +``` + +### lookup_node_time + +Returns the peer timestamp as a Unix timestamp and UTC time string. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_node_time +``` + +Expected reply: + +```text +timestamp: 1234567890 +time: 2009-02-13 23:31:30 UTC +``` + +### lookup_height + +Returns the connected peer chain height. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_height +``` + +Expected reply: + +```text +123 +``` + +### lookup_difficulty + +Returns the next block difficulty reported by the peer. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_difficulty +``` + +Expected reply: + +```text +1183350000000000 +``` + +### lookup_largest_txfee + +Returns the largest transaction fee currently available for miner selection. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_largest_txfee +``` + +Expected reply: + +```text +1.00000000 +``` + +### lookup_block_by_height + +Looks up a block by height. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_block_by_height +``` + +Expected reply: + +```json +{ + "block": "" +} +``` + +### lookup_block_by_hash + +Looks up a block by its 64-character block hash. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_block_by_hash +``` + +Expected reply: + +```json +{ + "block": "" +} +``` + +### lookup_transaction + +Looks up a transaction by transaction hash. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_transaction +``` + +Expected reply: + +```json +{ + "transaction": "" +} +``` + +### lookup_total_transactions + +Returns total transaction counts by transaction category. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_total_transactions +``` + +Expected reply: + +```json +[ + { + "transaction_type": "", + "count": 0 + } +] +``` + +### lookup_token + +Looks up one token by token name. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_token +``` + +Expected reply: + +```json +{ + "token": "" +} +``` + +### lookup_token_list + +Returns the token list from the connected peer. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_token_list +``` + +Expected reply: + +```json +[ + "" +] +``` + +### lookup_nft + +Looks up one NFT. Use item number `0` for a 1/1 NFT. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_nft +``` + +Expected reply: + +```json +{ + "nft": "" +} +``` + +### lookup_nft_list + +Returns the NFT list from the connected peer. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_nft_list +``` + +Expected reply: + +```json +[ + "" +] +``` + +### lookup_contract_by_hash + +Looks up a loan contract by contract hash. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_contract_by_hash +``` + +Expected reply: + +```text + +``` + +### lookup_contract_by_address + +Looks up loan contracts by wallet address. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_contract_by_address +``` + +Expected reply: + +```text + +``` + +### lookup_remote_balance + +Looks up the network balance sheet for an address file. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_remote_balance +``` + +Expected reply: + +```text +CLC = 100.00000000 +TOKEN = 50.00000000 +NFT:1 = 1.00000000 +``` + +or: + +```text +[] +``` + +### lookup_local_balance + +Reads and prints a local balance sheet file directly. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: No + +Usage: + +```text +lookup_local_balance +``` + +Expected reply: + +```text +100.00000000 +``` + +### lookup_mempool_tx_count + +Returns the number of transactions currently in the peer mempool records. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_mempool_tx_count +``` + +Expected reply: + +```text +0 +``` + +### lookup_mempool_tx_by_signature + +Looks up one mempool transaction by signature. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_mempool_tx_by_signature +``` + +Expected reply: + +```json +{ + "transaction": "" +} +``` + +or: + +```text +Transaction not found in mempool. +``` + +### lookup_mempool_tx_by_address + +Looks up mempool transactions by wallet address. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_mempool_tx_by_address +``` + +Expected reply: + +```json +[ + { + "transaction": "" + } +] +``` + +or: + +```text +[] +``` + +### lookup_torrent + +Looks up torrent metadata by block height. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +lookup_torrent +``` + +Expected reply: + +```json +{ + "torrent": "" +} +``` + +## Server Owner Tools + +These tools are meant for the node operator. They send owner-style RPC commands to the configured peer. + +### server_owner_block_ip + +Blocks an IP address. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +server_owner_block_ip +``` + +Expected reply: + +```text + +``` + +### server_owner_unblock_ip + +Unblocks an IP address. + +Requires: + +- Running node: Reachable peer +- Wallet key: Yes +- Writes file: No + +Usage: + +```text +server_owner_unblock_ip +``` + +Expected reply: + +```text + +``` + +## Block and Torrent Tools + +These tools inspect local block or torrent files. They do not ask the network for data unless the tool name starts with `lookup_`. + +### average_block_time_checker + +Calculates the average time between local block files in a block range. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: No + +Usage: + +```text +average_block_time_checker +``` + +Expected reply: + +```text +Average time difference between blocks and : seconds +``` + +### unpack_block_header + +Reads a local block file and prints the decoded block header. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: No + +Usage: + +```text +unpack_block_header +``` + +Expected reply: + +```json +{ + "header": "" +} +``` + +### unpack_torrent + +Reads a local torrent file and prints decoded torrent metadata. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: No + +Usage: + +```text +unpack_torrent +``` + +Expected reply: + +```json +{ + "announce": "", + "info": "" +} +``` + +### validate_torrent_and_block_headers + +Compares a local torrent file against the matching local block file and prints each validation check. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: No + +Usage: + +```text +validate_torrent_and_block_headers +``` + +Expected reply: + +```text +timestamp match: passed +nonce match: passed +wallet address match: passed +block difficulty check: passed +VRF match: passed +file size check: passed +block header hash check: passed +VRF validation check: passed +VRF Proof check: passed +piece 0 hash check: passed +block hash check: passed + +Block fully validated. +``` + +## Utility Tools + +### postgres_installer + +Installs or configures PostgreSQL and prints settings values for `settings.ini`. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: May install or configure PostgreSQL + +Usage: + +```text +postgres_installer +``` + +Expected reply: + +```ini +[Postgres] +host = 127.0.0.1 +port = 5432 +user = contractless +password = +dbname = contractless_db +``` + +On Windows, the tool also prints `[Postgres-Testnet]`. + +### sign_message + +Signs a plain text message with the configured wallet. + +Requires: + +- Running node: No +- Wallet key: Yes +- Writes file: Yes + +Usage: + +```text +sign_message "" +``` + +Expected reply: + +```text +message: , signature: +``` + +### verify_message + +Verifies a signed message using an address file and signature file. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: No + +Usage: + +```text +verify_message "" +``` + +Expected reply: + +```text +valid signature +``` + +or: + +```text +invalid signature +``` + +### verify_address + +Verifies an address file and prints the message hash used by the verification flow. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: No + +Usage: + +```text +verify_address +``` + +Expected reply: + +```text + +``` + +### skein_hasher + +Hashes text, an entire file or a byte range from a file. + +Requires: + +- Running node: No +- Wallet key: No +- Writes file: No + +Usage: + +```text +skein_hasher text large|small +skein_hasher file large|small +skein_hasher bytes large|small +``` + +Expected reply: + +```text +Hash of text '': +``` + +or: + +```text +Hash of file +``` + +or: + +```text +Hash of bytes through of file '': +``` diff --git a/docs/DEV.md b/docs/DEV.md new file mode 100644 index 0000000..d137ce6 --- /dev/null +++ b/docs/DEV.md @@ -0,0 +1,250 @@ +# Contractless Developer Guide + +Contractless builds as both executable tools and a Rust library crate named `blockchain`. The library artifact is `libblockchain.rlib`, and it exposes the same major modules used by the node, CLI tools and internal tests. + +This file is a developer map. It is not meant to replace generated Rust documentation. Many functions are public because the crate is split across modules, not because every public function is a stable external API. + +## Build Targets + +The package builds node binaries, standalone CLI tools and the `blockchain` library crate. + +Default testnet build: + +```bash +cargo build --release +``` + +Explicit testnet build: + +```bash +cargo build --release --features testnet +``` + +Mainnet has a feature flag, but mainnet builds are disabled during the testnet phase: + +```bash +cargo build --release --no-default-features --features mainnet +``` + +## Generated API Reference + +Use Rustdoc for the complete list of public modules, structs, enums, constants and functions: + +```bash +cargo doc --no-deps +``` + +Open the generated reference: + +```text +target/doc/blockchain/index.html +``` + +The generated docs are the best way to inspect every exposed item in `libblockchain.rlib`. + +## Public Surface + +The crate root exposes these local modules: + +| Module | What it contains | +| --- | --- | +| `blocks` | Block structs, transaction structs, byte encoding and transaction balance effects | +| `common` | Shared constants, hashing, settings paths, CLI prompts, network startup helpers and shared utility code | +| `config` | `settings.ini` loading and `Settings` structure | +| `miner` | Mining loop, nonce search, block reward logic, fairness checks and mining structs | +| `orphans` | Orphan correction, rollback, staged torrent replay and transaction undo logic | +| `records` | Chain records, balance sheets, wallet registry, memory records, PostgreSQL helpers and network mappings | +| `rpc` | RPC command IDs, client helpers, server loop, command handlers and response types | +| `standalone_tools` | Shared connection and request helpers used by CLI tools | +| `startup` | Node startup, daemon/service support, logging, paths and runtime initialization | +| `torrent` | Torrent metadata, torrent cache, torrent staging, block download and torrent validation | +| `verifications` | Transaction, block, torrent and rule validation logic | +| `wallets` | Wallet structs, address conversion, key generation, signing, verification and vanity lookup | + +The crate root also re-exports many dependency types and functions used throughout the project. This keeps internal modules consistent, but external developers should prefer the stable public modules above instead of relying on dependency re-exports as an API contract. + +## Useful Entry Points + +### Wallets + +Useful for tools that need to load wallets, derive addresses or sign data. + +Common areas: + +- `wallets::structures::Wallet` +- `wallets::structures::SavedWallet` +- address conversion functions in the wallet modules +- transaction signing and verification functions + +Typical uses: + +- create or load encrypted wallets +- convert long addresses to short addresses +- validate short or vanity addresses +- sign transaction hashes +- verify signatures + +### Blocks and Transactions + +Useful for tools that need to construct, decode or inspect transactions. + +Common areas: + +- `blocks::block` +- `blocks::transfer` +- `blocks::token` +- `blocks::nft` +- `blocks::swap` +- `blocks::loans` +- `blocks::loan_payment` +- `blocks::collateral` +- `blocks::vanity` + +Typical uses: + +- create transaction structs +- encode transactions into bytes +- decode transactions from bytes +- inspect transaction fields +- apply or undo balance-sheet effects + +For user-facing transaction behavior, see [TRANSACTIONS.md](TRANSACTIONS.md). + +### Common Hashing and Types + +Useful for tools that need protocol constants or hashing compatible with the node. + +Common areas: + +- `common::types` +- `common::skein` +- `common::network_paths_and_settings` +- `common::cli_prompts` + +Typical uses: + +- access transaction type IDs +- access fee constants +- hash text or bytes with the same Skein helpers used by the node +- resolve network-scoped paths +- share CLI prompt behavior across tools + +### RPC + +Useful for network clients, node tools and server command development. + +Common areas: + +- `rpc::command_maps` +- `rpc::commands` +- `rpc::client` +- `rpc::server` +- `rpc::structs` + +Typical uses: + +- map command IDs to RPC behavior +- implement a new RPC command handler +- inspect client request helpers +- inspect response structures + +For raw client handshake notes, see [DEV_CLIENTS.md](DEV_CLIENTS.md). + +### Standalone Tool Connections + +Useful for CLI tools that need to connect to a peer without acting like a mining node. + +Common areas: + +- `standalone_tools::connections::handshake` +- `standalone_tools::connections::sending_request` +- `standalone_tools::connections::transaction_creator` + +Typical uses: + +- perform the authenticated client handshake +- send a binary RPC request +- decode reply payloads for CLI output + +### Torrent and Orphan Handling + +Useful for tools that inspect block synchronization behavior. + +Common areas: + +- `torrent` +- `orphans` +- `records::staged_torrents` + +Typical uses: + +- read and decode torrent metadata +- validate torrent data before block download +- inspect staged torrent candidates +- understand orphan correction and replay behavior + +### Records and Storage + +Useful for tools that inspect local chain state or database-backed records. + +Common areas: + +- `records::record_chain` +- `records::balance_sheet` +- `records::wallet_registry` +- `records::postgres` +- `records::memory` +- `records::network_mapping` + +Typical uses: + +- read saved chain records +- inspect balance sheets +- resolve registered wallets or vanity addresses +- query or maintain PostgreSQL-backed lookup records +- inspect in-memory connection state + +## Adding New Public API + +Before making a new function public, check whether it is public for one of these reasons: + +- another module must call it +- a CLI tool needs it +- an external developer should reasonably use it +- Rustdoc should expose it as part of a stable integration surface + +If a function only wraps one other function and adds no validation, conversion, ownership boundary or domain meaning, prefer calling the original function directly. + +## Adding New CLI Tools + +Standalone CLI tools live in `src/bin`. + +When adding a tool: + +- use `common::cli_prompts` for user prompts +- use `standalone_tools::connections` for RPC client requests +- keep RPC payloads binary across the TCP stream +- print human-readable or JSON-decoded output only after the binary response is received +- add the tool to [CLI_TOOLS.md](CLI_TOOLS.md) + +## Adding New RPC Commands + +RPC command handlers should live under `src/rpc/commands`. + +When adding a command: + +- add or update the command ID in `rpc::command_maps` +- put the command handler in `rpc::commands` +- keep inline code in the server loop minimal +- send and receive binary payloads across the stream +- return through `RpcResponse` directly +- add or update any matching CLI tool documentation + +## Documentation Map + +- [README.md](../README.md): build, configure and start a node +- [CLI_TOOLS.md](CLI_TOOLS.md): command-line tool reference +- [TRANSACTIONS.md](TRANSACTIONS.md): transaction behavior reference +- [POSTGRES.md](POSTGRES.md): manual PostgreSQL setup +- [SETTINGS.md](SETTINGS.md): `settings.ini` field reference +- [DEV_CLIENTS.md](DEV_CLIENTS.md): low-level client handshake and request notes diff --git a/docs/DEV_CLIENTS.md b/docs/DEV_CLIENTS.md new file mode 100644 index 0000000..3a1951c --- /dev/null +++ b/docs/DEV_CLIENTS.md @@ -0,0 +1,98 @@ +# Developer Client Notes + +This file contains low-level notes for building external clients or debugging the Contractless RPC client flow. It is not required for normal node setup. + +## RPC Clients + +RPC clients are not treated the same as mining nodes. A client must follow the handshake format exactly, or the server may classify it as a node/miner instead of a client. When that happens, client tools can hang, be rejected or behave unexpectedly. + +### Required Client Handshake Rules + +1. Use a valid wallet address and signature in the handshake. + +- The client must load a real wallet locally. +- The wallet encryption key is used only locally to open the wallet and sign the handshake. +- The wallet encryption key is not sent over the stream. +- The wallet address is sent as part of the handshake identity. + +2. The opening handshake message must be `aced`. + +- The client signs the hash of `aced`. +- The server verifies that signature against the wallet address supplied in the handshake. + +3. A client must advertise its IP with port `0`. + +- Example: `127.0.0.1:0` +- This is the rule that tells the server the connection is a client-style RPC connection rather than a mining node connection. +- The server checks whether the submitted `ip:port` is externally reachable. +- If the submitted port is open, the connection is treated as a miner/node. +- If the submitted port is `0` or not reachable, the connection is treated as a client. + +4. The client timestamp must be within 1 second of the server timestamp. + +- If the timestamp is too far off, the handshake is rejected. + +5. The submitted IP must match the caller IP seen by the server. + +- The server compares the claimed IP in the handshake against the actual remote peer IP. + +### Handshake Byte Layout + +The client sends: + +- `2` bytes: message (`aced`) +- `65` bytes: recoverable signature +- `21` bytes: wallet address +- `4` bytes: timestamp +- `18` bytes: `ip:port` + +Total client handshake size: + +- `110` bytes + +The server returns: + +- `2` bytes: message +- `65` bytes: recoverable signature +- `21` bytes: wallet address + +Total server handshake size: + +- `88` bytes + +### Server Return Messages + +After the client handshake is accepted: + +- miner/node connections receive `face` +- client connections receive `ecaf` + +RPC clients should expect `ecaf`. + +If you do not follow the client rules above, especially the `ip:0` rule, the server can classify your connection as a mining/node connection instead of a client connection. That will cause errors for standalone RPC tools. + +### RPC Request Format After Handshake + +After the handshake succeeds, the client sends: + +- `1` byte: command +- `3` bytes: request uid +- command-specific payload bytes if required + +Normal RPC replies are returned as: + +- `1` byte: reply command (`111`) +- `3` bytes: request uid +- `2` bytes: payload length +- payload bytes + +### Practical Note For Standalone Tools + +Standalone tools in `src/bin` should behave like clients, not mining nodes. Their handshake should therefore: + +- use a real wallet +- send `aced` +- sign it correctly +- advertise `your_ip:0` + +If a standalone tool advertises a real RPC/mining port instead of `0`, the server may treat it as a node connection and the tool can fail in confusing ways. diff --git a/docs/POSTGRES.md b/docs/POSTGRES.md new file mode 100644 index 0000000..de27c08 --- /dev/null +++ b/docs/POSTGRES.md @@ -0,0 +1,111 @@ +# PostgreSQL Setup + +Contractless uses PostgreSQL for transaction lookup and mempool-style records. The node creates or migrates its required tables and indexes on startup, so manual setup only needs PostgreSQL itself, a database, a login user, a password and permissions for that user. + +PostgreSQL 16 is the tested version. Newer PostgreSQL versions should work, but they have not been tested for launch. + +Download PostgreSQL from the official project downloads page: + + + +## Required Database Access + +The database user configured in `settings.ini` must be able to: + +- connect to the configured database +- create tables and indexes +- read, insert, update and delete rows in the tables it creates +- use sequences created for `BIGSERIAL` primary keys + +The simplest way to provide the required permissions is to make the Contractless user the owner of the Contractless database. + +## Linux Manual Setup + +Run the SQL as the PostgreSQL superuser. On many Linux installs this means using the `postgres` system user: + +```bash +sudo -u postgres psql +``` + +Create the database user: + +```sql +CREATE USER contractless WITH PASSWORD 'change_this_password'; +``` + +Create the database and make the Contractless user the owner: + +```sql +CREATE DATABASE contractless_db OWNER contractless; +``` + +If local password login is not already enabled, update `pg_hba.conf` so local TCP connections can authenticate with a password: + +```text +host all all 127.0.0.1/32 md5 +host all all ::1/128 md5 +``` + +Reload PostgreSQL after changing `pg_hba.conf`: + +```bash +sudo systemctl reload postgresql +``` + +Then add the matching values to the active PostgreSQL section of `settings.ini`: + +```ini +[Postgres-Testnet] +host = 127.0.0.1 +port = 5432 +user = contractless +password = change_this_password +dbname = contractless_db +``` + +## Windows Manual Setup + +Install PostgreSQL from the official PostgreSQL download page. PostgreSQL 16 is the tested version. + +Open the PostgreSQL SQL Shell, pgAdmin query tool or another PostgreSQL client as a superuser, then run: + +```sql +CREATE USER contractless WITH PASSWORD 'change_this_password'; +CREATE DATABASE contractless_db OWNER contractless; +``` + +Add the matching values to the active PostgreSQL section of `settings.ini`: + +```ini +[Postgres-Testnet] +host = 127.0.0.1 +port = 5432 +user = contractless +password = change_this_password +dbname = contractless_db +``` + +## Existing Database + +If the database already exists and is not owned by the Contractless user, either change the owner: + +```sql +ALTER DATABASE contractless_db OWNER TO contractless; +``` + +or grant the user enough rights to create and manage objects in the public schema: + +```sql +GRANT CONNECT ON DATABASE contractless_db TO contractless; +\c contractless_db +GRANT USAGE, CREATE ON SCHEMA public TO contractless; +ALTER SCHEMA public OWNER TO contractless; +``` + +Database ownership is preferred because Contractless creates and updates its schema automatically. + +## Startup Behavior + +When the node starts, it connects to PostgreSQL using the values in `settings.ini`, then runs the mempool schema setup. This creates the required tables, applies compatible migrations, creates indexes and starts from an empty live mempool. + +If PostgreSQL login succeeds but the user lacks ownership or create permissions, node startup will fail during the automatic table or index setup. diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md new file mode 100644 index 0000000..e7c761d --- /dev/null +++ b/docs/SETTINGS.md @@ -0,0 +1,191 @@ +# Settings Reference + +Contractless reads `settings.ini` during startup. Relative paths are resolved relative to the location of the loaded `settings.ini` file. Runtime storage paths are also scoped by active network, so testnet and mainnet data are kept in separate subfolders. + +The node loads `settings.ini` in this order: + +1. `--config ` +2. `SETTINGS_PATH` environment variable +3. `./settings.ini` +4. `settings.ini` beside the executable +5. platform fallback path + +On Linux, the fallback path is `/etc/contractless/settings.ini`. + +## `[Paths]` + +### `BLOCK_PATH` + +Base folder where block files are stored. The active network name is appended automatically. + +Example: + +```ini +BLOCK_PATH = "./blocks" +``` + +Testnet will store blocks under: + +```text +./blocks/testnet +``` + +Mainnet will store blocks under: + +```text +./blocks/mainnet +``` + +### `TORRENT_PATH` + +Base folder where torrent metadata, staged torrents and torrent-related files are stored. The active network name is appended automatically. + +### `DB_PATH` + +Base folder for the internal sled database used for non-PostgreSQL node state. The active network name is appended automatically. + +### `WALLET_PATH` + +Base folder where wallet files are stored. The active network name and `WALLET_NAME` are appended automatically. + +Example: + +```ini +WALLET_PATH = "./wallets" +WALLET_NAME = "contractless.wallet" +``` + +Testnet will load the wallet from: + +```text +./wallets/testnet/contractless.wallet +``` + +### `WALLET_NAME` + +Name of the encrypted wallet file to load at startup. + +### `BALANCE_SHEET` + +Base folder where balance sheet files are stored. The active network name is appended automatically. + +## `[Settings]` + +### `LOG_PATH` + +Base folder where node log files are written. The active network name is appended automatically so testnet and mainnet nodes can run side by side without writing to the same rotating log files. + +Example: + +```ini +LOG_PATH = "./logs" +``` + +Testnet will write logs under: + +```text +./logs/testnet +``` + +### `LOG_LEVEL` + +Runtime log filter. + +Valid common values: + +- `info`: show info, warning and error logs. +- `warn`: show warning and error logs. +- `error`: show only error logs. +- `off`: disable log output. + +The logger also supports more advanced module-level filters through `flexi_logger`, but normal nodes should use one of the simple values above. + +### `IP` + +Reachable IP address or domain name announced by this node during handshakes. + +Use `127.0.0.1` only for local testing. A public node should use a public IP address or a domain name that resolves to the node and reaches the configured RPC port. + +### `RPC_PORT` + +Mainnet RPC port. This value is used when the node is built with the `mainnet` feature. + +Mainnet builds are currently disabled during the testnet phase, but the setting exists for launch. + +### `TESTNET_RPC_PORT` + +Testnet RPC port. This value is used when the node is built with the `testnet` feature. + +Public testnet nodes must allow inbound traffic to this port. + +### `INCOMING_CONNECTIONS` + +Maximum number of incoming peer connections allowed by the node. + +This should be greater than `0`. A recommended value is `50`. + +If the limit is reached, new incoming peers are rejected during handshake. + +### `OUTGOING_CONNECTIONS` + +Maximum number of outgoing peer connections the node tries to discover and maintain. + +This should be greater than `0`. A recommended value is `10`. + +### `THREADS` + +Number of async worker tasks used during each mining nonce round. + +Contractless mining only searches one byte of nonce space per timestamp, so all workers split the fixed `0..255` nonce range. More threads do not increase the total nonce space; they only split the fixed search range across more tasks. + +Allowed values: + +- `1` +- `2` +- any multiple of `4` + +The value must be between `1` and `256`. + +## `[Piggyback]` + +Piggyback entries are startup peers. The node tries these peers when it first starts so it can join the network and discover other nodes. + +```ini +[Piggyback] +PIGGYBACK_1 = "1.2.3.4:50053" +PIGGYBACK_2 = "5.6.7.8:50053" +``` + +The node only needs one live piggyback peer to begin discovery. Additional entries are backups if earlier entries are offline or unreachable. + +Piggyback entries should be public, routable peers. Private, loopback and invalid addresses are filtered before startup connections begin. + +## `[Postgres-Testnet]` + +PostgreSQL connection settings used by testnet builds. + +```ini +[Postgres-Testnet] +host = 127.0.0.1 +port = 5432 +user = contractless +password = change_this_password +dbname = contractless_db +``` + +The configured database must already exist and the configured user must be able to create tables and indexes. The node creates or migrates its own tables on startup. + +## `[Postgres]` + +PostgreSQL connection settings used by mainnet builds. + +```ini +[Postgres] +host = 127.0.0.1 +port = 5432 +user = contractless +password = change_this_password +dbname = contractless_db +``` + +Mainnet builds are currently disabled during the testnet phase, but this section is kept so the same settings file can be prepared for launch. diff --git a/docs/TRANSACTIONS.md b/docs/TRANSACTIONS.md new file mode 100644 index 0000000..78519c7 --- /dev/null +++ b/docs/TRANSACTIONS.md @@ -0,0 +1,533 @@ +# Contractless Transaction Reference + +Contractless transactions are native protocol operations. They are not smart contracts. Each transaction is validated before block inclusion, then the balance-sheet changes are applied when the block is saved. If an orphan correction rolls a block back, the matching balance-sheet operations are undone before replacement blocks are replayed. + +This reference explains what each transaction does, which tool creates it, what fee or tip rules apply and how it changes balances. + +## Table of Contents + +- [Shared Concepts](#shared-concepts) +- [Transaction Type IDs](#transaction-type-ids) +- [Fee Summary](#fee-summary) +- [Transaction Flow](#transaction-flow) +- [Wallet Registration](#wallet-registration) +- [Transaction Types](#transaction-types) +- [Loan Transactions](#loan-transactions) +- [Validation Failures](#validation-failures) + +## Shared Concepts + +### Addresses + +User-facing addresses are network-specific. Mainnet short addresses end with `.clc`; testnet short addresses end with `.cltc`. + +Vanity addresses are display addresses. When a normal transaction accepts a vanity address, the node resolves it to the real short address before the transaction is saved in blocks, mempool records or balance-sheet records. The only transaction that stores a vanity address as its own transaction field is the vanity-address registration transaction. + +### Timestamps + +Each transaction carries its own timestamp. The transaction timestamp records when the transaction was created, not when it was mined. Blocks also carry their own timestamp, which records when the miner discovered and assembled the valid block. + +### Fees + +Most transactions include a `txfee` field paid in the base network currency. On mainnet that is CLC. On testnet that is CLTC. The fee is removed from the sender and credited to the miner when the transaction is included in a saved block. + +Base-currency transfers use a percentage minimum fee: + +```text +minimum fee = ceil(value * 1 / 100) +``` + +Token and NFT transfers use a fixed non-base transfer minimum of 1 CLC or CLTC. + +Users may pay more than the minimum fee. Higher fees can improve the chance that a transaction is selected when miners have more valid transactions available than will fit in the next block. + +### Token Tips + +Some transaction types also include a token tip. A token tip is different from the base-currency transaction fee. It is paid in the asset involved in the transaction and credited to the miner balance sheet for that asset. + +Token tips are used by swap transactions and loan payment transactions. + +### Signatures + +Transactions are signed by the wallet private key. Multi-party transactions require more than one signature. For example, a swap is created by one party, then validated and signed by the second party before broadcast. + +### Transaction Hashes + +Transaction hashes identify the signed transaction. They are used for lookup, mempool records, duplicate checks and block transaction records. + +### Mempool and Block Validation + +Broadcast transactions are checked before they are accepted into peer records. They are checked again when a miner attempts to include them in a block. This keeps invalid, stale or already-spent transaction state from being accepted just because it passed an earlier network request. + +## Transaction Type IDs + +Transaction type bytes are the canonical IDs used in blocks, mempool routing and verification dispatch. + +| ID | Transaction | Purpose | +| --- | --- | --- | +| 0 | Genesis | Creates the genesis network record | +| 1 | Rewards | Pays the miner reward | +| 2 | Transfer | Moves CLC, CLTC, tokens or NFTs | +| 3 | Create token | Creates a new token definition | +| 4 | Create NFT | Creates a new NFT definition | +| 5 | Marketing | Creates a marketing record | +| 6 | Swap | Exchanges assets between two parties | +| 7 | Loan | Creates a collateralized loan contract | +| 8 | Loan payment | Pays against an existing loan | +| 9 | Collateral claim | Claims collateral from a defaulted loan | +| 10 | Burn | Burns a token or NFT | +| 11 | Issue token | Issues more supply for an existing token | +| 12 | Vanity address | Registers or updates a vanity address | + +## Fee Summary + +Fixed fees are stored in the smallest coin unit. The values below are shown as human-readable CLC or CLTC. + +| Transaction | Minimum fee | +| --- | --- | +| Base transfer | 1% of transfer value, rounded up | +| Token or NFT transfer | 1 | +| Create token | 500 | +| Create NFT | 0.5 | +| Marketing | 1 | +| Swap | 1 from each party | +| Loan | 3 from lender | +| Loan payment | 0.01 from payer | +| Collateral claim | 3 | +| Burn | 0.0001 | +| Issue token | 100 | +| Vanity address | 5 | + +Swap transactions and loan payment transactions may also include token tips paid to the miner in the related asset. + +## Transaction Flow + +The normal user flow is: + +```text +create_* tool -> signed JSON -> broadcast_transaction -> mempool checks -> block validation -> balance-sheet update +``` + +For two-party transactions: + +```text +create_swap_tx/create_loan_tx -> verify_sign_swap_tx/verify_sign_loan_tx -> broadcast_transaction +``` + +Wallet registration uses `register_wallet` directly because it submits the wallet mapping to a peer instead of creating a normal block transaction JSON file. + +## Wallet Registration + +Wallet registration is a network registry operation, not a block transaction type. It submits a signed short-address to long-address mapping so peers can resolve and verify wallet identities during later network operations. + +Created with: + +```text +register_wallet +``` + +Requires tip: + +No. + +Minimum fee: + +None. + +Balance-sheet effect: + +None. + +Notes: + +The command returns `Wallet registered: ` when the peer accepts the mapping. + +## Transaction Types + +### Genesis + +Purpose: + +Creates the genesis transaction for the chain. + +Created with: + +```text +node startup / genesis creation path +``` + +Requires tip: + +No. + +Minimum fee: + +None. + +Balance-sheet effect: + +Creates the initial chain state required for block zero. + +Notes: + +Users do not normally create genesis transactions with a CLI tool. + +### Rewards + +Purpose: + +Pays the miner reward for a saved block. + +Created with: + +```text +mining process +``` + +Requires tip: + +No. + +Minimum fee: + +None. + +Balance-sheet effect: + +Credits the miner base-currency balance sheet with the block reward. During the first 100 blocks mined by a registered miner, the reward transaction is valid but has zero value. + +Notes: + +Reward transactions are created by the miner, not by a standalone user transaction tool. + +### Transfer + +Purpose: + +Moves base currency, tokens or NFTs from one wallet to another. + +Created with: + +```text +create_transfer_tx +``` + +Requires tip: + +No. + +Minimum fee: + +Base-currency transfers pay 1% of the transferred value, rounded up. Token and NFT transfers pay the fixed non-base minimum fee of 1 CLC or CLTC. + +Balance-sheet effect: + +Removes the transferred value and transaction fee from the sender. Adds the transferred value to the receiver. Adds the transaction fee to the miner. + +Notes: + +Vanity receiver addresses are resolved to the real short address before the transaction is stored. + +### Create Token + +Purpose: + +Creates a new token definition. + +Created with: + +```text +create_tokens_tx +``` + +Requires tip: + +No. + +Minimum fee: + +500 CLC or CLTC. + +Balance-sheet effect: + +Removes the transaction fee from the creator and credits the fee to the miner. Creates the token record used by later issue-token and transfer transactions. + +Notes: + +Token names are fixed-length protocol fields. Duplicate token names are rejected. + +### Issue Token + +Purpose: + +Issues additional supply for an existing token. + +Created with: + +```text +create_issue_token_tx +``` + +Requires tip: + +No. + +Minimum fee: + +100 CLC or CLTC. + +Balance-sheet effect: + +Removes the transaction fee from the issuer and credits the fee to the miner. Credits the issued token amount to the issuer if the token rules allow more supply. + +Notes: + +Hard-limited tokens cannot be issued past their allowed supply. + +### Create NFT + +Purpose: + +Creates a new NFT definition. This can be a 1/1 NFT or a series NFT. + +Created with: + +```text +create_nft_tx +``` + +Requires tip: + +No. + +Minimum fee: + +0.5 CLC or CLTC. + +Balance-sheet effect: + +Removes the transaction fee from the creator and credits the fee to the miner. Creates the NFT record and credits the NFT ownership state to the creator. + +Notes: + +Use series number `0` for a 1/1 NFT in lookup tooling. + +### Burn + +Purpose: + +Destroys a token or NFT balance. + +Created with: + +```text +create_burn_tx +``` + +Requires tip: + +No. + +Minimum fee: + +0.0001 CLC or CLTC. + +Balance-sheet effect: + +Removes the burned asset from the burner. Removes the base-currency transaction fee from the burner and credits the fee to the miner. + +Notes: + +Base currency cannot be burned with this transaction type. + +### Marketing + +Purpose: + +Creates a marketing record with campaign metadata, impression value and click value. + +Created with: + +```text +create_marketing_tx +``` + +Requires tip: + +No. + +Minimum fee: + +1 CLC or CLTC. + +Balance-sheet effect: + +Removes the transaction fee from the advertiser and credits the fee to the miner. Stores the marketing record for later lookup. + +Notes: + +The marketing record stores its own transaction data. It does not move a token or NFT balance to another wallet. + +### Vanity Address + +Purpose: + +Registers or updates the wallet vanity address mapping. + +Created with: + +```text +create_vanity_tx +``` + +Requires tip: + +No. + +Minimum fee: + +5 CLC or CLTC. + +Balance-sheet effect: + +Removes the transaction fee from the registering wallet and credits the fee to the miner. Registers the vanity address mapping so future user-facing input can resolve to the wallet short address. + +Notes: + +The vanity-address transaction is the only normal transaction type that stores the vanity address itself. Other transactions store the resolved short address. + +### Swap + +Purpose: + +Exchanges assets between two wallets. + +Created with: + +```text +create_swap_tx +verify_sign_swap_tx +``` + +Requires tip: + +Yes. Swap transactions include `tip1` and `tip2`, paid in the assets being swapped. + +Minimum fee: + +1 CLC or CLTC from each party. + +Balance-sheet effect: + +Removes each offered asset from its sender and credits it to the other party. Removes each party's base-currency transaction fee and credits those fees to the miner. Removes token tips from the parties and credits the tips to the miner in the related assets. + +Notes: + +The first party creates the swap JSON. The second party validates and signs it before broadcast. + +## Loan Transactions + +Loan, loan payment and collateral claim transactions are related. The loan transaction creates the contract terms. Loan payment transactions pay against that contract. Collateral claim transactions enforce the collateral path when the contract rules allow it. + +### Loan + +Purpose: + +Creates a collateralized loan contract between lender and borrower. + +Created with: + +```text +create_loan_tx +verify_sign_loan_tx +``` + +Requires tip: + +No. + +Minimum fee: + +3 CLC or CLTC paid by the lender. + +Balance-sheet effect: + +Moves or locks the relevant loan and collateral values according to the contract terms. Removes the lender transaction fee and credits it to the miner. + +Notes: + +The first party creates the loan JSON. The second party validates and signs it before broadcast. + +### Loan Payment + +Purpose: + +Pays against an existing loan contract. + +Created with: + +```text +create_loan_payment_tx +``` + +Requires tip: + +Yes. Loan payment transactions include a miner tip in the loan token. + +Minimum fee: + +0.01 CLC or CLTC. + +Balance-sheet effect: + +Removes the payment amount, token tip and base-currency transaction fee from the payer. Credits the payment to the loan receiver according to the contract. Credits the base fee and token tip to the miner. + +Notes: + +Loan payments must match the active loan contract state. + +### Collateral Claim + +Purpose: + +Claims collateral from a loan contract when the contract rules allow it. + +Created with: + +```text +create_collateral_claim_tx +``` + +Requires tip: + +No. + +Minimum fee: + +3 CLC or CLTC. + +Balance-sheet effect: + +Moves eligible collateral to the claimant according to the contract. Removes the base-currency transaction fee from the claimant and credits the fee to the miner. + +Notes: + +Collateral claims fail when the loan state does not allow collateral to be claimed. + +## Validation Failures + +Common validation failures include: + +- Invalid network address. +- Invalid vanity address mapping. +- Invalid transaction signature. +- Missing second signature on a two-party transaction. +- Transaction fee below the required minimum. +- Insufficient base-currency balance for fees. +- Insufficient token or NFT balance for the asset being moved. +- Duplicate token, NFT or vanity registration. +- Token supply rules reject the requested issue amount. +- Loan contract state does not match the payment or collateral claim. +- Transaction has already been accepted or conflicts with current mempool state. + +When a transaction fails validation, fix the transaction fields or wallet state before broadcasting again. diff --git a/settings.ini b/settings.ini new file mode 100644 index 0000000..82d186b --- /dev/null +++ b/settings.ini @@ -0,0 +1,39 @@ +; settings.ini + +[Paths] +BLOCK_PATH = "./blocks" +TORRENT_PATH = "./torrents" +DB_PATH = "./state" +BALANCE_SHEET = "./balance_sheet" +LOG_PATH = "./logs" +WALLET_PATH = "/home/viraladmin/chatgpt/wallets" +WALLET_NAME = "contractless.wallet" + + +[Settings] +LOG_LEVEL = "disabled" +PUBLIC_IP = "your_public_ip_address" +LISTEN_IP = "0.0.0.0" +RPC_PORT = "50055" +TESTNET_RPC_PORT = "50050" +INCOMING_CONNECTIONS = "100" +OUTGOING_CONNECTIONS = "10" +; THREADS must 1, 2, or a multple of 4 +THREADS = "8" + +[Piggyback] +PIGGYBACK_1 = "contractless.dev:50050" + +[Postgres-Testnet] +host = 127.0.0.1 +port = 5432 +user = contractless2 +password = your_paddword +dbname = contractless_db2 + +[Postgres] +host = 127.0.0.1 +port = 5432 +user = contractless +password = your_password +dbname = contractless_db diff --git a/src/bin/average_block_time_checker.rs b/src/bin/average_block_time_checker.rs new file mode 100644 index 0000000..ba2cdf5 --- /dev/null +++ b/src/bin/average_block_time_checker.rs @@ -0,0 +1,94 @@ +use blockchain::common::network_paths_and_settings::block_extension_and_paths; +use blockchain::env; +use blockchain::fs; +use blockchain::Duration; +use blockchain::Error; + +fn calculate_average_time_between_timestamps( + start_block: u32, + stop_block: u32, + directory: &str, +) -> Result, Box> { + let mut timestamps: Vec = Vec::new(); + + // Iterate over all files in the directory + for entry in fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + + let ( + _network_name, + _padded_base_coin, + extension, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + if path.is_file() && path.extension().is_some_and(|ext| *ext == *extension) { + if let Some(file_name) = path.file_stem() { + if let Some(file_name_str) = file_name.to_str() { + if let Ok(block_number) = file_name_str.parse::() { + if block_number >= start_block && block_number <= stop_block { + let file_content = fs::read(&path)?; + if file_content.len() >= 4 { + let timestamp_bytes = &file_content[0..4]; + let timestamp = u32::from_le_bytes(timestamp_bytes.try_into()?); + timestamps.push(timestamp); + } + } + } + } + } + } + } + + // Sort the timestamps + timestamps.sort(); + + // Calculate the differences between consecutive timestamps + let mut time_differences: Vec = Vec::new(); + for i in 1..timestamps.len() { + let time_difference = timestamps[i] - timestamps[i - 1]; + time_differences.push(Duration::from_secs(time_difference as u64)); + } + + // Calculate the average time difference + if !time_differences.is_empty() { + let total_duration: Duration = time_differences.iter().sum(); + let average_time = total_duration / (time_differences.len() as u32); + Ok(Some(average_time)) + } else { + Ok(None) + } +} + +fn main() -> Result<(), Box> { + // Collect command-line arguments + let args: Vec = env::args().collect(); + + if args.len() != 4 { + println!("Usage: {} ", args[0]); + return Ok(()); + } + + let start_block: u32 = args[1].parse()?; + let stop_block: u32 = args[2].parse()?; + let directory = &args[3]; + + match calculate_average_time_between_timestamps(start_block, stop_block, directory)? { + Some(average_time) => { + println!( + "The average time between timestamps is {:.2} seconds.", + average_time.as_secs_f64() + ); + } + None => { + println!("Not enough data to calculate an average time difference."); + } + } + + Ok(()) +} diff --git a/src/bin/broadcast_transaction.rs b/src/bin/broadcast_transaction.rs new file mode 100644 index 0000000..e3a2fbc --- /dev/null +++ b/src/bin/broadcast_transaction.rs @@ -0,0 +1,149 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::from_slice; +use blockchain::read; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::wallets::structures::{SavedWallet, Wallet}; +use blockchain::Path; +use serde_json::Value; + +fn display_vanity_address(short_address: &str) -> Option { + // Wallet display should hide the left padding used by vanity address bytes. + let (payload, network_suffix) = short_address.rsplit_once('.')?; + let trimmed_payload = payload.trim_start(); + if trimmed_payload.is_empty() { + return None; + } + Some(format!("{trimmed_payload}.{network_suffix}")) +} + +async fn persist_local_vanity_address(encryption_key: &str, tx_json: &str) { + // Only successful vanity transactions update the local saved wallet display field. + let Ok(value) = serde_json::from_str::(tx_json) else { + return; + }; + + let txtype = value + .get("txtype") + .and_then(|v| v.as_u64()) + .unwrap_or_default() as u8; + if txtype != 12 { + return; + } + + let Some(vanity_address) = value.get("vanity_address").and_then(|v| v.as_str()) else { + return; + }; + let Some(sender_address) = value.get("address").and_then(|v| v.as_str()) else { + return; + }; + + // Read the saved wallet and only update it if it matches the vanity transaction sender. + let wallet_path = Wallet::get_wallet_path().await; + let wallet_bytes = match read(Path::new(&wallet_path)).await { + Ok(bytes) => bytes, + Err(_) => return, + }; + + let mut saved_wallet: SavedWallet = match from_slice(&wallet_bytes) { + Ok(wallet) => wallet, + Err(_) => return, + }; + + let _ = encryption_key; + + if saved_wallet.short_address.trim() != sender_address.trim() { + return; + } + + let Some(display_vanity) = display_vanity_address(vanity_address) else { + return; + }; + + saved_wallet.vanity_address = Some(display_vanity); + saved_wallet.save_the_wallet(Path::new(&wallet_path)).await; +} + +#[tokio::main] +async fn main() { + // Broadcast takes one signed transaction JSON file and submits it to a peer. + let hashmap_key = generate_uid(); + + let args: Vec = env::args().collect(); + + let filename = match args.get(1) { + Some(filename) => filename, + None => { + eprintln!("Usage: {} ", args[0]); + return; + } + }; + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Read the signed transaction JSON exactly as it will be serialized for broadcast. + let json = match tokio::fs::read_to_string(filename).await { + Ok(content) => content, + Err(err) => { + eprintln!("Error reading file: {err}"); + return; + } + }; + let txid = serde_json::from_str::(&json).ok().and_then(|value| { + value + .get("hash") + .and_then(|hash| hash.as_str()) + .map(|hash| hash.to_string()) + }); + + // Command 8 tells sending_request to build a transaction broadcast request. + let rpc_command = 8; + + // Try each configured peer until one accepts or returns a response. + let connections = get_connections().await; + + let mut connected: bool = false; + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + json.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + match result { + Ok(response) => { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + // When a vanity tx broadcasts successfully, keep the local wallet display in sync. + if trimmed == "successful_broadcast: true" { + persist_local_vanity_address(&encryption_key, &json).await; + if let Some(hash) = &txid { + println!("{hash}"); + } + } + connected = true; + } + } + Err(_) => { + connected = false; + } + } + } + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/contractless-submit-key.rs b/src/bin/contractless-submit-key.rs new file mode 100644 index 0000000..6de598e --- /dev/null +++ b/src/bin/contractless-submit-key.rs @@ -0,0 +1,86 @@ +#[cfg(windows)] +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +#[cfg(windows)] +use blockchain::startup::unlock_pipe::pipe_name; +#[cfg(windows)] +use blockchain::startup::unlock_structs::{UnlockPipeRequest, UnlockPipeResponse}; +#[cfg(windows)] +use blockchain::{env, from_slice, to_string}; +#[cfg(windows)] +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +#[cfg(windows)] +use tokio::net::windows::named_pipe::ClientOptions; + +#[cfg(windows)] +#[tokio::main] +async fn main() { + // Keep the Windows-only service helper small: run the pipe request and print any failure. + if let Err(err) = run().await { + eprintln!("{err}"); + std::process::exit(1); + } +} + +#[cfg(windows)] +async fn run() -> Result<(), Box> { + // The helper supports simple service checks as well as submitting the wallet key. + let command = env::args().nth(1).unwrap_or_else(|| "submit".to_string()); + let request = match command.as_str() { + "ping" => UnlockPipeRequest::Ping, + "status" => UnlockPipeRequest::Status, + "submit" => { + // Wallet keys are hidden at the terminal and rejected if the user enters nothing. + let wallet_key = prompt_hidden_nonempty( + "Please enter your wallet key: ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + UnlockPipeRequest::SubmitKey { wallet_key } + } + other => { + return Err(format!( + "Unsupported command '{other}'. Use 'submit', 'ping', or 'status'." + ) + .into()) + } + }; + + let pipe = pipe_name(); + let payload = to_string(&request)?; + + // The service unlock pipe uses a length-prefixed JSON payload. + let mut client = ClientOptions::new().open(&pipe)?; + client.write_u32_le(payload.len() as u32).await?; + client.write_all(payload.as_bytes()).await?; + + // Read the length-prefixed service reply and decode the response enum. + let response_len = client.read_u32_le().await? as usize; + let mut response_bytes = vec![0u8; response_len]; + client.read_exact(&mut response_bytes).await?; + + let response: UnlockPipeResponse = from_slice(&response_bytes)?; + + match response { + UnlockPipeResponse::Pong => { + println!("Pong"); + } + UnlockPipeResponse::Status { state } => { + println!("Service state: {state:?}"); + } + UnlockPipeResponse::KeyAccepted => { + println!("Wallet key accepted."); + } + UnlockPipeResponse::Error { message } => { + return Err(message.into()); + } + } + + Ok(()) +} + +#[cfg(not(windows))] +fn main() { + // Non-Windows builds keep the binary present but clearly report that the pipe does not exist. + eprintln!("contractless-submit-key is only supported on Windows."); + std::process::exit(1); +} diff --git a/src/bin/create_burn_tx.rs b/src/bin/create_burn_tx.rs new file mode 100644 index 0000000..dea59ed --- /dev/null +++ b/src/bin/create_burn_tx.rs @@ -0,0 +1,147 @@ +use blockchain::blocks::burn::{BurnTransaction, UnsignedBurnTransaction}; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::network_paths_and_settings::block_extension_and_paths; +use blockchain::common::types::BURN_FEE; +use blockchain::json; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +// Pad the asset name so it matches the 15-byte on-chain asset format. +fn pad_to_width(input: &str, width: usize) -> String { + let mut result = String::with_capacity(width); + let _ = std::fmt::write( + &mut result, + format_args!("{input: f64 { + // Fees are stored as atomic units and displayed as whole-coin decimals for the CLI. + value as f64 / 100_000_000.0 +} + +#[tokio::main] +async fn main() { + // Burn transactions use type 10 and the current local timestamp. + let txtype = 10; + let timestamp = Utc::now().timestamp() as u32; + + // Burnable assets are tokens or NFTs, never the base coin for this transaction type. + let coin_name = prompt_visible("Please enter the token or NFT name you wish to burn: ").await; + let coin = pad_to_width(&coin_name.trim().to_lowercase(), 15); + + if coin.trim().to_lowercase() == block_extension_and_paths().1.trim().to_lowercase() { + println!("Base coin cannot be burned with this transaction type."); + return; + } + + // Series 0 is used for fungible tokens and 1/1 NFTs; series NFTs use their item number. + let nft_series_string = prompt_visible( + "Please enter the NFT series number to burn, or 0 for standard tokens / 1of1 NFTs: ", + ) + .await; + let nft_series: u32 = nft_series_string + .trim() + .parse() + .expect("Please enter a valid NFT series number."); + + // Values are entered as display units and saved as atomic units. + let value_string = prompt_visible("Please enter the burn amount: (use 1.0 for NFTs) ").await; + let value_f64: f64 = value_string + .trim() + .parse() + .expect("Please enter a valid amount."); + let value = (value_f64 * 100_000_000.0).round() as u64; + + let txfee_prompt = format!( + "Please enter the amount for the fee: (eg. 0.0001, minimum fee {:.8})", + display_fee(BURN_FEE) + ); + let txfee_string = prompt_visible(&format!("{txfee_prompt}: ")).await; + let txfee_f64: f64 = txfee_string + .trim() + .parse() + .expect("Please enter a valid fee."); + let txfee = (txfee_f64 * 100_000_000.0).round() as u64; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Load the wallet that owns the asset being burned. + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let private_key = &wallet.saved.private_key; + let address = &wallet.saved.short_address; + + // The burn address must be a valid current-network short address. + if !Wallet::short_address_validation(address.trim_matches('"')) { + println!("burner wallet invalid: {}", &address); + return; + } + + // Build and sign the burn transaction before writing the broadcast JSON. + let unsigned_burn = UnsignedBurnTransaction::new( + txtype, + timestamp, + address.trim_matches('"'), + &coin, + nft_series, + value, + txfee, + ) + .await; + + let burn = match BurnTransaction::new(unsigned_burn, private_key).await { + Ok(tx) => tx, + Err(err) => { + eprintln!("Failed to sign burn transaction: {err}"); + return; + } + }; + + let hashed_data = burn.unsigned_burn.hash().await; + let signature = burn.signature.clone(); + + // Save the signed transaction as JSON so broadcast_transaction can submit it later. + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "address": address.trim_matches('"'), + "coin": coin, + "nft_series": nft_series, + "value": value, + "txfee": txfee, + "hash": hashed_data, + "signature": signature, + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Save the transaction JSON into the standard transactions folder for broadcasting. + let dir_path = "./transactions"; + if let Err(error) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {error}"); + return; + } + + let file_path = format!("{dir_path}/{hashed_data}.json"); + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + if let Err(error) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {error}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_collateral_claim_tx.rs b/src/bin/create_collateral_claim_tx.rs new file mode 100644 index 0000000..6dad2d0 --- /dev/null +++ b/src/bin/create_collateral_claim_tx.rs @@ -0,0 +1,117 @@ +use blockchain::blocks::collateral::{ + CollateralClaimTransaction, UnsignedCollateralClaimTransaction, +}; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::types::COLLATERAL_FEE; +use blockchain::json; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +fn display_fee(value: u64) -> f64 { + // Fees are stored as atomic units and displayed as whole-coin decimals for the CLI. + value as f64 / 100_000_000.0 +} + +#[tokio::main] +async fn main() { + // Collateral claims use transaction type 9 and the current local timestamp. + let txtype = 9; + let timestamp = Utc::now().timestamp() as u32; + + // The contract hash identifies which loan contract collateral is being claimed. + let contract_hash = prompt_visible("Please enter the loan contract hash: ").await; + let contract_hash = contract_hash.trim().to_string(); + + // Store the fee in atomic units so the JSON can be signed and broadcast directly. + let txfee_prompt = format!( + "Please enter the amount for the fee: (eg. 1.0, minimum fee {:.8})", + display_fee(COLLATERAL_FEE) + ); + let txfee_string = prompt_visible(&format!("{txfee_prompt}: ")).await; + let txfee_f64: f64 = txfee_string + .trim() + .parse() + .expect("Please enter a valid fee"); + let txfee = (txfee_f64 * 100_000_000.0).round() as u64; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Load the wallet so the transaction can use the saved short address and private key. + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let private_key = &wallet.saved.private_key; + let address = &wallet.saved.short_address; + + // Only a valid current-network short address should be written into the transaction. + if !Wallet::short_address_validation(address.trim_matches('"')) { + println!("wallet invalid: {address}"); + return; + } + + // Build the unsigned body first so the signed transaction hash matches validation logic. + let unsigned_claim = UnsignedCollateralClaimTransaction::new( + txtype, + timestamp, + &contract_hash, + address.trim_matches('"'), + txfee, + ) + .await; + + // Signing attaches the wallet signature that miners verify before accepting the claim. + let claim = match CollateralClaimTransaction::new(unsigned_claim, private_key).await { + Ok(tx) => tx, + Err(err) => { + eprintln!("Failed to sign collateral claim transaction: {err}"); + return; + } + }; + + let hash = claim.unsigned_collateral_claim.hash().await; + let signature = claim.signature.clone(); + + // Save the signed transaction as JSON so broadcast_transaction can submit it later. + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "contract_hash": contract_hash, + "address": address.trim_matches('"'), + "txfee": txfee, + "hash": hash, + "signature": signature, + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Transaction creation tools all write into ./transactions using the transaction hash. + let dir_path = "./transactions"; + if let Err(e) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {e}"); + return; + } + + let file_path = format!( + "{}/{}.json", + dir_path, + output["hash"].as_str().unwrap_or_default() + ); + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + if let Err(e) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_issue_token_tx.rs b/src/bin/create_issue_token_tx.rs new file mode 100644 index 0000000..5c33f74 --- /dev/null +++ b/src/bin/create_issue_token_tx.rs @@ -0,0 +1,131 @@ +use blockchain::blocks::issue_token::{IssueTokenTransaction, UnsignedIssueTokenTransaction}; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::types::ISSUE_TOKEN_FEE; +use blockchain::json; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +// Pad the ticker so it matches the fixed-width 15-byte on-chain format. +fn pad_to_width(input: &str, width: usize) -> String { + let mut result = String::with_capacity(width); + let _ = std::fmt::write( + &mut result, + format_args!("{input: f64 { + // Fees are stored as atomic units and displayed as whole-coin decimals for the CLI. + value as f64 / 100_000_000.0 +} + +#[tokio::main] +async fn main() { + // Issue-token transactions use type 11 and the current local timestamp. + let txtype = 11; + let timestamp = Utc::now().timestamp() as u32; + + // The ticker is stored as a fixed-width 15-byte asset field. + let ticker_name = + prompt_visible("Please enter the ticker / token name you wish to issue more of: ").await; + let ticker = pad_to_width(&ticker_name.trim().to_lowercase(), 15); + + // Token amounts are entered as whole tokens and stored as atomic units. + let number_string = + prompt_visible("Please enter the amount of additional tokens to issue: ").await; + let number_u64: u64 = number_string + .trim() + .parse() + .expect("Please enter a valid token amount."); + let number = ((number_u64 as f64) * 100_000_000.0).round() as u64; + + let txfee_prompt = format!( + "Please enter the amount for the fee: (eg. 0.0001, minimum fee {:.8})", + display_fee(ISSUE_TOKEN_FEE) + ); + let txfee_string = prompt_visible(&format!("{txfee_prompt}: ")).await; + let txfee_f64: f64 = txfee_string + .trim() + .parse() + .expect("Please enter a valid fee."); + let txfee = (txfee_f64 * 100_000_000.0).round() as u64; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Load the creator wallet that signs the additional token issuance. + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let private_key = &wallet.saved.private_key; + let creator = &wallet.saved.short_address; + + // The creator address must be a valid current-network short address. + if !Wallet::short_address_validation(creator.trim_matches('"')) { + println!("creator wallet invalid: {creator}"); + return; + } + + // Build and sign the issue-token transaction before writing the broadcast JSON. + let unsigned_issue_token = UnsignedIssueTokenTransaction::new( + txtype, + timestamp, + creator.trim_matches('"'), + &ticker, + number, + txfee, + ) + .await; + + let issue_token = match IssueTokenTransaction::new(unsigned_issue_token, private_key).await { + Ok(tx) => tx, + Err(err) => { + eprintln!("Failed to sign issue-token transaction: {err}"); + return; + } + }; + + let hashed_data = issue_token.unsigned_issue_token.hash().await; + let signature = issue_token.signature.clone(); + + // Save the signed transaction as JSON so broadcast_transaction can submit it later. + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "creator": creator.trim_matches('"'), + "ticker": ticker, + "number": number, + "txfee": txfee, + "hash": hashed_data, + "signature": signature, + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Save the transaction JSON into the standard transactions folder for broadcasting. + let dir_path = "./transactions"; + if let Err(error) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {error}"); + return; + } + + let file_path = format!("{dir_path}/{hashed_data}.json"); + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + if let Err(error) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {error}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_loan_payment_tx.rs b/src/bin/create_loan_payment_tx.rs new file mode 100644 index 0000000..a537878 --- /dev/null +++ b/src/bin/create_loan_payment_tx.rs @@ -0,0 +1,139 @@ +use blockchain::blocks::loan_payment::{ + ContractPaymentTransaction, UnsignedContractPaymentTransaction, +}; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::types::BORROWER_FEE; +use blockchain::json; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +fn display_fee(value: u64) -> f64 { + // Fees are stored as atomic units and displayed as whole-coin decimals for the CLI. + value as f64 / 100_000_000.0 +} + +#[tokio::main] +async fn main() { + // Loan payments use transaction type 8 and the current local timestamp. + let txtype = 8; + let timestamp = Utc::now().timestamp() as u32; + + // The contract hash ties this payment to the loan contract being repaid. + let contract_hash = prompt_visible("Please enter the loan contract hash: ").await; + let contract_hash = contract_hash.trim().to_string(); + + // Payment amount and miner tip are entered as coin values and stored as atomic units. + let payback_amount_string = + prompt_visible("Please enter the payment amount: (eg. 12.5): ").await; + let payback_amount_f64: f64 = payback_amount_string + .trim() + .parse() + .expect("Please enter a valid amount"); + let payback_amount = (payback_amount_f64 * 100_000_000.0).round() as u64; + + let tip_string = + prompt_visible("Please enter the miner tip: (must be at least 1% of payment amount): ") + .await; + let tip_f64: f64 = tip_string + .trim() + .parse() + .expect("Please enter a valid amount"); + let tip = (tip_f64 * 100_000_000.0).round() as u64; + + // Borrower fee is also stored in atomic units for signing and block serialization. + let txfee_prompt = format!( + "Please enter the amount for the fee: (eg. 0.001, minimum fee {:.8})", + display_fee(BORROWER_FEE) + ); + let txfee_string = prompt_visible(&format!("{txfee_prompt}: ")).await; + let txfee_f64: f64 = txfee_string + .trim() + .parse() + .expect("Please enter a valid fee"); + let txfee = (txfee_f64 * 100_000_000.0).round() as u64; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Load the wallet so the transaction can use the saved short address and private key. + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let private_key = &wallet.saved.private_key; + let address = &wallet.saved.short_address; + + // Payments must use a valid current-network short address. + if !Wallet::short_address_validation(address.trim_matches('"')) { + println!("wallet invalid: {address}"); + return; + } + + // Build the unsigned payment body before signing it with the wallet private key. + let unsigned_payment = UnsignedContractPaymentTransaction::new( + txtype, + timestamp, + payback_amount, + &contract_hash, + address.trim_matches('"'), + tip, + txfee, + ) + .await; + + // The signed form adds the transaction hash and signature required by verification. + let payment = match ContractPaymentTransaction::new(unsigned_payment, private_key).await { + Ok(tx) => tx, + Err(err) => { + eprintln!("Failed to sign loan payment transaction: {err}"); + return; + } + }; + + let hash = payment.hash.clone(); + let signature = payment.signature.clone(); + + // Save the signed transaction as JSON so broadcast_transaction can submit it later. + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "payback_amount": payback_amount, + "contract_hash": contract_hash, + "address": address.trim_matches('"'), + "tip": tip, + "txfee": txfee, + "hash": hash, + "signature": signature, + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Transaction creation tools all write into ./transactions using the transaction hash. + let dir_path = "./transactions"; + if let Err(e) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {e}"); + return; + } + + let file_path = format!( + "{}/{}.json", + dir_path, + output["hash"].as_str().unwrap_or_default() + ); + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + if let Err(e) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_loan_tx.rs b/src/bin/create_loan_tx.rs new file mode 100644 index 0000000..3541b29 --- /dev/null +++ b/src/bin/create_loan_tx.rs @@ -0,0 +1,250 @@ +use blockchain::blocks::loans::UnsignedLoanContractTransaction; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::types::LENDER_FEE; +use blockchain::json; +use blockchain::records::wallet_registry::resolve_local_input_short_address; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::{create_dir_all, AsyncWriteExt}; +use blockchain::{Local, LocalResult, NaiveDate, NaiveTime, TimeZone}; + +fn pad_to_width(input: &str, width: usize) -> String { + // Asset names are fixed-width fields in the loan transaction bytes. + let mut result = String::with_capacity(width); + let _ = std::fmt::write( + &mut result, + format_args!("{input: f64 { + // Fees are stored as atomic units and displayed as whole-coin decimals for the CLI. + value as f64 / 100_000_000.0 +} + +fn normalize_short_address_input(address: &str) -> Result { + // Accept local vanity/short input and resolve it into the real short address. + resolve_local_input_short_address(address.trim()) +} + +fn parse_start_date(input: &str) -> Result { + // Loan start dates are entered as calendar dates and stored as local midnight timestamps. + let date = NaiveDate::parse_from_str(input.trim(), "%Y-%m-%d") + .map_err(|_| "Please enter the date in YYYY-MM-DD format.".to_string())?; + let datetime = date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()); + let timestamp = match Local.from_local_datetime(&datetime) { + LocalResult::Single(dt) => dt.timestamp(), + LocalResult::Ambiguous(dt, _) => dt.timestamp(), + LocalResult::None => { + return Err("Loan start date could not be resolved in local time.".to_string()); + } + }; + if timestamp < 0 || timestamp > u32::MAX as i64 { + return Err("Loan start date is out of range.".to_string()); + } + Ok(timestamp as u32) +} + +#[tokio::main] +async fn main() { + // Loan contracts use transaction type 7 and are first signed by the lender. + let txtype = 7; + + // Coin/token and collateral names are padded to fixed-width transaction fields. + let loan_coin_name = prompt_visible("What coin or token will you be lending out? ").await; + let loan_coin_name = loan_coin_name.trim().to_lowercase(); + let loan_coin = pad_to_width(&loan_coin_name, 15); + + let loan_amount_string = prompt_visible("How many coins or tokens will you be lending? ").await; + let loan_amount_f64: f64 = loan_amount_string + .trim() + .parse() + .expect("Please enter a valid amount"); + let loan_amount = (loan_amount_f64 * 100_000_000.0).round() as u64; + + // Payment cadence is stored as a compact one-letter code. + let payment_period = + prompt_visible("Would you like each payment period to be daily, weekly, or monthly? ") + .await; + let payment_period = match payment_period.trim().to_lowercase().as_str() { + "daily" | "d" => "d".to_string(), + "weekly" | "w" => "w".to_string(), + "monthly" | "m" => "m".to_string(), + _ => { + println!("Payment period must be daily, weekly, or monthly."); + return; + } + }; + + let payment_number_string = + prompt_visible("How many payments do you expect until the loan is considered paid? ").await; + let payment_number: u8 = payment_number_string + .trim() + .parse() + .expect("Please enter a valid number"); + + let payment_amount_string = prompt_visible("What should be the amount of each payment? ").await; + let payment_amount_f64: f64 = payment_amount_string + .trim() + .parse() + .expect("Please enter a valid amount"); + let payment_amount = (payment_amount_f64 * 100_000_000.0).round() as u64; + + let grace_period_string = + prompt_visible("How many payments can be missed before you will claim the collateral? ") + .await; + let grace_period: u8 = grace_period_string + .trim() + .parse() + .expect("Please enter a valid number"); + + let max_late_value_string = prompt_visible( + "How far behind in value can the borrower be before you will claim the collateral? ", + ) + .await; + let max_late_value_f64: f64 = max_late_value_string + .trim() + .parse() + .expect("Please enter a valid amount"); + let max_late_value = (max_late_value_f64 * 100_000_000.0).round() as u64; + + // Collateral can be a coin, token, or NFT name; NFT collateral uses amount 1. + let collateral_name = + prompt_visible("What collateral coin, token, or NFT will you be accepting for this loan? ") + .await; + let collateral_name = collateral_name.trim().to_lowercase(); + let collateral = pad_to_width(&collateral_name, 15); + + let collateral_amount_string = prompt_visible("How many collateral coins or tokens will you be accepting for this loan (enter 1 for NFT collateral)? ").await; + let collateral_amount_f64: f64 = collateral_amount_string + .trim() + .parse() + .expect("Please enter a valid amount"); + let collateral_amount = (collateral_amount_f64 * 100_000_000.0).round() as u64; + + let start_date = + prompt_visible("What is the date this loan should begin? (YYYY-MM-DD): ").await; + let timestamp = match parse_start_date(&start_date) { + Ok(timestamp) => timestamp, + Err(err) => { + println!("{err}"); + return; + } + }; + + // Resolve the borrower input before writing it into the loan offer. + let borrower = prompt_visible("What is the wallet address of the borrower? ").await; + let borrower = match normalize_short_address_input(borrower.trim()) { + Ok(address) => address, + Err(_) => { + println!("borrower wallet invalid"); + return; + } + }; + + let txfee_prompt = format!( + "Enter the txfee you will be paying for this transaction (minimum: {:.8})", + display_fee(LENDER_FEE) + ); + let txfee_string = prompt_visible(&format!("{txfee_prompt}: ")).await; + let txfee_f64: f64 = txfee_string + .trim() + .parse() + .expect("Please enter a valid fee"); + let txfee = (txfee_f64 * 100_000_000.0).round() as u64; + + // Load the lender wallet that creates the first loan-contract signature. + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let private_key = &wallet.saved.private_key; + let lender = &wallet.saved.short_address; + + // The lender must sign with a valid current-network short address. + if !Wallet::short_address_validation(lender.trim_matches('"')) { + println!("lender wallet invalid: {lender}"); + return; + } + + // Build the unsigned loan so both lender and borrower sign the exact same hash. + let unsigned_loan = UnsignedLoanContractTransaction::new( + txtype, + timestamp, + &loan_coin, + loan_amount, + lender.trim_matches('"'), + &collateral, + collateral_amount, + &borrower, + &payment_period, + payment_number, + payment_amount, + grace_period, + max_late_value, + txfee, + ) + .await; + + // This first signature is the lender's offer; the borrower adds signature2 later. + let (hash, signature1) = match unsigned_loan.hash_and_sign(private_key).await { + Ok(value) => value, + Err(err) => { + eprintln!("Failed to sign loan contract: {err}"); + return; + } + }; + + // Save the partially signed loan JSON for the borrower to review and sign. + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "loan_coin": loan_coin, + "loan_amount": loan_amount, + "lender": lender.trim_matches('"'), + "collateral": collateral, + "collateral_amount": collateral_amount, + "borrower": borrower, + "payment_period": payment_period, + "payment_number": payment_number, + "payment_amount": payment_amount, + "grace_period": grace_period, + "max_late_value": max_late_value, + "txfee": txfee, + "hash": hash, + "signature1": signature1, + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Transaction creation tools all write into ./transactions using the transaction hash. + let dir_path = "./transactions"; + if let Err(e) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {e}"); + return; + } + + let file_path = format!( + "{}/{}.json", + dir_path, + output["hash"].as_str().unwrap_or_default() + ); + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + if let Err(e) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_marketing_tx.rs b/src/bin/create_marketing_tx.rs new file mode 100644 index 0000000..5b2980b --- /dev/null +++ b/src/bin/create_marketing_tx.rs @@ -0,0 +1,179 @@ +use blockchain::blocks::marketing::{MarketingTransaction, UnsignedMarketingTransaction}; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::types::MARKETING_FEE; +use blockchain::json; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +// pad the coin to ensure 15 characters +fn pad_to_width(input: &str, width: usize) -> String { + let mut result = String::with_capacity(width); // Pre-allocate string with capacity + let _ = std::fmt::write( + &mut result, + format_args!("{input: f64 { + value as f64 / 100_000_000.0 +} + +#[tokio::main] +async fn main() { + let campaign_data = prompt_visible("What is the id number of your campaign? ").await; + let campaign: u64 = campaign_data + .trim() + .parse() + .expect("Please enter a valid number"); + + let ad_type_input = + prompt_visible("What type of campaign is this? banner, social, or text: ").await; + let ad_type = pad_to_width(ad_type_input.trim(), 6); + + let keyword_input = prompt_visible("Enter a keyword, can be up to 40 characters: ").await; + let keyword = pad_to_width(keyword_input.trim(), 40); + + let displayed_input = + prompt_visible("Enter the location the ad was displayed, can be up to 100 characters: ") + .await; + let displayed = pad_to_width(displayed_input.trim(), 100); + + let impression_input = prompt_visible("How many times was the ad viewed? (0 to 255): ").await; + let impression: u8 = impression_input + .trim() + .parse() + .expect("Please enter a valid number"); + + let click_input = prompt_visible("How many times was the ad clicked? (0 to 255): ").await; + let click: u8 = click_input + .trim() + .parse() + .expect("Please enter a valid number"); + + let impression_value_data = + prompt_visible("What is the value of each impression? (1.25): ").await; + let impression_value: f32 = impression_value_data + .trim() + .parse() + .expect("Please enter a valid number"); + let impression_value = (impression_value * 100.0) as u16; + + let click_value_data = prompt_visible("What is the value of each click? (1.25): ").await; + let click_value: f32 = click_value_data + .trim() + .parse() + .expect("Please enter a valid number"); + let click_value = (click_value * 100.0) as u16; + + let txfee_prompt = format!( + "Please enter the amount for the fee: (e.g., 0.001, minimum fee {:.8})", + display_fee(MARKETING_FEE) + ); + let txfee_string = prompt_visible(&format!("{txfee_prompt}: ")).await; + let txfee_f32: f32 = txfee_string + .trim() + .parse() + .expect("Please enter a valid value."); + let txfee = ((txfee_f32 as f64) * (100000000_f64)).round() as u64; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + + let private_key = &wallet.saved.private_key; + let address = &wallet.saved.short_address; + + // Set type and timestamp + let txtype = 5; + let timestamp = Utc::now().timestamp() as u32; + + // Validate wallets + if !Wallet::short_address_validation(address.trim_matches('"')) { + println!("creator wallet invalid: {}", &address); + return; + } + + let unsigned_marketing = UnsignedMarketingTransaction::new( + txtype, + timestamp, + campaign, + &ad_type, + &keyword, + &displayed, + impression, + click, + impression_value, + click_value, + address.trim_matches('"'), + txfee, + ) + .await; + + let marketing = match MarketingTransaction::new(unsigned_marketing, private_key).await { + Ok(tx) => tx, + Err(err) => { + eprintln!("Failed to sign marketing transaction: {err}"); + return; + } + }; + + let hashed_data = marketing.unsigned_marketing.hash().await; + let signature = marketing.signature.clone(); + + // Add the signature and hash + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "campaign": campaign, + "ad_type": ad_type, + "keyword": keyword, + "displayed": displayed, + "impression": impression, + "click": click, + "impression_value": impression_value, + "click_value": click_value, + "advertiser": address.trim_matches('"'), + "txfee": txfee, + "hash": hashed_data, + "signature": signature + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Define the directory path + let dir_path = "./transactions"; + + // Create the directory if it doesn't exist + if let Err(e) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {e}"); + return; + } + + // Define the file path + let file_path = format!("{dir_path}/{hashed_data}.json"); + + // Open the file for writing asynchronously + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + + // Write the JSON string to the file asynchronously + if let Err(e) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_new_wallet.rs b/src/bin/create_new_wallet.rs new file mode 100644 index 0000000..1a3ecf5 --- /dev/null +++ b/src/bin/create_new_wallet.rs @@ -0,0 +1,59 @@ +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::env; +use blockchain::metadata; +use blockchain::wallets::structures::Wallet; +use std::path::PathBuf; + +#[tokio::main] +async fn main() { + // Accept wallet path/name as args, otherwise prompt interactively. + let args: Vec = env::args().collect(); + + if args.len() > 1 && args.len() != 3 { + println!("Usage: ./create_new_wallet "); + return; + } + + let mut wallet_key: String; + let (wallet_path, wallet_filename) = if args.len() > 2 { + (args[1].clone(), args[2].clone()) + } else { + ( + prompt_visible("Please enter the path to your wallet file: ").await, + prompt_visible("Please enter wallet filename: ").await, + ) + }; + + wallet_key = prompt_hidden_nonempty( + "Please enter your wallet encryption key, if you do not have a wallet yet, enter a new encryption key here: ", + "Wallet key cannot be empty. Please try again.", + ).await; + + wallet_key = wallet_key.trim().to_string(); + let wallet_path = wallet_path.trim().to_string(); + let wallet_filename = wallet_filename.trim().to_string(); + let wallet_path = PathBuf::from(&wallet_path) + .join(&wallet_filename) + .to_string_lossy() + .into_owned(); + + // Refuse to overwrite an existing wallet file. + if let Ok(metadata) = metadata(&wallet_path).await { + if metadata.is_file() { + eprintln!( + "Error: Wallet already exists at the specified path: {wallet_path:?}" + ); + std::process::exit(1); + } + } + + // try_obtain_wallet creates a new wallet at this path when no saved wallet exists. + let wallet = match Wallet::try_obtain_wallet(wallet_key, Some(&wallet_path)).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet creation/loading failed: {err}"); + return; + } + }; + println!("{}", wallet.display_wallet()); +} diff --git a/src/bin/create_nft_tx.rs b/src/bin/create_nft_tx.rs new file mode 100644 index 0000000..bef321c --- /dev/null +++ b/src/bin/create_nft_tx.rs @@ -0,0 +1,157 @@ +use blockchain::blocks::nft::{CreateNftTransaction, UnsignedCreateNftTransaction}; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::types::CREATE_NFT_FEE; +use blockchain::json; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +// pad the coin to ensure 15 characters +fn pad_to_width(input: &str, width: usize) -> String { + let mut result = String::with_capacity(width); // Pre-allocate string with capacity + let _ = std::fmt::write( + &mut result, + format_args!("{input: f64 { + value as f64 / 100_000_000.0 +} + +#[tokio::main] +async fn main() { + // get user input series + let series_data = prompt_visible("Are you creating a 1/1 or a collection? ").await; + let series = if series_data.trim() == "collection" { + 1 + } else { + 0 + }; + + // get user input ticker + let nft_ticker_name = prompt_visible("Please enter the name for your NFT. This can be 3 to 15 characters and must be 100% unique. First come first serve: ").await; + let nft_name = pad_to_width(nft_ticker_name.trim(), 15); + + // get the padded CID string + let ipfs_hash = prompt_visible("Enter your IPFS CID for your NFT. This will be stored in a fixed 100 character field, so shorter CIDs are padded with spaces: ").await; + let item_ipfs = pad_to_width(ipfs_hash.trim(), 100); + + // how many items in the collection or 1 for 1/1s + let count_string = + prompt_visible("Please enter the number of items in your collection, enter 1 for 1/1s: ") + .await; + let count: u32 = count_string + .trim() + .parse() + .expect("Please enter a valid number"); + + // get the nft desc + let nft_desc = prompt_visible("Enter your nft description, A max of 100 characters: ").await; + let desc = pad_to_width(nft_desc.trim(), 100); + + // get user input txfee + let txfee_prompt = format!( + "Please enter the amount for the fee: (eg. 0.005, minimum fee {:.8})", + display_fee(CREATE_NFT_FEE) + ); + let txfee_string = prompt_visible(&format!("{txfee_prompt}: ")).await; + let txfee_f32: f32 = txfee_string + .trim() + .parse() + .expect("Please enter a valid value."); + let txfee = ((txfee_f32 as f64) * (100000000_f64)).round() as u64; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let private_key = &wallet.saved.private_key; + let address = &wallet.saved.short_address; + + // set type and timestampe + let txtype = 4; + let timestamp = Utc::now().timestamp() as u32; + + // validate wallets + if !Wallet::short_address_validation(address.trim_matches('"')) { + println!("creator wallet invalid: {}", &address); + return; + } + + let unsigned_create_nft = UnsignedCreateNftTransaction::new( + txtype, + timestamp, + address.trim_matches('"'), + series, + &nft_name, + &item_ipfs, + count, + &desc, + txfee, + ) + .await; + + let create_nft = match CreateNftTransaction::new(unsigned_create_nft, private_key).await { + Ok(tx) => tx, + Err(err) => { + eprintln!("Failed to sign NFT transaction: {err}"); + return; + } + }; + + let hashed_data = create_nft.unsigned_create_nft.hash().await; + let signature = create_nft.signature.clone(); + + //add the signature and hash + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "creator": address.trim_matches('"'), + "series": series, + "nft_name": nft_name, + "item_ipfs": item_ipfs, + "count": count, + "desc": desc, + "txfee": txfee, + "hash": hashed_data, + "signature": signature + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Define the directory path + let dir_path = "./transactions"; + + // Create the directory if it doesn't exist + if let Err(e) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {e}"); + return; + } + + // Define the file path + let file_path = format!("{dir_path}/{hashed_data}.json"); + + // Open the file for writing asynchronously + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + + // Write the JSON string to the file asynchronously + if let Err(e) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_swap_tx.rs b/src/bin/create_swap_tx.rs new file mode 100644 index 0000000..d80cf8d --- /dev/null +++ b/src/bin/create_swap_tx.rs @@ -0,0 +1,237 @@ +use blockchain::blocks::swap::UnsignedSwapTransaction; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::types::SWAP_FEE; +use blockchain::json; +use blockchain::records::wallet_registry::resolve_local_input_short_address; +use blockchain::wallets::structures::Wallet; +use blockchain::Duration; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +// pad the coin to ensure 15 characters +fn pad_to_width(input: &str, width: usize) -> String { + let mut result = String::with_capacity(width); // Pre-allocate string with capacity + let _ = std::fmt::write( + &mut result, + format_args!("{input: f64 { + value as f64 / 100_000_000.0 +} + +fn normalize_short_address_input(address: &str) -> Result { + resolve_local_input_short_address(address.trim()) +} + +#[tokio::main] +async fn main() { + // set type and timestampe + let txtype = 6; + let timestamp = Utc::now().timestamp() as u32; + + // get user input tocken1 + let ticker1_name = + prompt_visible("Please enter the coin or token you wish to send during the swap: ").await; + let ticker1_name = ticker1_name.trim().to_lowercase(); + let ticker1 = pad_to_width(&ticker1_name, 15); + + let nft_series1_string = prompt_visible("Please enter the NFT series number for the asset you are sending, or 0 for coins/tokens/1of1 NFTs: ").await; + let nft_series1: u32 = nft_series1_string + .trim() + .parse() + .expect("Please enter a valid number"); + + // get user input value1 + let value1_string = + prompt_visible("Please enter the amount of coins or tokens to send: (eg. 23.4): ").await; + let value1_f64: f64 = value1_string + .trim() + .parse() + .expect("Please enter a valid age"); + let value1 = (value1_f64 * (100000000_f64)).round() as u64; + + // get user input tocken2 + let ticker2_name = + prompt_visible("Please enter the coin or token you wish to receive during the swap: ") + .await; + let ticker2_name = ticker2_name.trim().to_lowercase(); + let ticker2 = pad_to_width(&ticker2_name, 15); + + let nft_series2_string = prompt_visible("Please enter the NFT series number for the asset you expect to receive, or 0 for coins/tokens/1of1 NFTs: ").await; + let nft_series2: u32 = nft_series2_string + .trim() + .parse() + .expect("Please enter a valid number"); + + // get user input value2 + let value2_string = prompt_visible( + "Please enter the amount of coins or tokens you expect to receive: (eg. 23.4): ", + ) + .await; + let value2_f64: f64 = value2_string + .trim() + .parse() + .expect("Please enter a valid age"); + let value2 = (value2_f64 * (100000000_f64)).round() as u64; + + // get user input swapper address + let sender2 = + prompt_visible("Please enter the wallet address of the account you are swapping with: ") + .await; + let sender2 = match normalize_short_address_input(&sender2) { + Ok(address) => address, + Err(_) => { + println!("reciver wallet is not valid"); + return; + } + }; + + // get user 1 tip + let tip1 = prompt_visible( + "Pleased enter the amount for the first miner tip: (must be at least 1% of token 1): ", + ) + .await; + let tip1_f64: f64 = tip1.trim().parse().expect("Please enter a valid value."); + let txtip1 = (tip1_f64 * (100000000_f64)).round() as u64; + + // get user user 2 tip + let tip2 = prompt_visible( + "Please enter the amount for the second miner tip: (must be at least 1% of token 2): ", + ) + .await; + let tip2_f32: f64 = tip2.trim().parse().expect("Please enter a valid value."); + let txtip2 = (tip2_f32 * (100000000_f64)).round() as u64; + + // get user input txfee1 + let txfee1_prompt = format!( + "Please enter the amount for the fee you will pay: (eg. 0.001, minimum fee {:.8} each party)", + display_fee(SWAP_FEE) + ); + let txfee1_string = prompt_visible(&format!("{txfee1_prompt}: ")).await; + let txfee1_f32: f64 = txfee1_string + .trim() + .parse() + .expect("Please enter a valid value."); + let txfee1 = (txfee1_f32 * (100000000_f64)).round() as u64; + + // get user input txfee2 + let txfee2_prompt = format!( + "Please enter the amount for the fee other party will pay pay: (eg. 0.001, minimum fee {:.8} each party)", + display_fee(SWAP_FEE) + ); + let txfee2_string = prompt_visible(&format!("{txfee2_prompt}: ")).await; + let txfee2_f32: f64 = txfee2_string + .trim() + .parse() + .expect("Please enter a valid value."); + let txfee2 = (txfee2_f32 * (100000000_f64)).round() as u64; + + // get expiration time in hours + let expiration_time = + prompt_visible("Please enter how many hours until the offer expires (eg. 72): ").await; + let expiration_time: u64 = expiration_time.trim().parse().expect("Invalid input"); + let expiration_duration = Duration::from_secs(expiration_time * 60 * 60); + let expiration_time: u32 = expiration_duration.as_secs() as u32; + let expires = timestamp + expiration_time; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + + let private_key = &wallet.saved.private_key; + let address = &wallet.saved.short_address; + + // validate wallets + if !Wallet::short_address_validation(address.trim_matches('"')) { + println!("sender wallet invalid: {}", &address); + return; + } + let unsigned_swap = UnsignedSwapTransaction::new( + txtype, + timestamp, + expires, + &ticker1, + nft_series1, + value1, + &ticker2, + nft_series2, + value2, + address.trim_matches('"'), + &sender2, + txtip1, + txtip2, + txfee1, + txfee2, + ) + .await; + + let hashed_data = unsigned_swap.hash().await; + let signature1 = match unsigned_swap.hash_and_sign(private_key).await { + Ok(signature) => signature, + Err(err) => { + eprintln!("Failed to sign swap transaction: {err}"); + return; + } + }; + + //add the signature and hash + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "offer_expiration": expires, + "ticker1": ticker1, + "nft_series1": nft_series1, + "value1": value1, + "ticker2": ticker2, + "nft_series2": nft_series2, + "value2": value2, + "sender1": address.trim_matches('"'), + "sender2": sender2, + "tip1": txtip1, + "tip2": txtip2, + "txfee1": txfee1, + "txfee2": txfee2, + "hash": hashed_data, + "signature1": signature1, + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Define the directory path + let dir_path = "./transactions"; + + // Create the directory if it doesn't exist + if let Err(e) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {e}"); + return; + } + + // Define the file path + let file_path = format!("{dir_path}/{hashed_data}.json"); + + // Open the file for writing asynchronously + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + + // Write the JSON string to the file asynchronously + if let Err(e) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_tokens_tx.rs b/src/bin/create_tokens_tx.rs new file mode 100644 index 0000000..7a8e4b7 --- /dev/null +++ b/src/bin/create_tokens_tx.rs @@ -0,0 +1,151 @@ +use blockchain::blocks::token::{CreateTokenTransaction, UnsignedCreateTokenTransaction}; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::types::CREATE_TOKEN_FEE; +use blockchain::json; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +// pad the coin to ensure 15 characters +fn pad_to_width(input: &str, width: usize) -> String { + let mut result = String::with_capacity(width); // Pre-allocate string with capacity + let _ = std::fmt::write( + &mut result, + format_args!("{input: f64 { + value as f64 / 100_000_000.0 +} + +#[tokio::main] +async fn main() { + // set type and timestampe + let txtype = 3; + let timestamp = Utc::now().timestamp() as u32; + + // get user input ticker + let ticker_name = prompt_visible("Please enter the ticker / token name you wish to create. This can be 3 to 15 characters and must be 100% unique. First come first serve: ").await; + let ticker = pad_to_width(ticker_name.trim(), 15); + + // get user input value + let number_string = prompt_visible( + "Please enter the amount of coins or tokens to create: (eg. 1, 1000, 100000, etc): ", + ) + .await; + let number_u64: u64 = number_string + .trim() + .parse() + .expect("Please enter a valid age"); + let number = ((number_u64 as f64) * (100000000_f64)).round() as u64; + + let hard_limit_string = + prompt_visible("Should this token have a hard cap? Enter 1 for yes or 0 for no: ").await; + let hard_limit: u8 = hard_limit_string + .trim() + .parse() + .expect("Please enter 1 or 0."); + if hard_limit > 1 { + println!("hard_limit must be 1 or 0."); + return; + } + + // get user input txfee + let txfee_prompt = format!( + "Please enter the amount for the fee: (eg. 500, minimum fee {:.8})", + display_fee(CREATE_TOKEN_FEE) + ); + let txfee_string = prompt_visible(&format!("{txfee_prompt}: ")).await; + let txfee_f32: f32 = txfee_string + .trim() + .parse() + .expect("Please enter a valid value."); + let txfee = ((txfee_f32 as f64) * (100000000_f64)).round() as u64; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + + let private_key = &wallet.saved.private_key; + let address = &wallet.saved.short_address; + + // validate wallets + if !Wallet::short_address_validation(address.trim_matches('"')) { + println!("creatpr wallet invalid: {}", &address); + return; + } + + let unsigned_create_token = UnsignedCreateTokenTransaction::new( + txtype, + timestamp, + address.trim_matches('"'), + &ticker, + number, + hard_limit, + txfee, + ) + .await; + + let create_token = match CreateTokenTransaction::new(unsigned_create_token, private_key).await { + Ok(tx) => tx, + Err(err) => { + eprintln!("Failed to sign token transaction: {err}"); + return; + } + }; + + let hashed_data = create_token.unsigned_create_token.hash().await; + let signature = create_token.signature.clone(); + + //add the signature and hash + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "creator": address.trim_matches('"'), + "ticker": ticker, + "number": number, + "hard_limit": hard_limit, + "txfee": txfee, + "hash": hashed_data, + "signature": signature, + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Define the directory path + let dir_path = "./transactions"; + + // Create the directory if it doesn't exist + if let Err(e) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {e}"); + return; + } + + // Define the file path + let file_path = format!("{dir_path}/{hashed_data}.json"); + + // Open the file for writing asynchronously + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + + // Write the JSON string to the file asynchronously + if let Err(e) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_transfer_tx.rs b/src/bin/create_transfer_tx.rs new file mode 100644 index 0000000..32ac73e --- /dev/null +++ b/src/bin/create_transfer_tx.rs @@ -0,0 +1,206 @@ +use blockchain::blocks::transfer::{TransferTransaction, UnsignedTransferTransaction}; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::network_paths_and_settings::block_extension_and_paths; +use blockchain::common::types::{NON_BASE_TRANSFER_MIN_FEE, TRANSFER_FEE}; +use blockchain::env; +use blockchain::json; +use blockchain::records::wallet_registry::resolve_local_input_short_address; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +// pad the coin to ensure 15 characters +fn pad_to_width(input: &str, width: usize) -> String { + let mut result = String::with_capacity(width); // Pre-allocate string with capacity + let _ = std::fmt::write( + &mut result, + format_args!("{input: f64 { + value as f64 / 100_000_000.0 +} + +fn normalize_short_address_input(address: &str) -> Result { + resolve_local_input_short_address(address.trim()) +} + +#[tokio::main] +async fn main() { + let minimum_transfer_fee_percent = TRANSFER_FEE * 100.0; + + // set type and timestamp + let txtype = 2; + let timestamp = Utc::now().timestamp() as u32; + + let args: Vec = env::args().collect(); + + if args.len() > 1 && args.len() != 6 { + println!("Usage: ./create_transfer_tx "); + return; + } + + let coin_name = if args.len() > 1 { + args[1].clone() + } else { + prompt_visible("Please enter the coin or token you wish to transfer: ").await + }; + let coin = pad_to_width(&coin_name, 15); + let is_base_coin = + coin.trim().to_lowercase() == block_extension_and_paths().1.trim().to_lowercase(); + + let value_string = if args.len() > 2 { + args[2].clone() + } else { + prompt_visible("Please enter the amount of coins or tokens to send: (eg. 23.4): ").await + }; + let value_f32: f32 = value_string + .trim() + .parse() + .expect("Please enter a valid amount."); + let value = ((value_f32 as f64) * 100_000_000.0).round() as u64; + + let receiver_input = if args.len() > 3 { + args[3].clone() + } else { + prompt_visible("Please enter the receiver wallet address: ").await + }; + let receiver = match normalize_short_address_input(&receiver_input) { + Ok(address) => address, + Err(_) => { + println!("reciver wallet is not valid"); + return; + } + }; + + let txfee_string = if args.len() > 4 { + args[4].clone() + } else { + let prompt = if is_base_coin { + format!( + "Please enter the amount for the fee: (eg. 1.09, minimum fee {minimum_transfer_fee_percent:.0}% of transaction):" + ) + } else { + format!( + "Please enter the amount for the fee: (eg. 0.0001, minimum fee {:.8} for tokens/NFTs):", + display_fee(NON_BASE_TRANSFER_MIN_FEE) + ) + }; + prompt_visible(&format!("{prompt} ")).await + }; + let txfee_f32: f32 = txfee_string + .trim() + .parse() + .expect("Please enter a valid fee."); + let txfee = ((txfee_f32 as f64) * 100_000_000.0).round() as u64; + + let nft_series_string = if args.len() > 5 { + args[5].clone() + } else { + prompt_visible( + "Please enter the NFT series number to transfer, or 0 for coins/tokens/1of1 NFTs: ", + ) + .await + }; + let nft_series: u32 = nft_series_string + .trim() + .parse() + .expect("Please enter a valid NFT series number."); + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + + let private_key = &wallet.saved.private_key; + let short_address = &wallet.saved.short_address; + + if !Wallet::short_address_validation(short_address.trim()) { + println!("sender wallet invalid: {short_address}"); + return; + } + + if short_address.trim() == receiver.trim() { + println!("You cannot send funds to yourself"); + return; + } + + if nft_series > 0 && value != 100_000_000 { + println!("Series NFT transfers must have a value of exactly 1.0"); + return; + } + + let unsigned_transfer = UnsignedTransferTransaction::new( + txtype, + timestamp, + value, + &coin, + nft_series, + short_address.trim(), + &receiver, + txfee, + ) + .await; + + let transfer = match TransferTransaction::new(unsigned_transfer, private_key).await { + Ok(tx) => tx, + Err(err) => { + eprintln!("Failed to sign transfer transaction: {err}"); + return; + } + }; + + let hashed_data = transfer.unsigned_transfer.hash().await; + let signature = transfer.signature.clone(); + + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "value": value, + "coin": coin, + "nft_series": nft_series, + "sender": short_address.trim(), + "receiver": receiver, + "txfee": txfee, + "hash": hashed_data, + "signature": signature, + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Define the directory path + let dir_path = "./transactions"; + + // Create the directory if it doesn't exist + if let Err(e) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {e}"); + return; + } + + // Define the file path + let file_path = format!("{dir_path}/{hashed_data}.json"); + + // Open the file for writing asynchronously + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + + // Write the JSON string to the file asynchronously + if let Err(e) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/create_vanity_tx.rs b/src/bin/create_vanity_tx.rs new file mode 100644 index 0000000..862f795 --- /dev/null +++ b/src/bin/create_vanity_tx.rs @@ -0,0 +1,171 @@ +use blockchain::blocks::vanity::{UnsignedVanityAddressTransaction, VanityAddressTransaction}; +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible}; +use blockchain::common::types::{VANITY_ADDRESS_FEE, VANITY_ADDRESS_TYPE}; +use blockchain::env; +use blockchain::json; +use blockchain::wallets::structures::Wallet; +use blockchain::File; +use blockchain::Utc; +use blockchain::{create_dir_all, AsyncWriteExt}; + +fn display_fee(value: u64) -> f64 { + // Fees are stored as atomic units and displayed as whole-coin decimals for the CLI. + value as f64 / 100_000_000.0 +} + +fn left_pad_vanity_payload(input: &str) -> Option { + // Vanity names are left padded to the 20-byte short-address payload size. + let trimmed = input.trim(); + if trimmed.is_empty() || trimmed.len() > Wallet::SHORT_ADDRESS_HASH_BYTES_LENGTH { + return None; + } + // Only letters are allowed so vanity payload bytes stay unambiguous. + if !trimmed.chars().all(|ch| ch.is_ascii_alphabetic()) { + return None; + } + Some(format!( + "{:>width$}", + trimmed, + width = Wallet::SHORT_ADDRESS_HASH_BYTES_LENGTH + )) +} + +#[tokio::main] +async fn main() { + // The vanity tool accepts optional CLI args, otherwise it prompts interactively. + let args: Vec = env::args().collect(); + if args.len() > 1 && args.len() != 3 { + println!("Usage: ./create_vanity_tx "); + return; + } + + let vanity_name = if args.len() > 1 { + args[1].clone() + } else { + prompt_visible("Enter the vanity name to register (letters only, up to 20 characters): ") + .await + }; + + // Normalize the vanity name into the exact payload form saved in the transaction. + let padded_payload = match left_pad_vanity_payload(&vanity_name) { + Some(payload) => payload, + None => { + eprintln!("Vanity names must be 1-20 letters only."); + return; + } + }; + + // Convert the entered fee into atomic units before signing. + let txfee_string = if args.len() > 2 { + args[2].clone() + } else { + let prompt = format!( + "Please enter the vanity registration fee: (e.g. 1.0, minimum fee {:.8})", + display_fee(VANITY_ADDRESS_FEE) + ); + prompt_visible(&format!("{prompt}: ")).await + }; + let txfee_f32: f32 = txfee_string + .trim() + .parse() + .expect("Please enter a valid fee."); + let txfee = ((txfee_f32 as f64) * 100_000_000.0).round() as u64; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Load the wallet that will own and sign the vanity registration. + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let private_key = &wallet.saved.private_key; + let address = wallet.saved.short_address.trim().to_string(); + + // Vanity registration is owned by the current wallet short address. + if !Wallet::short_address_validation(&address) { + eprintln!("sender wallet invalid: {address}"); + return; + } + + // Use the wallet network byte to attach the matching vanity suffix. + let network_byte = + match Wallet::short_address_to_bytes(&address).and_then(|bytes| bytes.last().copied()) { + Some(byte) => byte, + None => { + eprintln!("Could not determine wallet network."); + return; + } + }; + + let network_suffix = Wallet::map_byte_to_wallet(network_byte).to_ascii_lowercase(); + if network_suffix.is_empty() { + eprintln!("Could not determine wallet network."); + return; + } + // Validate the final vanity address before it is signed into the transaction. + let vanity_address = format!("{}.{}", padded_payload.to_ascii_lowercase(), network_suffix); + if Wallet::vanity_address_to_bytes(&vanity_address).is_none() { + eprintln!("Failed to build vanity address."); + return; + } + + // Build and sign the vanity registration transaction. + let timestamp = Utc::now().timestamp() as u32; + let unsigned_vanity = UnsignedVanityAddressTransaction::new( + VANITY_ADDRESS_TYPE, + timestamp, + &address, + &vanity_address, + txfee, + ) + .await; + + let vanity_transaction = match VanityAddressTransaction::new(unsigned_vanity, private_key).await + { + Ok(tx) => tx, + Err(err) => { + eprintln!("Failed to sign vanity transaction: {err}"); + return; + } + }; + + let hashed_data = vanity_transaction.unsigned_vanity_address.hash().await; + let signature = vanity_transaction.signature.clone(); + + // Save the signed transaction as JSON so broadcast_transaction can submit it later. + let output = json!({ + "txtype": VANITY_ADDRESS_TYPE, + "timestamp": timestamp, + "address": address, + "vanity_address": vanity_address, + "txfee": txfee, + "hash": hashed_data, + "signature": signature, + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Transaction creation tools all write into ./transactions using the transaction hash. + let dir_path = "./transactions"; + if let Err(e) = create_dir_all(&dir_path).await { + eprintln!("Failed to create directory: {e}"); + return; + } + + let file_path = format!("{dir_path}/{hashed_data}.json"); + let mut file = File::create(&file_path) + .await + .expect("Failed to create file"); + if let Err(e) = file.write_all(output_str.as_bytes()).await { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("transaction: {output_str}"); +} diff --git a/src/bin/lookup_block_by_hash.rs b/src/bin/lookup_block_by_hash.rs new file mode 100644 index 0000000..2416448 --- /dev/null +++ b/src/bin/lookup_block_by_hash.rs @@ -0,0 +1,83 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::records::unpack_block::load_by_binary_data::load_block_from_binary; +use blockchain::standalone_tools::connections::handshake; +use blockchain::to_string_pretty; + +#[tokio::main] +async fn main() { + // Command 10 asks a peer for a block by its 32-byte hash. + let hashmap_key = generate_uid(); + let rpc_command = 10; + + // The CLI accepts the block hash as a hex string and validates it before connecting. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./lookup_block_by_hash "); + return; + } + + let block_hash = args[1].trim().to_string(); + if block_hash.len() != 64 || !block_hash.chars().all(|c| c.is_ascii_hexdigit()) { + println!("Please enter a valid 64-character block hash"); + return; + } + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Try each configured peer until one returns a usable response. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + block_hash.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // A successful block response is binary block data, not text. + if let Ok(block) = load_block_from_binary(&response).await { + match to_string_pretty(&block) { + Ok(block_json) => { + println!("{block_json}"); + connected = true; + } + Err(err) => eprintln!("failed to serialize block json: {err}"), + } + } else { + // Error replies may be plain text; unknown binary is shown as hex for debugging. + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } else if !response.is_empty() { + println!("{}", hex::encode(response)); + connected = true; + } + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_block_by_height.rs b/src/bin/lookup_block_by_height.rs new file mode 100644 index 0000000..6c55563 --- /dev/null +++ b/src/bin/lookup_block_by_height.rs @@ -0,0 +1,87 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::records::unpack_block::load_by_binary_data::load_block_from_binary; +use blockchain::standalone_tools::connections::handshake; +use blockchain::to_string_pretty; + +#[tokio::main] +async fn main() { + // Command 9 asks a peer for a block by block height. + let hashmap_key = generate_uid(); + let rpc_command = 9; + + // Validate the height locally before opening a peer connection. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./lookup_block_by_height "); + return; + } + + let block_number = match args[1].parse::() { + Ok(num) => num, + Err(_) => { + println!("Please enter a valid number"); + return; + } + }; + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // sending_request expects command 9 input as a decimal block number string. + let payload = block_number.to_string(); + // Try each configured peer until one returns a usable response. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + payload.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // Successful block responses are binary block data. + if let Ok(block) = load_block_from_binary(&response).await { + match to_string_pretty(&block) { + Ok(block_json) => { + println!("{block_json}"); + connected = true; + } + Err(err) => eprintln!("failed to serialize block json: {err}"), + } + } else { + // Error replies may be plain text; unknown binary is shown as hex for debugging. + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } else if !response.is_empty() { + println!("{}", hex::encode(response)); + connected = true; + } + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_contract_by_address.rs b/src/bin/lookup_contract_by_address.rs new file mode 100644 index 0000000..88fc526 --- /dev/null +++ b/src/bin/lookup_contract_by_address.rs @@ -0,0 +1,76 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::encode; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; + +#[tokio::main] +async fn main() { + // Command 37 asks a peer for contract records tied to a wallet address. + let hashmap_key = generate_uid(); + let rpc_command = 37; + + // The address can be a long, short, or vanity address; request encoding normalizes it later. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./contract_lookup_by_address "); + return; + } + + let wallet_address = match args[1].parse::() { + Ok(address) => address, + Err(_) => { + println!("Please enter a wallet address"); + return; + } + }; + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Try each configured peer until one returns a response. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + wallet_address.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // Contract lookup replies are usually text; fallback to hex if the peer sends raw bytes. + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } else if !response.is_empty() { + println!("{}", encode(response)); + connected = true; + } + } + Err(_) => { + connected = false; + } + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_contract_by_hash.rs b/src/bin/lookup_contract_by_hash.rs new file mode 100644 index 0000000..8966043 --- /dev/null +++ b/src/bin/lookup_contract_by_hash.rs @@ -0,0 +1,76 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::encode; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; + +#[tokio::main] +async fn main() { + // Command 33 asks a peer for one loan contract by its 32-byte hash. + let hashmap_key = generate_uid(); + let rpc_command = 33; + + // The hash is passed as text here and encoded into binary request bytes later. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./contract_lookup_by_hash "); + return; + } + + let contract_hash = match args[1].parse::() { + Ok(hash) => hash, + Err(_) => { + println!("Please enter a contract hash"); + return; + } + }; + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Try each configured peer until one returns a response. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + contract_hash.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // Contract lookup replies are usually text; fallback to hex if the peer sends raw bytes. + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } else if !response.is_empty() { + println!("{}", encode(response)); + connected = true; + } + } + Err(_) => { + connected = false; + } + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_difficulty.rs b/src/bin/lookup_difficulty.rs new file mode 100644 index 0000000..fdf7285 --- /dev/null +++ b/src/bin/lookup_difficulty.rs @@ -0,0 +1,63 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; + +#[tokio::main] +async fn main() { + // Command 5 asks a peer for the current network difficulty. + let hashmap_key = generate_uid(); + + let _args: Vec = env::args().collect(); + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let rpc_command = 5; + let json = "".to_string(); + + // Try each configured peer until one returns difficulty or text error. + let connections = get_connections().await; + + let mut connected: bool = false; + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + json.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + match result { + Ok(response) => { + // The difficulty reply carries 4 bytes of header plus an 8-byte u64 difficulty. + if response.len() == 12 { + let difficulty = u64::from_le_bytes(response[4..12].try_into().unwrap()); + println!("{difficulty}"); + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + } + Err(_) => { + connected = false; + } + } + } + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_height.rs b/src/bin/lookup_height.rs new file mode 100644 index 0000000..6dbc31c --- /dev/null +++ b/src/bin/lookup_height.rs @@ -0,0 +1,71 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; + +#[tokio::main] +async fn main() { + let hashmap_key = generate_uid(); + + // Get command-line arguments + let args: Vec = env::args().collect(); + + // Check if a command-line argument is provided + if args.len() != 1 { + println!("Usage: ./request_height"); + return; + } + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // set the rpc command number + let rpc_command = 2; + let json = "".to_string(); + + let connections = get_connections().await; + + let mut connected: bool = false; + // Iterate over the returned Vec and print each connection + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + json.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + if response.len() == 4 { + let height = u32::from_le_bytes(response[..4].try_into().unwrap()); + println!("{height}"); + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + } + Err(_) => { + connected = false; + } + } + } + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_largest_txfee.rs b/src/bin/lookup_largest_txfee.rs new file mode 100644 index 0000000..2f8b6b5 --- /dev/null +++ b/src/bin/lookup_largest_txfee.rs @@ -0,0 +1,82 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; + +fn format_balance(value: u64) -> String { + let whole = value / 100_000_000; + let fractional = value % 100_000_000; + format!("{whole}.{fractional:08}") +} + +#[tokio::main] +async fn main() { + let hashmap_key = generate_uid(); + + // Get command-line arguments + let args: Vec = env::args().collect(); + + // Check if a command-line argument is provided + if args.len() != 1 { + println!("Usage: ./large_tx_fee"); + return; + } + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // set the rpc command number + let rpc_command = 13; + let json = "".to_string(); + + let connections = get_connections().await; + + let mut connected: bool = false; + // Iterate over the returned Vec and print each connection + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + json.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + if response.is_empty() { + println!("0"); + connected = true; + } else if response.len() == 8 { + let fee = u64::from_le_bytes(response[0..8].try_into().unwrap()); + if fee == 0 { + println!("0"); + } else { + println!("{}", format_balance(fee)); + } + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + } + Err(_) => connected = false, + } + } + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_local_balance.rs b/src/bin/lookup_local_balance.rs new file mode 100644 index 0000000..843bee7 --- /dev/null +++ b/src/bin/lookup_local_balance.rs @@ -0,0 +1,89 @@ +use blockchain::env; +use blockchain::fs; +use blockchain::tilde; +use rustyline::completion::FilenameCompleter; +use rustyline::error::ReadlineError; +use rustyline::{history::DefaultHistory, CompletionType, Config, Editor}; +use rustyline_derive::Completer; +use rustyline_derive::Helper as RustyHelper; +use rustyline_derive::Highlighter as RustyHighlighter; +use rustyline_derive::Hinter as RustyHinter; +use rustyline_derive::Validator as RustyValidator; + +fn format_balance(balance: u64) -> String { + // Balance files store atomic units; display as whole coins with 8 decimals. + let whole = balance / 100_000_000; + let fractional = balance % 100_000_000; + format!("{whole}.{fractional:08}") +} + +#[derive(RustyHelper, Completer, RustyHinter, RustyHighlighter, RustyValidator)] +struct PathHelper { + #[rustyline(Completer)] + completer: FilenameCompleter, +} + +fn prompt_for_path(prompt: &str) -> Result { + // Rustyline provides path completion for interactive balance-file lookup. + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => Ok(line.trim().to_string()), + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read balance file path: {err}")), + } +} + +#[tokio::main] +async fn main() { + // The tool accepts a balance file path as an arg or prompts with completion. + let args: Vec = env::args().collect(); + + if args.len() > 1 && args.len() != 2 { + eprintln!("Usage: ./lookup_local_balance "); + return; + } + + let balance_file_path = if args.len() == 2 { + args[1].clone() + } else { + match prompt_for_path("Please enter the path to the balance file: ") { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + } + }; + + // Allow users to type paths with ~ and read the balance bytes from disk. + let expanded_path = tilde(&balance_file_path).to_string(); + let bytes = match fs::read(&expanded_path) { + Ok(bytes) => bytes, + Err(err) => { + eprintln!("Error reading file: {err}"); + return; + } + }; + + if bytes.len() < 8 { + eprintln!("Error: File should have at least 8 bytes of data"); + return; + } + + // The first 8 bytes are the little-endian u64 balance value. + let mut buffer = [0u8; 8]; + buffer.copy_from_slice(&bytes[..8]); + let value = u64::from_le_bytes(buffer); + + println!("{}", format_balance(value)); +} diff --git a/src/bin/lookup_mempool_tx_by_address.rs b/src/bin/lookup_mempool_tx_by_address.rs new file mode 100644 index 0000000..2161083 --- /dev/null +++ b/src/bin/lookup_mempool_tx_by_address.rs @@ -0,0 +1,130 @@ +use blockchain::blocks::burn::BurnTransaction; +use blockchain::blocks::collateral::CollateralClaimTransaction; +use blockchain::blocks::issue_token::IssueTokenTransaction; +use blockchain::blocks::loan_payment::ContractPaymentTransaction; +use blockchain::blocks::loans::LoanContractTransaction; +use blockchain::blocks::marketing::MarketingTransaction; +use blockchain::blocks::nft::CreateNftTransaction; +use blockchain::blocks::swap::SwapTransaction; +use blockchain::blocks::token::CreateTokenTransaction; +use blockchain::blocks::transfer::TransferTransaction; +use blockchain::blocks::vanity::VanityAddressTransaction; +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::common::types::{ + BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, + ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, SWAP_TYPE, TRANSFER_TYPE, VANITY_ADDRESS_TYPE, +}; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::rpc::command_maps; +use blockchain::standalone_tools::connections::handshake; +use blockchain::to_string_pretty; + +async fn decode_one_transaction(tx_bytes: &[u8]) -> Option { + let txtype = *tx_bytes.first()?; + let body = &tx_bytes[1..]; + + match txtype { + TRANSFER_TYPE => to_string_pretty(&TransferTransaction::from_bytes(txtype, body).await.ok()?).ok(), + CREATE_TOKEN_TYPE => to_string_pretty(&CreateTokenTransaction::from_bytes(txtype, body).await.ok()?).ok(), + CREATE_NFT_TYPE => to_string_pretty(&CreateNftTransaction::from_bytes(txtype, body).await.ok()?).ok(), + MARKETING_TYPE => to_string_pretty(&MarketingTransaction::from_bytes(txtype, body).await.ok()?).ok(), + SWAP_TYPE => to_string_pretty(&SwapTransaction::from_bytes(txtype, body).await.ok()?).ok(), + LENDER_TYPE => to_string_pretty(&LoanContractTransaction::from_bytes(txtype, body).await.ok()?).ok(), + BORROWER_TYPE => to_string_pretty(&ContractPaymentTransaction::from_bytes(txtype, body).await.ok()?).ok(), + COLLATERAL_TYPE => to_string_pretty(&CollateralClaimTransaction::from_bytes(txtype, body).await.ok()?).ok(), + BURN_TYPE => to_string_pretty(&BurnTransaction::from_bytes(txtype, body).await.ok()?).ok(), + ISSUE_TOKEN_TYPE => to_string_pretty(&IssueTokenTransaction::from_bytes(txtype, body).await.ok()?).ok(), + VANITY_ADDRESS_TYPE => to_string_pretty(&VanityAddressTransaction::from_bytes(txtype, body).await.ok()?).ok(), + _ => None, + } +} + +async fn decode_mempool_transactions(response: &[u8]) -> Option> { + // Address lookup can return many concatenated original transaction byte records. + let mut offset = 0; + let mut transactions = Vec::new(); + + while offset < response.len() { + let txtype = *response.get(offset)?; + let tx_len = command_maps::get_bytes(txtype); + if tx_len == 0 || offset + tx_len > response.len() { + return None; + } + + let tx_bytes = &response[offset..offset + tx_len]; + transactions.push(decode_one_transaction(tx_bytes).await?); + offset += tx_len; + } + + Some(transactions) +} + +#[tokio::main] +async fn main() { + // Command 16 asks a peer for unprocessed mempool transactions tied to an address. + let rpc_command = 16; + + // The address can be long, short, or vanity; sending_request normalizes it to short bytes. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./lookup_mempool_tx_by_address "); + return; + } + let address = args[1].trim().to_string(); + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + address.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + generate_uid(), + ) + .await; + + match result { + Ok(response) => { + if response.is_empty() { + println!("[]"); + connected = true; + } else if let Some(transactions) = decode_mempool_transactions(&response).await { + for transaction in transactions { + println!("{transaction}"); + } + connected = true; + } else { + // Text errors are printed directly; unknown binary is shown as hex for inspection. + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + } else { + println!("{}", hex::encode(response)); + } + connected = true; + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_mempool_tx_by_signature.rs b/src/bin/lookup_mempool_tx_by_signature.rs new file mode 100644 index 0000000..9976aa7 --- /dev/null +++ b/src/bin/lookup_mempool_tx_by_signature.rs @@ -0,0 +1,109 @@ +use blockchain::blocks::burn::BurnTransaction; +use blockchain::blocks::collateral::CollateralClaimTransaction; +use blockchain::blocks::issue_token::IssueTokenTransaction; +use blockchain::blocks::loan_payment::ContractPaymentTransaction; +use blockchain::blocks::loans::LoanContractTransaction; +use blockchain::blocks::marketing::MarketingTransaction; +use blockchain::blocks::nft::CreateNftTransaction; +use blockchain::blocks::swap::SwapTransaction; +use blockchain::blocks::token::CreateTokenTransaction; +use blockchain::blocks::transfer::TransferTransaction; +use blockchain::blocks::vanity::VanityAddressTransaction; +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::common::types::{ + BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, + ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, SWAP_TYPE, TRANSFER_TYPE, VANITY_ADDRESS_TYPE, +}; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::to_string_pretty; + +async fn decode_mempool_transaction(response: &[u8]) -> Option { + // Mempool transaction replies are the original transaction bytes, + // starting with the transaction type byte and no block-height prefix. + let txtype = *response.first()?; + let body = &response[1..]; + + match txtype { + TRANSFER_TYPE => to_string_pretty(&TransferTransaction::from_bytes(txtype, body).await.ok()?).ok(), + CREATE_TOKEN_TYPE => to_string_pretty(&CreateTokenTransaction::from_bytes(txtype, body).await.ok()?).ok(), + CREATE_NFT_TYPE => to_string_pretty(&CreateNftTransaction::from_bytes(txtype, body).await.ok()?).ok(), + MARKETING_TYPE => to_string_pretty(&MarketingTransaction::from_bytes(txtype, body).await.ok()?).ok(), + SWAP_TYPE => to_string_pretty(&SwapTransaction::from_bytes(txtype, body).await.ok()?).ok(), + LENDER_TYPE => to_string_pretty(&LoanContractTransaction::from_bytes(txtype, body).await.ok()?).ok(), + BORROWER_TYPE => to_string_pretty(&ContractPaymentTransaction::from_bytes(txtype, body).await.ok()?).ok(), + COLLATERAL_TYPE => to_string_pretty(&CollateralClaimTransaction::from_bytes(txtype, body).await.ok()?).ok(), + BURN_TYPE => to_string_pretty(&BurnTransaction::from_bytes(txtype, body).await.ok()?).ok(), + ISSUE_TOKEN_TYPE => to_string_pretty(&IssueTokenTransaction::from_bytes(txtype, body).await.ok()?).ok(), + VANITY_ADDRESS_TYPE => to_string_pretty(&VanityAddressTransaction::from_bytes(txtype, body).await.ok()?).ok(), + _ => None, + } +} + +#[tokio::main] +async fn main() { + // Command 14 asks a peer for one unprocessed mempool transaction by signature. + let rpc_command = 14; + + // The signature is passed as hex and converted into raw signature bytes by sending_request. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./lookup_mempool_tx_by_signature "); + return; + } + let signature = args[1].trim().to_string(); + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + signature.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + generate_uid(), + ) + .await; + + match result { + Ok(response) => { + if response.is_empty() { + println!("Transaction not found in mempool."); + connected = true; + } else if let Some(transaction_json) = decode_mempool_transaction(&response).await { + println!("{transaction_json}"); + connected = true; + } else { + // Text errors are printed directly; unknown binary is shown as hex for inspection. + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + } else { + println!("{}", hex::encode(response)); + } + connected = true; + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_mempool_tx_count.rs b/src/bin/lookup_mempool_tx_count.rs new file mode 100644 index 0000000..ce220ce --- /dev/null +++ b/src/bin/lookup_mempool_tx_count.rs @@ -0,0 +1,66 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; + +#[tokio::main] +async fn main() { + // Command 15 asks a peer for the current unprocessed mempool transaction count. + let rpc_command = 15; + + // This lookup takes no arguments beyond the wallet key used for the handshake. + let args: Vec = env::args().collect(); + if args.len() != 1 { + println!("Usage: ./lookup_mempool_tx_count"); + return; + } + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Try each configured peer until one returns a count or text error. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + "".to_string(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + generate_uid(), + ) + .await; + + match result { + Ok(response) if response.len() >= 4 => { + // The mempool count command returns a 4-byte little-endian integer. + let count = u32::from_le_bytes(response[0..4].try_into().unwrap()); + println!("{count}"); + connected = true; + } + Ok(response) => { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_network_info.rs b/src/bin/lookup_network_info.rs new file mode 100644 index 0000000..5297b44 --- /dev/null +++ b/src/bin/lookup_network_info.rs @@ -0,0 +1,142 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::json; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::to_string_pretty; + +const NETWORK_NAME_BYTES: usize = 7; +const NETWORK_INFO_FIXED_BYTES_WITHOUT_PREFIX: usize = 40; + +fn read_u32(response: &[u8], offset: &mut usize) -> Option { + let bytes: [u8; 4] = response.get(*offset..*offset + 4)?.try_into().ok()?; + *offset += 4; + Some(u32::from_le_bytes(bytes)) +} + +fn read_u64(response: &[u8], offset: &mut usize) -> Option { + let bytes: [u8; 8] = response.get(*offset..*offset + 8)?.try_into().ok()?; + *offset += 8; + Some(u64::from_le_bytes(bytes)) +} + +fn decode_network_info(response: &[u8]) -> Option { + // Network-info responses are binary, with one variable-width field: + // the wallet prefix is whatever remains after the fixed fields. + if response.len() <= NETWORK_INFO_FIXED_BYTES_WITHOUT_PREFIX { + return None; + } + + let wallet_prefix_len = response.len() - NETWORK_INFO_FIXED_BYTES_WITHOUT_PREFIX; + let mut offset = 0; + + let version = *response.get(offset)?; + offset += 1; + + // The network name is the fixed 7-byte value from network settings: + // "mainnet", "testnet", or "Invalid". + let network = String::from_utf8_lossy(response.get(offset..offset + NETWORK_NAME_BYTES)?) + .trim() + .to_string(); + offset += NETWORK_NAME_BYTES; + + let time = read_u32(response, &mut offset)?; + + // Mainnet uses CLC and testnet uses CLTC, so the prefix length is + // inferred from the total payload size instead of hard-coded. + let wallet_prefix = + String::from_utf8_lossy(response.get(offset..offset + wallet_prefix_len)?) + .trim() + .to_string(); + offset += wallet_prefix_len; + + let height = read_u32(response, &mut offset)?; + let next_block_difficulty = read_u64(response, &mut offset)?; + let total_block_transactions = read_u32(response, &mut offset)?; + let total_mempool_transactions = read_u32(response, &mut offset)?; + let largest_tx_fee = read_u64(response, &mut offset)?; + + // Print JSON for the CLI user, but this is decoded from the binary + // RPC payload and no JSON crossed the TCP stream. + let output = json!({ + "version": version, + "network": network, + "time": time, + "wallet_prefix": wallet_prefix, + "height": height, + "next_block_difficulty": next_block_difficulty, + "total_block_transactions": total_block_transactions, + "total_mempool_transactions": total_mempool_transactions, + "largest_tx_fee": largest_tx_fee, + }); + + to_string_pretty(&output).ok() +} + +#[tokio::main] +async fn main() { + // Command 1 asks a peer for its current network-info snapshot. + let hashmap_key = generate_uid(); + let rpc_command = 1; + + // This lookup takes no arguments; the wallet key is only used for + // the authenticated handshake before the RPC command is sent. + let args: Vec = env::args().collect(); + if args.len() != 1 { + println!("Usage: ./lookup_network_info"); + return; + } + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Try configured peers in order and stop at the first readable + // network-info response or text error. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + "".to_string(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // Prefer binary network-info decoding; if that fails, + // print a plain text error returned by the peer. + if let Some(output) = decode_network_info(&response) { + println!("{output}"); + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + } + Err(_) => { + connected = false; + } + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_nft.rs b/src/bin/lookup_nft.rs new file mode 100644 index 0000000..7542f02 --- /dev/null +++ b/src/bin/lookup_nft.rs @@ -0,0 +1,233 @@ +use blockchain::common::binary_conversions::binary_to_string; +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::encode; +use blockchain::env; +use blockchain::json; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::wallets::structures::Wallet; +use blockchain::{Map, Value}; + +const NFT_NAME_BYTES: usize = 15; +const SERIES_BYTES: usize = 4; +const GENESIS_HASH_BYTES: usize = 32; +const CREATOR_BYTES: usize = Wallet::SHORT_ADDRESS_BYTES_LENGTH; +const IPFS_BYTES: usize = 100; +const HOLDER_BYTES: usize = Wallet::SHORT_ADDRESS_BYTES_LENGTH; +const HISTORY_COUNT_BYTES: usize = 4; +const HISTORY_ENTRY_SIZE: usize = + 32 + 4 + 1 + 1 + (2 * Wallet::SHORT_ADDRESS_BYTES_LENGTH) + 15 + 4 + 8; +const NFT_LOOKUP_FIXED_BYTES: usize = NFT_NAME_BYTES + + SERIES_BYTES + + GENESIS_HASH_BYTES + + CREATOR_BYTES + + IPFS_BYTES + + HOLDER_BYTES + + HISTORY_COUNT_BYTES; + +fn action_name(action: u8) -> &'static str { + // History action bytes are stored compactly on the wire and expanded for display. + match action { + 1 => "create_nft", + 2 => "transfer", + 3 => "swap", + 4 => "loan_locked", + 5 => "loan_payment", + 6 => "collateral_claimed", + 7 => "loan_issued", + _ => "unknown", + } +} + +fn decode_wallet(bytes: &[u8]) -> String { + // Empty wallet fields are encoded as all zeroes; otherwise decode a short address. + if bytes.iter().all(|b| *b == 0) { + String::new() + } else { + Wallet::bytes_to_short_address(bytes).unwrap_or_default() + } +} + +fn decode_history_entry(entry: &[u8]) -> Option { + // Each history row has a fixed binary size so bad lengths mean an invalid response. + if entry.len() != HISTORY_ENTRY_SIZE { + return None; + } + + // Decode the history fields in the same order the RPC command writes them. + let txid = encode(&entry[0..32]); + let block = u32::from_le_bytes(entry[32..36].try_into().ok()?); + let txtype = entry[36]; + let action = entry[37]; + let from = decode_wallet(&entry[38..60]); + let to = decode_wallet(&entry[60..82]); + let received_asset = binary_to_string(entry[82..97].to_vec()).trim().to_string(); + let received_series = u32::from_le_bytes(entry[97..101].try_into().ok()?); + let received_value_raw = u64::from_le_bytes(entry[101..109].try_into().ok()?); + let received_value = received_value_raw as f64 / 100_000_000.0; + + // Omit empty optional fields so the CLI output stays readable. + let mut obj = Map::new(); + obj.insert("txid".to_string(), json!(txid)); + obj.insert("block".to_string(), json!(block)); + obj.insert("txtype".to_string(), json!(txtype)); + obj.insert("action".to_string(), json!(action_name(action))); + + if !from.is_empty() { + obj.insert("from".to_string(), json!(from)); + } + if !to.is_empty() { + obj.insert("to".to_string(), json!(to)); + } + if !received_asset.is_empty() { + obj.insert("received_asset".to_string(), json!(received_asset)); + if received_series > 0 { + obj.insert("received_series".to_string(), json!(received_series)); + } + obj.insert("received_value".to_string(), json!(received_value)); + } + + Some(Value::Object(obj)) +} + +fn decode_nft_lookup(response: &[u8]) -> Option { + // The fixed NFT fields must exist before any history entries can be decoded. + if response.len() < NFT_LOOKUP_FIXED_BYTES { + return None; + } + + // Decode the fixed-width NFT metadata fields in wire order. + let mut cursor = 0usize; + let nft_name = binary_to_string(response[cursor..cursor + NFT_NAME_BYTES].to_vec()) + .trim() + .to_string(); + cursor += NFT_NAME_BYTES; + + let series = u32::from_le_bytes(response[cursor..cursor + SERIES_BYTES].try_into().ok()?); + cursor += SERIES_BYTES; + + let genesis = encode(&response[cursor..cursor + GENESIS_HASH_BYTES]); + cursor += GENESIS_HASH_BYTES; + + let creator = Wallet::bytes_to_short_address(&response[cursor..cursor + CREATOR_BYTES])?; + cursor += CREATOR_BYTES; + + let ipfs_cid = binary_to_string(response[cursor..cursor + IPFS_BYTES].to_vec()) + .trim() + .to_string(); + cursor += IPFS_BYTES; + + let current_holder = decode_wallet(&response[cursor..cursor + HOLDER_BYTES]); + cursor += HOLDER_BYTES; + + let history_count = u32::from_le_bytes( + response[cursor..cursor + HISTORY_COUNT_BYTES] + .try_into() + .ok()?, + ); + cursor += HISTORY_COUNT_BYTES; + + // Any remaining bytes are fixed-size history entries. + let mut history = Vec::new(); + let mut offset = cursor; + while offset + HISTORY_ENTRY_SIZE <= response.len() { + if let Some(entry) = decode_history_entry(&response[offset..offset + HISTORY_ENTRY_SIZE]) { + history.push(entry); + } + offset += HISTORY_ENTRY_SIZE; + } + + // Convert the binary record into user-facing JSON. + let mut output = Map::new(); + output.insert("nft_name".to_string(), json!(nft_name)); + output.insert("series".to_string(), json!(series)); + if series == 0 { + output.insert("ipfs".to_string(), json!(format!("ipfs://{ipfs_cid}"))); + } else { + output.insert( + "ipfs".to_string(), + json!(format!("ipfs://{ipfs_cid}/{series}.json")), + ); + } + if series > 0 { + output.insert( + "asset_name".to_string(), + json!(format!("{nft_name}_{series}")), + ); + } + output.insert("genesis".to_string(), json!(genesis)); + output.insert("creator".to_string(), json!(creator)); + output.insert("current_holder".to_string(), json!(current_holder)); + output.insert("history_count".to_string(), json!(history_count)); + output.insert("history".to_string(), Value::Array(history)); + + serde_json::to_string_pretty(&Value::Object(output)).ok() +} + +#[tokio::main] +async fn main() { + // Command 36 asks a peer for NFT metadata by name and series/item number. + let hashmap_key = generate_uid(); + let rpc_command = 36; + + // Item number 0 means a 1/1 NFT; nonzero values identify a series item. + let args: Vec = env::args().collect(); + if args.len() != 3 { + println!("Usage: ./lookup_nft "); + println!("Use item_number 0 for a 1/1 NFT, or the specific item number for a series NFT."); + return; + } + + let nft_name = args[1].clone(); + let item_number = args[2].clone(); + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + // sending_request expects command 36 lookup input as "name|series". + let payload = format!("{nft_name}|{item_number}"); + + // Try each configured peer until one returns a parsable NFT record or text error. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + payload.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // Successful NFT lookups are binary records; errors are returned as text. + if let Some(output) = decode_nft_lookup(&response) { + println!("{output}"); + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_nft_list.rs b/src/bin/lookup_nft_list.rs new file mode 100644 index 0000000..42ef114 --- /dev/null +++ b/src/bin/lookup_nft_list.rs @@ -0,0 +1,119 @@ +use blockchain::common::binary_conversions::binary_to_string; +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::json; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::to_string_pretty; +use blockchain::{Map, Value}; + +fn decode_nft_list(response: &[u8]) -> Option { + // An empty binary payload means the peer has no NFT index entries. + if response.is_empty() { + return Some("{\n \"nfts\": {}\n}".to_string()); + } + + // Each NFT-list row is 32 bytes hash, 15 bytes name, and 4 bytes series. + if response.len() % 51 != 0 { + return None; + } + + let mut grouped = Map::new(); + let mut offset = 0; + while offset + 51 <= response.len() { + // Group entries under their genesis hash so a collection can contain multiple series items. + let hash = binary_to_string(response[offset..offset + 32].to_vec()) + .trim() + .to_string(); + let nft_name = binary_to_string(response[offset + 32..offset + 47].to_vec()) + .trim() + .to_string(); + let series = u32::from_le_bytes(response[offset + 47..offset + 51].try_into().ok()?); + + if !hash.is_empty() && !nft_name.is_empty() { + let entry = grouped + .entry(hash) + .or_insert_with(|| Value::Array(Vec::new())); + + if let Value::Array(items) = entry { + items.push(json!({ + "nft_name": nft_name, + "series": series, + })); + } + } + + offset += 51; + } + + let output = json!({ + "nfts": Value::Object(grouped), + }); + to_string_pretty(&output).ok() +} + +#[tokio::main] +async fn main() { + // Command 32 asks a peer for the full NFT list. + let hashmap_key = generate_uid(); + let rpc_command = 32; + + // This lookup takes no user arguments beyond the wallet key used for handshake auth. + let args: Vec = env::args().collect(); + if args.len() != 1 { + println!("Usage: ./nft_list"); + return; + } + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + // Try each configured peer until one returns a parsable NFT list or text error. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + "".to_string(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // Prefer binary NFT-list decoding; otherwise print a text error if one was returned. + if let Some(output) = decode_nft_list(&response) { + println!("{output}"); + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } else { + connected = false; + } + } + } + Err(_) => { + connected = false; + } + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_node_time.rs b/src/bin/lookup_node_time.rs new file mode 100644 index 0000000..f7640f2 --- /dev/null +++ b/src/bin/lookup_node_time.rs @@ -0,0 +1,73 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::{TimeZone, Utc}; + +#[tokio::main] +async fn main() { + // Command 4 asks a peer for its current UTC timestamp. + let rpc_command = 4; + + // This lookup takes no arguments beyond the wallet key used for the handshake. + let args: Vec = env::args().collect(); + if args.len() != 1 { + println!("Usage: ./lookup_node_time"); + return; + } + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Try each configured peer until one returns a 4-byte timestamp. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + "".to_string(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + generate_uid(), + ) + .await; + + match result { + Ok(response) if response.len() == 4 => { + let timestamp = u32::from_le_bytes(response[0..4].try_into().unwrap()); + let time = Utc + .timestamp_opt(timestamp as i64, 0) + .single() + .map(|datetime| datetime.format("%H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "invalid UTC timestamp".to_string()); + println!("timestamp: {timestamp}"); + println!("time: {time}"); + connected = true; + } + Ok(response) => { + // Text errors are printed directly; unknown binary is ignored so another peer can be tried. + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_remote_balance.rs b/src/bin/lookup_remote_balance.rs new file mode 100644 index 0000000..c64ac92 --- /dev/null +++ b/src/bin/lookup_remote_balance.rs @@ -0,0 +1,177 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::from_str; +use blockchain::read_to_string; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::tilde; +use blockchain::Value; +use rustyline::completion::FilenameCompleter; +use rustyline::error::ReadlineError; +use rustyline::{history::DefaultHistory, CompletionType, Config, Editor}; +use rustyline_derive::Completer; +use rustyline_derive::Helper as RustyHelper; +use rustyline_derive::Highlighter as RustyHighlighter; +use rustyline_derive::Hinter as RustyHinter; +use rustyline_derive::Validator as RustyValidator; + +fn format_balance(balance: u64) -> String { + // Balance values are atomic units and display with 8 decimal places. + let whole = balance / 100_000_000; + let fractional = balance % 100_000_000; + format!("{whole}.{fractional:08}") +} + +fn extract_address(contents: &str) -> Result { + // Address files may be plain text or saved wallet JSON. + let trimmed = contents.trim(); + if trimmed.is_empty() { + return Err("Address file is empty".to_string()); + } + + if trimmed.starts_with('{') { + // Prefer short_address for balance lookups, but accept long_address too. + let value: Value = + from_str(trimmed).map_err(|e| format!("Failed to parse wallet JSON: {e}"))?; + let address = value + .get("short_address") + .and_then(|v| v.as_str()) + .or_else(|| value.get("long_address").and_then(|v| v.as_str())) + .ok_or_else(|| "Wallet JSON does not contain a usable address field".to_string())?; + return Ok(address.trim().to_string()); + } + + Ok(trimmed.to_string()) +} + +#[derive(RustyHelper, Completer, RustyHinter, RustyHighlighter, RustyValidator)] +struct PathHelper { + #[rustyline(Completer)] + completer: FilenameCompleter, +} + +fn prompt_for_path(prompt: &str) -> Result { + // Rustyline gives the interactive prompt filesystem completion. + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => Ok(line.trim().to_string()), + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read address file path: {err}")), + } +} + +#[tokio::main] +async fn main() { + // Command 23 asks a peer for all asset balances for one wallet address. + let hashmap_key = generate_uid(); + + let rpc_command = 23; + + // Accept an address file path as an arg or prompt interactively. + let args: Vec = env::args().collect(); + + if args.len() > 1 && args.len() != 2 { + println!("Usage: ./address_balance_lookup "); + return; + } + + let address_file_path = if args.len() == 2 { + args[1].clone() + } else { + match prompt_for_path( + "Please enter the path to the file containing the wallet address: ", + ) { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + } + }; + + // Extract the lookup address from the selected file before opening a peer connection. + let expanded_path = tilde(&address_file_path).to_string(); + let address_contents = read_to_string(&expanded_path) + .await + .expect("Failed to read address file"); + let wallet_address = match extract_address(&address_contents) { + Ok(address) => address, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let json = wallet_address; + // Try each configured peer until one returns a parsable balance response or text error. + let connections = get_connections().await; + + let mut connected: bool = false; + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + json.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + match result { + Ok(response) => { + if response.is_empty() { + connected = true; + println!("[]"); + break; + } + // Balance rows are 15 bytes asset name, 4 bytes NFT series, and 8 bytes balance. + if response.len() % 27 == 0 { + for entry in response.chunks_exact(27) { + let name_bytes = &entry[..15]; + let nft_series = u32::from_le_bytes(entry[15..19].try_into().unwrap()); + let balance = u64::from_le_bytes(entry[19..27].try_into().unwrap()); + let asset_name = String::from_utf8_lossy(name_bytes).trim_end().to_string(); + let formatted_balance = format_balance(balance); + if nft_series > 0 { + println!("{asset_name}:{nft_series} = {formatted_balance}"); + } else { + println!("{asset_name} = {formatted_balance}"); + } + } + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + } + Err(_) => connected = false, + } + } + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_token.rs b/src/bin/lookup_token.rs new file mode 100644 index 0000000..86e09fe --- /dev/null +++ b/src/bin/lookup_token.rs @@ -0,0 +1,172 @@ +use blockchain::common::binary_conversions::binary_to_string; +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::json; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::wallets::structures::Wallet; + +const TOKEN_NAME_BYTES: usize = 15; +const HASH_BYTES: usize = 32; +const CREATOR_BYTES: usize = Wallet::SHORT_ADDRESS_BYTES_LENGTH; +const TOKEN_COUNT_BYTES: usize = 8; +const TOKEN_SPREAD_BYTES: usize = 4; +const HARD_LIMIT_BYTES: usize = 1; +const ISSUE_COUNT_BYTES: usize = 4; +const TOKEN_LOOKUP_FIXED_BYTES: usize = TOKEN_NAME_BYTES + + HASH_BYTES + + CREATOR_BYTES + + TOKEN_COUNT_BYTES + + TOKEN_SPREAD_BYTES + + HARD_LIMIT_BYTES + + ISSUE_COUNT_BYTES; + +#[tokio::main] +async fn main() { + // Command 35 asks a peer for token metadata by token name. + let hashmap_key = generate_uid(); + let rpc_command = 35; + + // The request encoder pads/truncates the token name to the fixed 15-byte field. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./lookup_token "); + return; + } + + let token_name = args[1].clone(); + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Try each configured peer until one returns a parsable token record or text error. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + token_name.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // Successful token lookups are binary records with variable issue/burn hash lists. + if let Some(token_json) = decode_token_lookup(&response) { + println!("{token_json}"); + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} + +fn decode_token_lookup(response: &[u8]) -> Option { + // The fixed portion must be present before reading variable-length issue/burn lists. + if response.len() < TOKEN_LOOKUP_FIXED_BYTES { + return None; + } + + // Decode the fixed-width token fields in the same order the RPC command writes them. + let mut cursor = 0usize; + let token_name = binary_to_string(response[cursor..cursor + TOKEN_NAME_BYTES].to_vec()) + .trim() + .to_string(); + cursor += TOKEN_NAME_BYTES; + + let contract = hex::encode(&response[cursor..cursor + HASH_BYTES]); + cursor += HASH_BYTES; + + let creator = Wallet::bytes_to_short_address(&response[cursor..cursor + CREATOR_BYTES])?; + cursor += CREATOR_BYTES; + + let token_count = u64::from_le_bytes( + response[cursor..cursor + TOKEN_COUNT_BYTES] + .try_into() + .ok()?, + ); + cursor += TOKEN_COUNT_BYTES; + + let token_spread = u32::from_le_bytes( + response[cursor..cursor + TOKEN_SPREAD_BYTES] + .try_into() + .ok()?, + ); + cursor += TOKEN_SPREAD_BYTES; + + let hard_limit = response[cursor]; + cursor += HARD_LIMIT_BYTES; + + let issue_count = u32::from_le_bytes( + response[cursor..cursor + ISSUE_COUNT_BYTES] + .try_into() + .ok()?, + ); + cursor += ISSUE_COUNT_BYTES; + + // After the issue count, each issue hash is 32 bytes. + let issue_bytes = issue_count as usize * 32; + if response.len() < cursor + issue_bytes + 4 { + return None; + } + + let mut issued_hashes = Vec::with_capacity(issue_count as usize); + for chunk in response[cursor..cursor + issue_bytes].chunks(32) { + issued_hashes.push(hex::encode(chunk)); + } + cursor += issue_bytes; + + let burn_count = u32::from_le_bytes(response[cursor..cursor + 4].try_into().ok()?); + cursor += 4; + // The final section is a fixed number of 32-byte burn hashes. + let burn_bytes = burn_count as usize * 32; + if response.len() != cursor + burn_bytes { + return None; + } + + let mut burned_hashes = Vec::with_capacity(burn_count as usize); + for chunk in response[cursor..cursor + burn_bytes].chunks(32) { + burned_hashes.push(hex::encode(chunk)); + } + + // Convert atomic token units and counters into user-facing JSON. + let display_token_count = token_count as f64 / 100_000_000.0; + let display_token_spread = format!("{token_spread} addresses"); + + let output = json!({ + "token_name": token_name, + "genesis": contract, + "creator": creator, + "token_count": display_token_count, + "token_spread": display_token_spread, + "hard_limit": hard_limit == 1, + "issued_hashes": issued_hashes, + "burned_hashes": burned_hashes + }); + + serde_json::to_string_pretty(&output).ok() +} diff --git a/src/bin/lookup_token_list.rs b/src/bin/lookup_token_list.rs new file mode 100644 index 0000000..0214379 --- /dev/null +++ b/src/bin/lookup_token_list.rs @@ -0,0 +1,109 @@ +use blockchain::common::binary_conversions::binary_to_string; +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::json; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::to_string_pretty; + +fn decode_token_list(response: &[u8]) -> Option { + // An empty binary payload means the peer has no token index entries. + if response.is_empty() { + return Some("{\n \"tokens\": []\n}".to_string()); + } + + // Each token-list row is 15 bytes token name and 64 bytes token hash text. + if response.len() % 79 != 0 { + return None; + } + + let mut tokens = Vec::new(); + let mut offset = 0; + while offset + 79 <= response.len() { + // Convert fixed-width padded fields back into display strings. + let token = binary_to_string(response[offset..offset + 15].to_vec()) + .trim() + .to_string(); + let hash = binary_to_string(response[offset + 15..offset + 79].to_vec()) + .trim() + .to_string(); + if !token.is_empty() && !hash.is_empty() { + tokens.push(json!({ + "token": token, + "hash": hash, + })); + } + offset += 79; + } + + let output = json!({ + "tokens": tokens, + }); + to_string_pretty(&output).ok() +} + +#[tokio::main] +async fn main() { + // Command 31 asks a peer for the full token list. + let hashmap_key = generate_uid(); + let rpc_command = 31; + + // This lookup takes no user arguments beyond the wallet key used for handshake auth. + let args: Vec = env::args().collect(); + if args.len() != 1 { + println!("Usage: ./token_list"); + return; + } + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + // Try each configured peer until one returns a parsable token list or text error. + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + "".to_string(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // Prefer binary token-list decoding; otherwise print a text error if one was returned. + if let Some(output) = decode_token_list(&response) { + println!("{output}"); + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } else { + connected = false; + } + } + } + Err(_) => { + connected = false; + } + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_torrent.rs b/src/bin/lookup_torrent.rs new file mode 100644 index 0000000..a70665e --- /dev/null +++ b/src/bin/lookup_torrent.rs @@ -0,0 +1,91 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::to_string_pretty; +use blockchain::torrent::structs::Torrent; + +#[tokio::main] +async fn main() { + let hashmap_key = generate_uid(); + + // set the rpc command number + let rpc_command = 12; + + // Get command-line arguments + let args: Vec = env::args().collect(); + + // Check if a command-line argument is provided + if args.len() != 2 { + println!("Usage: ./request_torrent "); + return; + } + + // Extract the block number from the command-line argument + let block_number = match args[1].parse::() { + Ok(num) => num, + Err(_) => { + println!("Please enter a valid number"); + return; + } + }; + + // Extract the encryption key from the command-line arguments + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let json = block_number.to_string(); + let connections = get_connections().await; + + let mut connected: bool = false; + // Iterate over the returned Vec and print each connection + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + json.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + match result { + Ok(response) => { + if let Ok(torrent) = Torrent::from_bytes(&response).await { + match to_string_pretty(&torrent) { + Ok(torrent_json) => { + println!("{torrent_json}"); + connected = true; + } + Err(err) => { + eprintln!("failed to serialize torrent json: {err}"); + } + } + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } else if !response.is_empty() { + println!("{}", hex::encode(response)); + connected = true; + } + } + } + Err(_) => { + connected = false; + } + } + } + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/lookup_total_transactions.rs b/src/bin/lookup_total_transactions.rs new file mode 100644 index 0000000..859aeaa --- /dev/null +++ b/src/bin/lookup_total_transactions.rs @@ -0,0 +1,120 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::common::types::{ + BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, + ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, REWARDS_TYPE, SWAP_TYPE, TRANSFER_TYPE, + VANITY_ADDRESS_TYPE, +}; +use blockchain::env; +use blockchain::json; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; + +#[tokio::main] +async fn main() { + // Get command-line arguments + let args: Vec = env::args().collect(); + + // Check if a command-line argument is provided + if args.len() != 1 { + println!("Usage: ./total_transactions"); + return; + } + + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // set the rpc command number + let rpc_command = 18; + let json = "".to_string(); + let connections = get_connections().await; + + let mut connected: bool = false; + let mut saw_empty_response = false; + // Iterate over the returned Vec and print each connection + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let hashmap_key = generate_uid(); + + let result = handshake::connect_and_handshake( + socket_address, + json.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + if response.is_empty() { + saw_empty_response = true; + println!("[]"); + } else { + let mut output = Vec::new(); + for chunk in response.chunks_exact(17) { + let txtype = chunk[0]; + let mut total_bytes = [0u8; 8]; + total_bytes.copy_from_slice(&chunk[1..9]); + let total = u64::from_le_bytes(total_bytes); + + let mut non_zero_bytes = [0u8; 8]; + non_zero_bytes.copy_from_slice(&chunk[9..17]); + let non_zero = u64::from_le_bytes(non_zero_bytes); + + let tx_name = match txtype { + REWARDS_TYPE => "rewards", + TRANSFER_TYPE => "transfer", + CREATE_TOKEN_TYPE => "token", + CREATE_NFT_TYPE => "nft", + MARKETING_TYPE => "marketing", + SWAP_TYPE => "swap", + LENDER_TYPE => "loan", + BORROWER_TYPE => "loan_payment", + COLLATERAL_TYPE => "collateral_claim", + BURN_TYPE => "burn", + ISSUE_TOKEN_TYPE => "issue_token", + VANITY_ADDRESS_TYPE => "vanity_address", + _ => "unknown", + }; + + if txtype == REWARDS_TYPE { + output.push(json!({ + "txtype": txtype, + "type": tx_name, + "total": total, + "non_zero": non_zero + })); + } else { + output.push(json!({ + "txtype": txtype, + "type": tx_name, + "total": total + })); + } + } + + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } + connected = true; + } + Err(_) => { + connected = false; + } + } + } + + if !connected { + if saw_empty_response { + println!("[]"); + } else { + eprintln!("Unable to reach any node or retrieve a transaction count."); + } + } +} diff --git a/src/bin/lookup_transaction.rs b/src/bin/lookup_transaction.rs new file mode 100644 index 0000000..b156e6b --- /dev/null +++ b/src/bin/lookup_transaction.rs @@ -0,0 +1,162 @@ +use blockchain::blocks::collateral::CollateralClaimTransaction; +use blockchain::blocks::genesis::GenesisTransaction; +use blockchain::blocks::loan_payment::ContractPaymentTransaction; +use blockchain::blocks::loans::LoanContractTransaction; +use blockchain::blocks::marketing::MarketingTransaction; +use blockchain::blocks::nft::CreateNftTransaction; +use blockchain::blocks::rewards::RewardsTransaction; +use blockchain::blocks::swap::SwapTransaction; +use blockchain::blocks::token::CreateTokenTransaction; +use blockchain::blocks::transfer::TransferTransaction; +use blockchain::blocks::vanity::VanityAddressTransaction; +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::common::types::{ + BORROWER_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, GENESIS_TYPE, LENDER_TYPE, + MARKETING_TYPE, REWARDS_TYPE, SWAP_TYPE, TRANSFER_TYPE, VANITY_ADDRESS_TYPE, +}; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::to_string_pretty; + +#[tokio::main] +async fn main() { + let hashmap_key = generate_uid(); + + // set the rpc command number + let rpc_command = 17; + + // Get command-line arguments + let args: Vec = env::args().collect(); + + // Check if a command-line argument is provided + if args.len() != 2 { + println!("Usage: ./lookup_transaction "); + return; + } + + // Extract the block number from the command-line argument + let block_hash = match args[1].parse::() { + Ok(num) => num, + Err(_) => { + println!("Please enter a hash"); + return; + } + }; + + // Extract the encryption ley from the command-line argument + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let json = block_hash.to_string(); + + let connections = get_connections().await; + + let mut connected: bool = false; + // Iterate over the returned Vec and print each connection + for conn in connections { + if connected { + break; + } + let socket_address = conn.parse().expect("Failed to parse the socket address"); + + let result = handshake::connect_and_handshake( + socket_address, + json.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(encryption_key.clone()), + hashmap_key, + ) + .await; + match result { + Ok(response) => { + if let Some(transaction_json) = decode_transaction_to_json(&response).await { + println!("{transaction_json}"); + connected = true; + } else { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } else if !response.is_empty() { + println!("{}", hex::encode(response)); + connected = true; + } + } + } + Err(_) => connected = false, + } + } + if !connected { + eprintln!("failed to connect"); + } +} + +async fn decode_transaction_to_json(response: &[u8]) -> Option { + if response.len() < 5 { + return None; + } + + let block_number = u32::from_le_bytes(response[0..4].try_into().ok()?); + let txtype = response[4]; + let body = &response[5..]; + + let json = match txtype { + GENESIS_TYPE => { + to_string_pretty(&GenesisTransaction::from_bytes(txtype, body).await.ok()?).ok()? + } + REWARDS_TYPE => { + to_string_pretty(&RewardsTransaction::from_bytes(txtype, body).await.ok()?).ok()? + } + TRANSFER_TYPE => { + to_string_pretty(&TransferTransaction::from_bytes(txtype, body).await.ok()?).ok()? + } + CREATE_TOKEN_TYPE => to_string_pretty( + &CreateTokenTransaction::from_bytes(txtype, body) + .await + .ok()?, + ) + .ok()?, + CREATE_NFT_TYPE => { + to_string_pretty(&CreateNftTransaction::from_bytes(txtype, body).await.ok()?).ok()? + } + MARKETING_TYPE => { + to_string_pretty(&MarketingTransaction::from_bytes(txtype, body).await.ok()?).ok()? + } + SWAP_TYPE => { + to_string_pretty(&SwapTransaction::from_bytes(txtype, body).await.ok()?).ok()? + } + LENDER_TYPE => to_string_pretty( + &LoanContractTransaction::from_bytes(txtype, body) + .await + .ok()?, + ) + .ok()?, + BORROWER_TYPE => to_string_pretty( + &ContractPaymentTransaction::from_bytes(txtype, body) + .await + .ok()?, + ) + .ok()?, + COLLATERAL_TYPE => to_string_pretty( + &CollateralClaimTransaction::from_bytes(txtype, body) + .await + .ok()?, + ) + .ok()?, + VANITY_ADDRESS_TYPE => to_string_pretty( + &VanityAddressTransaction::from_bytes(txtype, body) + .await + .ok()?, + ) + .ok()?, + _ => return None, + }; + + Some(format!("block: {block_number}\n{json}")) +} diff --git a/src/bin/postgres_installer.rs b/src/bin/postgres_installer.rs new file mode 100644 index 0000000..a9c5dcb --- /dev/null +++ b/src/bin/postgres_installer.rs @@ -0,0 +1,531 @@ +use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible_with_default}; +#[cfg(windows)] +use std::env; +#[cfg(unix)] +use std::error::Error; +#[cfg(windows)] +use std::error::Error; +#[cfg(unix)] +use std::fs; +#[cfg(windows)] +use std::fs; +#[cfg(windows)] +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::process::Command; +#[cfg(unix)] +use std::process::{Command, Stdio}; + +#[cfg(windows)] +const DEFAULT_PG_VERSION: &str = "16.4-1"; + +#[cfg(unix)] +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("\n=== Contractless Postgres Installer ===\n"); + + // Collect the settings values that will be printed for settings.ini after setup. + let host = prompt_visible_with_default("Enter Postgres host", "127.0.0.1").await; + let port = prompt_visible_with_default("Enter Postgres port", "5432").await; + let dbname = + prompt_visible_with_default("Enter new database name for blockchain", "contractless_db") + .await; + let user = + prompt_visible_with_default("Enter new username for blockchain database", "contractless") + .await; + let user_pass = prompt_hidden_nonempty( + "Enter password for new database user: ", + "Password cannot be empty. Please try again.", + ) + .await; + + // Linux setup edits Postgres config and may install packages, so it requires root. + if !nix::unistd::Uid::effective().is_root() { + eprintln!("\n[!] This installer requires root privileges. Please run with sudo.\n"); + std::process::exit(1); + } + + // Install PostgreSQL when psql is not already available. + let psql_installed = Command::new("which").arg("psql").output()?.status.success(); + if !psql_installed { + println!("\n[+] PostgreSQL not found. Installing..."); + Command::new("apt-get").args(["update"]).status()?; + Command::new("apt-get") + .args(["install", "-y", "postgresql"]) + .status()?; + } else { + println!("[✓] PostgreSQL already installed"); + } + + // Ensure pg_hba.conf allows local password auth for the blockchain database user. + println!("\n[~] Configuring password-based login..."); + let pg_hba_path = find_pg_hba_path()?; + let hba_contents = fs::read_to_string(&pg_hba_path)?; + if !hba_contents.contains("host all all 127.0.0.1/32 md5") { + let mut lines: Vec = hba_contents.lines().map(String::from).collect(); + lines.push(String::from("host all all 127.0.0.1/32 md5")); + lines.push(String::from("host all all ::1/128 md5")); + fs::write(&pg_hba_path, lines.join("\n"))?; + println!("[+] Updated pg_hba.conf to enable md5 authentication"); + + // Reload Postgres so pg_hba.conf changes are applied without a reboot. + Command::new("systemctl") + .args(["reload", "postgresql"]) + .status()?; + println!("[✓] PostgreSQL reloaded"); + } else { + println!("[✓] Password auth already enabled"); + } + + // Create the dedicated blockchain database user. + let create_user_cmd = format!("CREATE USER {user} WITH PASSWORD '{user_pass}';"); + let user_status = Command::new("sudo") + .arg("-u") + .arg("postgres") + .arg("psql") + .arg("-c") + .arg(&create_user_cmd) + .status()?; + + // Create the blockchain database owned by that dedicated user. + let create_db_cmd = format!("CREATE DATABASE {dbname} OWNER {user};"); + let db_status = Command::new("sudo") + .arg("-u") + .arg("postgres") + .arg("psql") + .arg("-c") + .arg(&create_db_cmd) + .status()?; + + if !db_status.success() { + return Err("❌ Failed to create database (maybe it already exists?)".into()); + } + + if !user_status.success() { + println!("❌ Failed to create user (maybe it already exists?)"); + } + + println!("\n[✓] Database and user created successfully!"); + println!("\nPaste the following into your settings.ini:\n"); + println!("[Postgres]"); + println!("host = {host}"); + println!("port = {port}"); + println!("user = {user}"); + println!("password = {user_pass}"); + println!("dbname = {dbname}"); + + Ok(()) +} + +#[cfg(not(unix))] +#[cfg(windows)] +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("\n=== Contractless Postgres Installer (Windows) ===\n"); + + // Windows installation writes into Program Files and manages a service. + ensure_administrator()?; + + // Collect install settings and the database credentials to print at the end. + let host = prompt_visible_with_default("Enter Postgres host", "127.0.0.1").await; + let port = prompt_visible_with_default("Enter Postgres port", "5432").await; + let dbname = + prompt_visible_with_default("Enter new database name for blockchain", "contractless_db") + .await; + let user = + prompt_visible_with_default("Enter new username for blockchain database", "contractless") + .await; + let user_pass = prompt_hidden_nonempty( + "Enter password for new database user: ", + "Password cannot be empty. Please try again.", + ) + .await; + let postgres_pass = prompt_hidden_nonempty( + "Enter password for the postgres superuser/service account: ", + "Password cannot be empty. Please try again.", + ) + .await; + let install_dir = prompt_visible_with_default( + "Enter PostgreSQL install directory", + r"C:\Program Files\PostgreSQL\16", + ) + .await; + let data_dir = prompt_visible_with_default( + "Enter PostgreSQL data directory", + r"C:\Program Files\PostgreSQL\16\data", + ) + .await; + let service_name = prompt_visible_with_default( + "Enter PostgreSQL Windows service name", + "postgresql-contractless", + ) + .await; + + validate_identifier("database name", &dbname)?; + validate_identifier("database user", &user)?; + + // The installer may create PostgreSQL, but existing installs are reused. + let prefix = PathBuf::from(&install_dir); + let datadir = PathBuf::from(&data_dir); + let psql_path = prefix.join("bin").join("psql.exe"); + + if !psql_path.exists() { + let installer_path = download_installer(DEFAULT_PG_VERSION)?; + run_unattended_installer( + &installer_path, + &prefix, + &datadir, + &port, + &service_name, + &postgres_pass, + )?; + } else { + println!("[✓] PostgreSQL already installed at {}", prefix.display()); + } + + if !psql_path.exists() { + return Err(format!( + "psql.exe was not found after installation at {}", + psql_path.display() + ) + .into()); + } + + wait_for_psql_ready(&psql_path, &host, &port, &postgres_pass)?; + ensure_database_user(&psql_path, &host, &port, &user, &user_pass, &postgres_pass)?; + ensure_database(&psql_path, &host, &port, &dbname, &user, &postgres_pass)?; + test_blockchain_login(&psql_path, &host, &port, &dbname, &user, &user_pass)?; + + println!("\n[✓] PostgreSQL installed and configured successfully!"); + println!("\nPaste the following into your settings-windows.ini or settings.ini:\n"); + println!("[Postgres]"); + println!("host = {host}"); + println!("port = {port}"); + println!("user = {user}"); + println!("password = {user_pass}"); + println!("dbname = {dbname}"); + println!("\n[Postgres-Testnet]"); + println!("host = {host}"); + println!("port = {port}"); + println!("user = {user}"); + println!("password = {user_pass}"); + println!("dbname = {dbname}"); + + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn main() { + eprintln!("postgres_installer is only supported on Unix-like systems and Windows."); + std::process::exit(1); +} + +#[cfg(unix)] +fn find_pg_hba_path() -> Result> { + // Ask PostgreSQL for the active pg_hba.conf path instead of guessing distro paths. + let output = Command::new("sudo") + .arg("-u") + .arg("postgres") + .arg("psql") + .arg("-t") + .arg("-P") + .arg("format=unaligned") + .arg("-c") + .arg("SHOW hba_file;") + .stdout(Stdio::piped()) + .output()?; + + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + return Err("❌ Could not determine pg_hba.conf path".into()); + } + Ok(path) +} + +#[cfg(windows)] +fn ensure_administrator() -> Result<(), Box> { + // "net session" succeeds only from an elevated Windows terminal. + let status = Command::new("net").arg("session").status()?; + + if status.success() { + Ok(()) + } else { + Err("This installer requires Administrator privileges. Please run it from an elevated terminal.".into()) + } +} + +#[cfg(windows)] +fn download_installer(version: &str) -> Result> { + // Download the EDB installer once into temp and reuse it on repeated runs. + let installer_name = format!("postgresql-{version}-windows-x64.exe"); + let url = format!("https://get.enterprisedb.com/postgresql/{installer_name}"); + let temp_dir = env::temp_dir().join("contractless-postgres-installer"); + fs::create_dir_all(&temp_dir)?; + let installer_path = temp_dir.join(&installer_name); + + if installer_path.exists() { + println!( + "[✓] Reusing downloaded installer at {}", + installer_path.display() + ); + return Ok(installer_path); + } + + println!("[~] Downloading PostgreSQL installer from {url}"); + + let status = Command::new("powershell") + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + &format!( + "Invoke-WebRequest -Uri '{}' -OutFile '{}'", + url, + installer_path.display() + ), + ]) + .status()?; + + if !status.success() { + return Err("Failed to download the PostgreSQL installer.".into()); + } + + Ok(installer_path) +} + +#[cfg(windows)] +fn run_unattended_installer( + installer_path: &Path, + prefix: &Path, + datadir: &Path, + port: &str, + service_name: &str, + postgres_pass: &str, +) -> Result<(), Box> { + println!("[~] Running unattended PostgreSQL installer..."); + + // Use the command-line installer so the tool can run without GUI interaction. + let prefix_string = prefix.to_string_lossy().into_owned(); + let datadir_string = datadir.to_string_lossy().into_owned(); + + let status = Command::new(installer_path) + .arg("--mode") + .arg("unattended") + .arg("--unattendedmodeui") + .arg("minimal") + .arg("--superaccount") + .arg("postgres") + .arg("--superpassword") + .arg(postgres_pass) + .arg("--serviceaccount") + .arg("postgres") + .arg("--servicepassword") + .arg(postgres_pass) + .arg("--servicename") + .arg(service_name) + .arg("--serverport") + .arg(port) + .arg("--prefix") + .arg(&prefix_string) + .arg("--datadir") + .arg(&datadir_string) + .arg("--enable-components") + .arg("server,commandlinetools") + .arg("--disable-components") + .arg("pgAdmin,stackbuilder") + .status()?; + + if !status.success() { + return Err("PostgreSQL installer failed.".into()); + } + + Ok(()) +} + +#[cfg(windows)] +fn run_psql( + psql_path: &Path, + host: &str, + port: &str, + database: &str, + password: &str, + sql: &str, +) -> Result> { + // PGPASSWORD avoids interactive password prompts while still using normal psql auth. + let output = Command::new(psql_path) + .env("PGPASSWORD", password) + .args([ + "-h", + host, + "-p", + port, + "-U", + "postgres", + "-d", + database, + "-v", + "ON_ERROR_STOP=1", + "-t", + "-A", + "-c", + sql, + ]) + .output()?; + + Ok(output) +} + +#[cfg(windows)] +fn wait_for_psql_ready( + psql_path: &Path, + host: &str, + port: &str, + postgres_pass: &str, +) -> Result<(), Box> { + println!("[~] Waiting for PostgreSQL service to accept connections..."); + + // The Windows service can take a few seconds after install before psql accepts logins. + for _ in 0..30 { + let output = run_psql( + psql_path, + host, + port, + "postgres", + postgres_pass, + "SELECT 1;", + )?; + if output.status.success() { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_secs(2)); + } + + Err("PostgreSQL did not become ready in time.".into()) +} + +#[cfg(windows)] +fn ensure_database_user( + psql_path: &Path, + host: &str, + port: &str, + user: &str, + user_pass: &str, + postgres_pass: &str, +) -> Result<(), Box> { + // Create the blockchain login if missing, otherwise refresh its password. + let escaped_pass = user_pass.replace('\'', "''"); + let sql = format!( + "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{user}') THEN CREATE ROLE {user} LOGIN PASSWORD '{escaped_pass}'; ELSE ALTER ROLE {user} WITH LOGIN PASSWORD '{escaped_pass}'; END IF; END $$;" + ); + + let output = run_psql(psql_path, host, port, "postgres", postgres_pass, &sql)?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "Failed to create or update database user: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()) + } +} + +#[cfg(windows)] +fn ensure_database( + psql_path: &Path, + host: &str, + port: &str, + dbname: &str, + user: &str, + postgres_pass: &str, +) -> Result<(), Box> { + // Check first because CREATE DATABASE cannot run inside the DO block used for roles. + let check_sql = format!("SELECT 1 FROM pg_database WHERE datname = '{dbname}';"); + let output = run_psql(psql_path, host, port, "postgres", postgres_pass, &check_sql)?; + if !output.status.success() { + return Err(format!( + "Failed to check database existence: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + + if String::from_utf8_lossy(&output.stdout).trim() == "1" { + println!("[✓] Database {dbname} already exists"); + return Ok(()); + } + + let create_sql = format!("CREATE DATABASE {dbname} OWNER {user};"); + let create_output = run_psql( + psql_path, + host, + port, + "postgres", + postgres_pass, + &create_sql, + )?; + if create_output.status.success() { + Ok(()) + } else { + Err(format!( + "Failed to create database: {}", + String::from_utf8_lossy(&create_output.stderr) + ) + .into()) + } +} + +#[cfg(windows)] +fn test_blockchain_login( + psql_path: &Path, + host: &str, + port: &str, + dbname: &str, + user: &str, + user_pass: &str, +) -> Result<(), Box> { + // Verify the credentials that will be placed into settings.ini actually work. + let output = Command::new(psql_path) + .env("PGPASSWORD", user_pass) + .args([ + "-h", + host, + "-p", + port, + "-U", + user, + "-d", + dbname, + "-v", + "ON_ERROR_STOP=1", + "-t", + "-A", + "-c", + "SELECT 1;", + ]) + .output()?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "Failed to verify blockchain database login: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()) + } +} + +#[cfg(windows)] +fn validate_identifier(label: &str, value: &str) -> Result<(), Box> { + // Database identifiers are inserted into SQL statements, so keep them simple and safe. + if value.is_empty() || !value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(format!( + "Invalid {label}. Only letters, numbers, and underscores are allowed." + ) + .into()); + } + + Ok(()) +} diff --git a/src/bin/private_key_from_image.rs b/src/bin/private_key_from_image.rs new file mode 100644 index 0000000..cc3a8a6 --- /dev/null +++ b/src/bin/private_key_from_image.rs @@ -0,0 +1,127 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::decode_image_and_extract_text; +use blockchain::decrypts; +use blockchain::env; +use blockchain::from_str; +use blockchain::fs; +use blockchain::tilde; +use blockchain::Value; +use rustyline::completion::FilenameCompleter; +use rustyline::error::ReadlineError; +use rustyline::{history::DefaultHistory, CompletionType, Config, Editor}; +use rustyline_derive::Completer; +use rustyline_derive::Helper as RustyHelper; +use rustyline_derive::Highlighter as RustyHighlighter; +use rustyline_derive::Hinter as RustyHinter; +use rustyline_derive::Validator as RustyValidator; + +pub async fn decode_private_key(base64_image: String, wallet_key: String) -> String { + let private_key: String; + if let Some(encrypted_text) = decode_image_and_extract_text(&base64_image) { + // Decrypt the encrypted text to get the real private key + if let Some(decrypted_private_key) = decrypts(&encrypted_text, Some(&wallet_key)) { + private_key = decrypted_private_key; + } else { + eprintln!("Decryption of private key failed."); + panic!(); + } + } else { + eprintln!("Failed to decode the image and extract text."); + panic!(); + } + private_key +} + +fn extract_private_key(contents: &str) -> Result { + let trimmed = contents.trim(); + if trimmed.is_empty() { + return Err("Wallet/image file is empty".to_string()); + } + + if trimmed.starts_with('{') { + let value: Value = + from_str(trimmed).map_err(|e| format!("Failed to parse wallet JSON: {e}"))?; + let private_key = value + .get("private_key") + .or_else(|| value.get("privkey")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + "Wallet JSON does not contain a \"private_key\" or \"privkey\" field".to_string() + })?; + return Ok(private_key.trim().to_string()); + } + + Ok(trimmed.to_string()) +} + +#[derive(RustyHelper, Completer, RustyHinter, RustyHighlighter, RustyValidator)] +struct PathHelper { + #[rustyline(Completer)] + completer: FilenameCompleter, +} + +fn prompt_for_path(prompt: &str) -> Result { + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => Ok(line.trim().to_string()), + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read file path: {err}")), + } +} + +#[tokio::main] +async fn main() { + // Collect command-line arguments + let args: Vec = env::args().collect(); + + let base64_file_path = if args.len() > 1 { + args[1].clone() + } else { + // Prompt the user for file path and encryption key + match prompt_for_path( + "Please enter the path to the file containing the wallet/image data: ", + ) { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + } + }; + + let wallet_key = prompt_hidden_nonempty( + "Please enter your encryption key: ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Read the Base64 string from the specified file + let expanded_path = tilde(&base64_file_path).to_string(); + let file_contents = fs::read_to_string(&expanded_path) + .expect("Failed to read Base64 string file") + .to_string(); + + let base64_string = match extract_private_key(&file_contents) { + Ok(value) => value, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + // Decode the private key from Base64 string + let private_key = decode_private_key(base64_string, wallet_key).await; + + // Print the private key in text format + println!("{private_key}"); +} diff --git a/src/bin/recreate_wallet.rs b/src/bin/recreate_wallet.rs new file mode 100644 index 0000000..71ffeea --- /dev/null +++ b/src/bin/recreate_wallet.rs @@ -0,0 +1,235 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::create_img; +use blockchain::encrypts; +use blockchain::env; +use blockchain::fs; +use blockchain::read_to_string; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::tilde; +use blockchain::wallets::structures::{SavedWallet, Wallet}; +use rustyline::completion::FilenameCompleter; +use rustyline::error::ReadlineError; +use rustyline::{history::DefaultHistory, CompletionType, Config, Editor}; +use rustyline_derive::Completer; +use rustyline_derive::Helper as RustyHelper; +use rustyline_derive::Highlighter as RustyHighlighter; +use rustyline_derive::Hinter as RustyHinter; +use rustyline_derive::Validator as RustyValidator; +use std::path::PathBuf; + +#[derive(RustyHelper, Completer, RustyHinter, RustyHighlighter, RustyValidator)] +struct PathHelper { + #[rustyline(Completer)] + completer: FilenameCompleter, +} + +fn display_vanity_address(short_address: &str) -> Option { + let (payload, network_suffix) = short_address.rsplit_once('.')?; + let trimmed_payload = payload.trim_start(); + if trimmed_payload.is_empty() { + return None; + } + Some(format!("{trimmed_payload}.{network_suffix}")) +} + +async fn lookup_registered_vanity_address( + short_address: &str, + long_address: &str, + private_key: &str, +) -> Option { + let hashmap_key = generate_uid(); + let rpc_command = 40; + let connections = get_connections().await; + + for conn in connections { + let Ok(socket_address) = conn.parse() else { + continue; + }; + + let result = handshake::connect_and_handshake( + socket_address, + short_address.to_string(), + rpc_command, + handshake::HandshakeWallet::WalletParts { + long_address: long_address.to_string(), + private_key: private_key.to_string(), + }, + hashmap_key, + ) + .await; + + if let Ok(response) = result { + if response.is_empty() { + return None; + } + + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if trimmed.is_empty() || trimmed.starts_with("error:") { + return None; + } + + return display_vanity_address(trimmed); + } + } + + None +} + +fn prompt_for_path(prompt: &str) -> Result { + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => Ok(line.trim().to_string()), + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read file path: {err}")), + } +} + +fn prompt_for_filename(prompt: &str) -> Result { + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + Err("Filename cannot be empty".to_string()) + } else { + Ok(trimmed.to_string()) + } + } + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read filename: {err}")), + } +} + +#[tokio::main] +async fn main() { + let args: Vec = env::args().collect(); + let private_key_path: String; + let output_path: String; + + if args.len() > 1 && args.len() != 3 && args.len() != 4 { + println!("Usage: ./recreate_wallet "); + println!(" or: ./recreate_wallet "); + return; + } + + if args.len() == 4 { + output_path = PathBuf::from(&args[2]) + .join(&args[3]) + .to_string_lossy() + .to_string(); + private_key_path = args[1].clone(); + } else if args.len() == 3 { + private_key_path = args[1].clone(); + output_path = args[2].clone(); + } else { + private_key_path = match prompt_for_path( + "Please enter the path to the file containing your wallet private key: ", + ) { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let output_dir_path = match prompt_for_path( + "Please enter the directory path where the rebuilt wallet JSON should be written: ", + ) { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let output_filename = + match prompt_for_filename("Please enter the filename for the rebuilt wallet JSON: ") { + Ok(filename) => filename, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let output_dir = tilde(&output_dir_path).to_string(); + output_path = PathBuf::from(output_dir) + .join(output_filename) + .to_string_lossy() + .to_string(); + } + + let wallet_key = prompt_hidden_nonempty( + "Please enter your encryption key: ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let expanded_private_key_path = tilde(&private_key_path).to_string(); + let expanded_output_path = tilde(&output_path).to_string(); + + let private_key = read_to_string(&expanded_private_key_path) + .await + .expect("Failed to read private key file") + .trim() + .to_string(); + + // encrypt the private key for generated a secure image from it + let encrypted = encrypts(&private_key.clone(), Some(&wallet_key), None).unwrap(); + // don't set a watermark + let watermark = "empty"; + // create an image from the encrypted private key + let image_data = create_img( + &encrypted, "h", watermark, None, None, None, None, None, None, + ) + .expect("Failed to create image"); + + match Wallet::regenerate_public_key(&private_key) { + Ok(public_key_bytes) => { + let public_key = blockchain::encode(public_key_bytes.clone()); + let long_address = Wallet::generate_address(&public_key); + let long_address_bytes = Wallet::long_address_to_bytes(long_address.clone()); + let short_address_bytes = + Wallet::long_address_bytes_to_short_address_bytes(&long_address_bytes) + .expect("Failed to derive short address bytes"); + let short_address = Wallet::bytes_to_short_address(&short_address_bytes) + .expect("Failed to encode short address"); + let vanity_address = + lookup_registered_vanity_address(&short_address, &long_address, &private_key).await; + let saved_wallet = SavedWallet { + long_address, + short_address, + vanity_address, + public_key, + private_key: image_data, + }; + let output_str = serde_json::to_string_pretty(&saved_wallet).unwrap(); + fs::write(&expanded_output_path, output_str) + .expect("Failed to write rebuilt wallet file"); + println!("Wallet written to {expanded_output_path}"); + } + Err(e) => println!("Error: {e}"), + } +} diff --git a/src/bin/recreate_wallet_from_image.rs b/src/bin/recreate_wallet_from_image.rs new file mode 100644 index 0000000..35b0ab1 --- /dev/null +++ b/src/bin/recreate_wallet_from_image.rs @@ -0,0 +1,272 @@ +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::decode_image_and_extract_text; +use blockchain::decrypts; +use blockchain::env; +use blockchain::fs; +use blockchain::read_to_string; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::stdout; +use blockchain::tilde; +use blockchain::wallets::structures::{SavedWallet, Wallet}; +use blockchain::AsyncWriteExt; +use rustyline::completion::FilenameCompleter; +use rustyline::error::ReadlineError; +use rustyline::{history::DefaultHistory, CompletionType, Config, Editor}; +use rustyline_derive::Completer; +use rustyline_derive::Helper as RustyHelper; +use rustyline_derive::Highlighter as RustyHighlighter; +use rustyline_derive::Hinter as RustyHinter; +use rustyline_derive::Validator as RustyValidator; +use std::path::PathBuf; + +fn display_vanity_address(short_address: &str) -> Option { + let (payload, network_suffix) = short_address.rsplit_once('.')?; + let trimmed_payload = payload.trim_start(); + if trimmed_payload.is_empty() { + return None; + } + Some(format!("{trimmed_payload}.{network_suffix}")) +} + +async fn lookup_registered_vanity_address( + short_address: &str, + long_address: &str, + private_key: &str, +) -> Option { + let hashmap_key = generate_uid(); + let rpc_command = 40; + let connections = get_connections().await; + + for conn in connections { + let Ok(socket_address) = conn.parse() else { + continue; + }; + + let result = handshake::connect_and_handshake( + socket_address, + short_address.to_string(), + rpc_command, + handshake::HandshakeWallet::WalletParts { + long_address: long_address.to_string(), + private_key: private_key.to_string(), + }, + hashmap_key, + ) + .await; + + if let Ok(response) = result { + if response.is_empty() { + return None; + } + + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if trimmed.is_empty() || trimmed.starts_with("error:") { + return None; + } + + return display_vanity_address(trimmed); + } + } + + None +} + +pub fn load_wallet(private_key_image: String, wallet_key: String) -> String { + let private_key: String; + if let Some(encrypted_text) = decode_image_and_extract_text(&private_key_image) { + // Decrypt the encrypted text to get the real private key + if let Some(decrypted_private_key) = decrypts(&encrypted_text, Some(&wallet_key)) { + // Update the wallet's private key with the decrypted private key + private_key = decrypted_private_key; + } else { + eprintln!("Decryption of private key failed."); + panic!(); + } + } else { + eprintln!("Failed to decode the image and extract text."); + panic!(); + } + private_key +} + +#[derive(RustyHelper, Completer, RustyHinter, RustyHighlighter, RustyValidator)] +struct PathHelper { + #[rustyline(Completer)] + completer: FilenameCompleter, +} + +fn prompt_for_path(prompt: &str) -> Result { + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => Ok(line.trim().to_string()), + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read file path: {err}")), + } +} + +fn prompt_for_filename(prompt: &str) -> Result { + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + Err("Filename cannot be empty".to_string()) + } else { + Ok(trimmed.to_string()) + } + } + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read filename: {err}")), + } +} + +async fn load_private_key_image_input(path: &str) -> Result { + let expanded_path = tilde(path).to_string(); + + if expanded_path.to_ascii_lowercase().ends_with(".png") { + let image_bytes = + fs::read(&expanded_path).map_err(|e| format!("Failed to read PNG file: {e}"))?; + return Ok(STANDARD.encode(image_bytes)); + } + + let file_contents = read_to_string(&expanded_path) + .await + .map_err(|e| format!("Failed to read Base64 string file: {e}"))?; + + Ok(file_contents.trim().to_string()) +} + +#[tokio::main] +async fn main() { + let mut stdout = stdout(); // Get Tokio's stdout + + // Collect command-line arguments + let args: Vec = env::args().collect(); + + let base64_file_path: String; + let output_path: String; + + if args.len() > 1 && args.len() != 3 && args.len() != 4 { + println!( + "Usage: ./recreate_wallet_from_image " + ); + println!(" or: ./recreate_wallet_from_image "); + return; + } + + if args.len() == 4 { + base64_file_path = args[1].clone(); + output_path = PathBuf::from(&args[2]) + .join(&args[3]) + .to_string_lossy() + .to_string(); + } else if args.len() == 3 { + base64_file_path = args[1].clone(); + output_path = args[2].clone(); + } else { + stdout.flush().await.expect("Failed to flush stdout"); + base64_file_path = + match prompt_for_path("Please enter the path to the Base64 or PNG image file: ") { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let output_dir_path = match prompt_for_path( + "Please enter the directory path where the rebuilt wallet JSON should be written: ", + ) { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let output_filename = + match prompt_for_filename("Please enter the filename for the rebuilt wallet JSON: ") { + Ok(filename) => filename, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let output_dir = tilde(&output_dir_path).to_string(); + output_path = PathBuf::from(output_dir) + .join(output_filename) + .to_string_lossy() + .to_string(); + } + + let wallet_key = prompt_hidden_nonempty( + "Please enter your encryption key: ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let base64_string = match load_private_key_image_input(&base64_file_path).await { + Ok(contents) => contents, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let expanded_output_path = tilde(&output_path).to_string(); + + // Generate private key from Base64 string + let private_key = load_wallet(base64_string.clone(), wallet_key); + + match Wallet::regenerate_public_key(&private_key) { + Ok(public_key_bytes) => { + let public_key = blockchain::encode(public_key_bytes.clone()); + let long_address = Wallet::generate_address(&public_key); + let long_address_bytes = Wallet::long_address_to_bytes(long_address.clone()); + let short_address_bytes = + Wallet::long_address_bytes_to_short_address_bytes(&long_address_bytes) + .expect("Failed to derive short address bytes"); + let short_address = Wallet::bytes_to_short_address(&short_address_bytes) + .expect("Failed to encode short address"); + let vanity_address = + lookup_registered_vanity_address(&short_address, &long_address, &private_key).await; + let saved_wallet = SavedWallet { + long_address, + short_address, + vanity_address, + public_key, + private_key: base64_string, + }; + let output_str = serde_json::to_string_pretty(&saved_wallet).unwrap(); + fs::write(&expanded_output_path, output_str) + .expect("Failed to write rebuilt wallet file"); + println!("Wallet written to {expanded_output_path}"); + } + Err(e) => println!("Error: {e}"), + } +} diff --git a/src/bin/register_wallet.rs b/src/bin/register_wallet.rs new file mode 100644 index 0000000..ff0dc03 --- /dev/null +++ b/src/bin/register_wallet.rs @@ -0,0 +1,104 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::common::skein::skein_256_hash_bytes; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::rpc::command_maps::RPC_REGISTER_WALLET; +use blockchain::standalone_tools::connections::handshake; +use blockchain::wallets::structures::Wallet; + +#[tokio::main] +async fn main() { + // Wallet registration submits a signed short-address -> long-address mapping to a peer. + let hashmap_key = generate_uid(); + + // This command works from the saved wallet only and takes no extra CLI arguments. + let args: Vec = env::args().collect(); + if args.len() != 1 { + println!("Usage: ./register_wallet"); + return; + } + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Load the wallet so both address forms and the signing key come from the same saved file. + let wallet = match Wallet::try_obtain_wallet(decryption_key.clone(), None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + + // The peer receives both addresses, but the signature proves they belong together. + let short_address = wallet.saved.short_address.clone(); + let long_address = wallet.saved.long_address.clone(); + let short_address_bytes = match Wallet::short_address_to_bytes(&short_address) { + Some(bytes) => bytes, + None => { + eprintln!("Failed to derive short address bytes from the wallet."); + return; + } + }; + let long_address_bytes = Wallet::long_address_to_bytes(long_address.clone()); + + // The signed payload mirrors the binary request body: command byte, short address, long address. + let mut signed_payload = + Vec::with_capacity(1 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + Wallet::ADDRESS_BYTES_LENGTH); + signed_payload.push(RPC_REGISTER_WALLET); + signed_payload.extend_from_slice(&short_address_bytes); + signed_payload.extend_from_slice(&long_address_bytes); + + let payload_hash = skein_256_hash_bytes(&signed_payload); + let signature = Wallet::sign_transaction(&payload_hash, &wallet.saved.private_key).await; + + // sending_request encodes command 38 from this pipe-delimited payload. + let json = format!("{short_address}|{long_address}|{signature}"); + let rpc_command = 38; + let connections = get_connections().await; + + // Try each configured peer until the registration is accepted or every peer fails. + let mut connected = false; + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + json.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(decryption_key.clone()), + hashmap_key, + ) + .await; + + match result { + Ok(response) => { + // The peer returns "1" on successful wallet registry insert. + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + if trimmed == "1" { + println!("Wallet registered: {short_address}"); + } else { + println!("Wallet registration failed."); + } + connected = true; + } + } + Err(_) => { + connected = false; + } + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/save_private_key_image.rs b/src/bin/save_private_key_image.rs new file mode 100644 index 0000000..0fdf2eb --- /dev/null +++ b/src/bin/save_private_key_image.rs @@ -0,0 +1,206 @@ +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use blockchain::env; +use blockchain::from_str; +use blockchain::fs; +use blockchain::read_to_string; +use blockchain::tilde; +use blockchain::Value; +use rustyline::completion::FilenameCompleter; +use rustyline::error::ReadlineError; +use rustyline::{history::DefaultHistory, CompletionType, Config, Editor}; +use rustyline_derive::Completer; +use rustyline_derive::Helper as RustyHelper; +use rustyline_derive::Highlighter as RustyHighlighter; +use rustyline_derive::Hinter as RustyHinter; +use rustyline_derive::Validator as RustyValidator; +use std::path::PathBuf; + +#[derive(RustyHelper, Completer, RustyHinter, RustyHighlighter, RustyValidator)] +struct PathHelper { + #[rustyline(Completer)] + completer: FilenameCompleter, +} + +fn prompt_for_path(prompt: &str) -> Result { + // Rustyline gives interactive path prompts filesystem completion. + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => Ok(line.trim().to_string()), + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read file path: {err}")), + } +} + +fn prompt_for_filename(prompt: &str) -> Result { + // The output filename is kept separate when the user chooses a directory interactively. + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + Err("Filename cannot be empty".to_string()) + } else { + Ok(trimmed.to_string()) + } + } + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read filename: {err}")), + } +} + +fn extract_private_key_image(contents: &str) -> Result { + // Input may be a saved wallet JSON object or a raw base64 image string. + let trimmed = contents.trim(); + if trimmed.is_empty() { + return Err("Wallet/image file is empty".to_string()); + } + + if trimmed.starts_with('{') { + // Accept both field names used by wallet/private-key image exports. + let value: Value = + from_str(trimmed).map_err(|e| format!("Failed to parse wallet JSON: {e}"))?; + let private_key = value + .get("private_key") + .or_else(|| value.get("privkey")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + "Wallet JSON does not contain a \"private_key\" or \"privkey\" field".to_string() + })?; + return Ok(private_key.trim().to_string()); + } + + Ok(trimmed.to_string()) +} + +fn decode_image_bytes(encoded: &str) -> Result, String> { + // Support both plain base64 and data:image/...;base64,... payloads. + let trimmed = encoded.trim(); + let base64_payload = if let Some((_, payload)) = trimmed.split_once(',') { + if trimmed.starts_with("data:") { + payload + } else { + trimmed + } + } else { + trimmed + }; + + STANDARD + .decode(base64_payload) + .map_err(|e| format!("Failed to decode image base64: {e}")) +} + +#[tokio::main] +async fn main() { + // Accept direct args or prompt for input/output paths interactively. + let args: Vec = env::args().collect(); + let wallet_file_path: String; + let output_png_path: String; + + if args.len() > 1 && args.len() != 3 && args.len() != 4 { + eprintln!("Usage: ./save_private_key_image "); + eprintln!(" or: ./save_private_key_image "); + return; + } + + if args.len() == 4 { + wallet_file_path = args[1].clone(); + output_png_path = PathBuf::from(&args[2]) + .join(&args[3]) + .to_string_lossy() + .to_string(); + } else if args.len() == 3 { + wallet_file_path = args[1].clone(); + output_png_path = args[2].clone(); + } else { + wallet_file_path = match prompt_for_path("Please enter the path to the wallet/image file: ") + { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let output_png_dir_path = match prompt_for_path( + "Please enter the directory path where the PNG should be written: ", + ) { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let output_filename = match prompt_for_filename("Please enter the filename for the PNG: ") { + Ok(filename) => filename, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + let output_dir = tilde(&output_png_dir_path).to_string(); + output_png_path = PathBuf::from(output_dir) + .join(output_filename) + .to_string_lossy() + .to_string(); + } + + // Expand ~ for both source and destination before reading/writing. + let expanded_wallet_path = tilde(&wallet_file_path).to_string(); + let expanded_output_path = tilde(&output_png_path).to_string(); + + let file_contents = match read_to_string(&expanded_wallet_path).await { + Ok(contents) => contents, + Err(err) => { + eprintln!("Failed to read wallet/image file: {err}"); + return; + } + }; + + let private_key_image = match extract_private_key_image(&file_contents) { + Ok(value) => value, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + // Decode and write the recovered PNG bytes to the requested output path. + let image_bytes = match decode_image_bytes(&private_key_image) { + Ok(bytes) => bytes, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + if let Err(err) = fs::write(&expanded_output_path, image_bytes) { + eprintln!("Failed to write PNG file: {err}"); + return; + } + + println!("Private key image written to {expanded_output_path}"); +} diff --git a/src/bin/server_owner_block_ip.rs b/src/bin/server_owner_block_ip.rs new file mode 100644 index 0000000..85d04e6 --- /dev/null +++ b/src/bin/server_owner_block_ip.rs @@ -0,0 +1,72 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::wallets::structures::Wallet; + +#[tokio::main] +async fn main() { + // Command 26 is currently handled by the server as the block-IP command. + let rpc_command = 26; + + // The server owner supplies the IP to block; the wallet key signs the request. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./server_owner_block_ip "); + return; + } + let ip = args[1].trim().to_string(); + + let wallet_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Server-side verification expects a signature over the exact IP string. + let wallet = match Wallet::try_obtain_wallet(wallet_key.clone(), None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let signature = Wallet::sign_transaction(&ip, &wallet.saved.private_key).await; + let payload = format!("{ip}|{signature}"); + + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + payload.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(wallet_key.clone()), + generate_uid(), + ) + .await; + + match result { + Ok(response) => { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/server_owner_unblock_ip.rs b/src/bin/server_owner_unblock_ip.rs new file mode 100644 index 0000000..152adb6 --- /dev/null +++ b/src/bin/server_owner_unblock_ip.rs @@ -0,0 +1,72 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::network_startup::get_connections; +use blockchain::env; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::standalone_tools::connections::handshake; +use blockchain::wallets::structures::Wallet; + +#[tokio::main] +async fn main() { + // Command 27 is currently handled by the server as the unblock-IP command. + let rpc_command = 27; + + // The server owner supplies the IP to unblock; the wallet key signs the request. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./server_owner_unblock_ip "); + return; + } + let ip = args[1].trim().to_string(); + + let wallet_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Server-side verification expects a signature over the exact IP string. + let wallet = match Wallet::try_obtain_wallet(wallet_key.clone(), None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let signature = Wallet::sign_transaction(&ip, &wallet.saved.private_key).await; + let payload = format!("{ip}|{signature}"); + + let connections = get_connections().await; + let mut connected = false; + + for conn in connections { + if connected { + break; + } + + let socket_address = conn.parse().expect("Failed to parse the socket address"); + let result = handshake::connect_and_handshake( + socket_address, + payload.clone(), + rpc_command, + handshake::HandshakeWallet::WalletKey(wallet_key.clone()), + generate_uid(), + ) + .await; + + match result { + Ok(response) => { + let response_text = String::from_utf8_lossy(&response); + let trimmed = response_text.trim(); + if !trimmed.is_empty() { + println!("{trimmed}"); + connected = true; + } + } + Err(_) => connected = false, + } + } + + if !connected { + eprintln!("failed to connect"); + } +} diff --git a/src/bin/sign_message.rs b/src/bin/sign_message.rs new file mode 100644 index 0000000..6cb677f --- /dev/null +++ b/src/bin/sign_message.rs @@ -0,0 +1,45 @@ +use blockchain::common::cli_prompts::prompt_hidden_nonempty; +use blockchain::common::skein::skein_256_hash_data; +use blockchain::env; +use blockchain::wallets::structures::Wallet; + +#[tokio::main] +async fn main() { + // The message to sign is supplied directly as a command-line argument. + let args: Vec = env::args().collect(); + + if args.len() != 2 { + println!("Usage: ./sign_message \"\""); + return; + } + + let message = match args[1].parse::() { + Ok(num) => num, + Err(_) => { + println!("Please enter a message"); + return; + } + }; + + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + // Load the wallet whose private key will create the detached signature. + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + + // Message signing hashes the UTF-8 message text before applying the wallet signature. + let message_hash = skein_256_hash_data(message.as_str()); + + let signature = Wallet::sign_transaction(&message_hash, &wallet.saved.private_key).await; + + println!("message: {}, signature: {}", message.as_str(), signature); +} diff --git a/src/bin/skein_hasher.rs b/src/bin/skein_hasher.rs new file mode 100644 index 0000000..effa2f3 --- /dev/null +++ b/src/bin/skein_hasher.rs @@ -0,0 +1,139 @@ +use blockchain::common::skein::{ + skein_256_hash_data, skein_256_hash_bytes, skein_128_hash_bytes, skein_128_hash_data, +}; +use blockchain::env; +use blockchain::File; +use blockchain::SeekFrom; +use blockchain::{AsyncReadExt, AsyncSeekExt}; + +#[tokio::main] +async fn main() { + // Collect command-line arguments + let args: Vec = env::args().collect(); + + // Get the mode from the arguments + let mode = if args.len() > 1 { &args[1] } else { "" }; + + match mode { + "text" => { + // Ensure the correct number of arguments for "text" mode + if args.len() != 4 { + eprintln!("Usage: ./skein_hasher text large|small "); + return; + } + + // Get the value and hash it + let size = &args[2]; + let value = &args[3]; + if size == "small" { + let hash = skein_128_hash_data(value); + println!("Hash of text '{value}': {hash}"); + } + if size == "large" { + let hash = skein_256_hash_data(value); + println!("Hash of text '{value}': {hash}"); + } + } + "file" => { + // Ensure the correct number of arguments for "file" mode + if args.len() != 4 { + eprintln!("Usage: ./skein_hasher file large|small "); + return; + } + + // Get the file path and hash its contents + let file_path = &args[3]; + match File::open(file_path).await { + Ok(mut file) => { + let mut data = Vec::new(); + if let Err(err) = file.read_to_end(&mut data).await { + eprintln!("Error reading file '{file_path}': {err}"); + return; + } + let hash = if &args[2] == "small" { + skein_128_hash_bytes(&data) + } else { + skein_256_hash_bytes(&data) + }; + println!("Hash of file {hash}"); + } + Err(err) => { + eprintln!("Error opening file '{file_path}': {err}"); + return; + } + } + } + "bytes" => { + // Ensure the correct number of arguments for "bytes" mode + if args.len() != 6 { + eprintln!( + "Usage: ./skein_hasher bytes large|small " + ); + return; + } + + // Parse the start and stop byte positions + let start_byte: usize = match args[3].parse() { + Ok(num) => num, + Err(_) => { + eprintln!("Invalid start byte: '{}'", args[3]); + return; + } + }; + + let stop_byte: usize = match args[4].parse() { + Ok(num) => num, + Err(_) => { + eprintln!("Invalid stop byte: '{}'", args[4]); + return; + } + }; + + if stop_byte <= start_byte { + eprintln!("Stop byte must be greater than start byte."); + return; + } + + let number_of_bytes = stop_byte - start_byte; + let file_path = &args[5]; + + // Open the file and read the specified range of bytes + match File::open(file_path).await { + Ok(mut file) => { + // Seek to the start byte position + if let Err(err) = file.seek(SeekFrom::Start(start_byte as u64)).await { + eprintln!("Error seeking to start byte {start_byte}: {err}"); + return; + } + + let mut buffer = vec![0; number_of_bytes]; + match file.read_exact(&mut buffer).await { + Ok(_) => { + let hash = if &args[2] == "large" { + skein_256_hash_bytes(&buffer) + } else { + skein_128_hash_bytes(&buffer) + }; + + println!( + "Hash of bytes {start_byte} through {stop_byte} of file '{file_path}': {hash}" + ); + } + Err(err) => { + eprintln!("Error reading file '{file_path}': {err}"); + return; + } + } + } + Err(err) => { + eprintln!("Error opening file '{file_path}': {err}"); + return; + } + } + } + _ => { + eprintln!("Invalid mode. Use 'text', 'file', or 'bytes'."); + return; + } + } +} diff --git a/src/bin/unpack_block_header.rs b/src/bin/unpack_block_header.rs new file mode 100644 index 0000000..d02da6b --- /dev/null +++ b/src/bin/unpack_block_header.rs @@ -0,0 +1,35 @@ +use blockchain::common::binary_conversions::hex_to_u64; +use blockchain::env; +use blockchain::io; +use blockchain::records::unpack_block::unpack_header::load_block_header; +use blockchain::to_string_pretty; + +#[tokio::main] +async fn main() -> io::Result<()> { + // Load a local block header by height and print both decoded fields and hash-derived difficulty. + let args: Vec = env::args().collect(); + if args.len() > 4 || args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + std::process::exit(1); + } + let block_number: u32 = match args[1].parse() { + Ok(num) => num, + Err(_) => { + eprintln!("Error: The provided block number must be a valid unsigned 32-bit integer."); + std::process::exit(1); + } + }; + + // Header loading uses the active network block path internally. + let header = load_block_header(block_number).await.unwrap(); + let hash = header.hash().await; + let json_pretty = to_string_pretty(&header)?; + // The displayed difficulty is the numeric value derived from the header hash. + let difficulty = hex_to_u64(&hash).await.unwrap(); + + println!("difficulty: {difficulty:?}"); + println!("hash_value: {hash}"); + println!("{json_pretty}"); + + Ok(()) +} diff --git a/src/bin/unpack_torrent.rs b/src/bin/unpack_torrent.rs new file mode 100644 index 0000000..e624ed6 --- /dev/null +++ b/src/bin/unpack_torrent.rs @@ -0,0 +1,56 @@ +use blockchain::common::network_paths_and_settings::block_extension_and_paths; +use blockchain::env; +use blockchain::exit; +use blockchain::io; +use blockchain::to_string_pretty; +use blockchain::torrent::structs::Torrent; +use blockchain::AsyncReadExt; +use blockchain::File; +use blockchain::Path; + +#[tokio::main] +async fn main() -> io::Result<()> { + // This utility loads one local torrent file by block number and prints the decoded struct. + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + exit(1); + } + + let block_number = &args[1]; + let ( + _network_name, + _padded_base_coin, + _block_ext, + torrent_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + // Torrent files are scoped by the active network path returned from settings. + let torrent_filename = format!("{torrent_path}/{block_number}.torrent"); + + if !Path::new(&torrent_filename).exists() { + eprintln!("Torrent file not found: {torrent_filename}"); + return Ok(()); + } + + let mut torrent_file = File::open(&torrent_filename).await?; + let mut torrent_contents = Vec::new(); + torrent_file.read_to_end(&mut torrent_contents).await?; + + // The torrent parser validates the binary layout before the result is printed as JSON. + match Torrent::from_bytes(&torrent_contents).await { + Ok(torrent) => { + let json_pretty = to_string_pretty(&torrent)?; + println!("{json_pretty}"); + } + Err(e) => { + eprintln!("Failed to parse torrent: {e}"); + } + } + + Ok(()) +} diff --git a/src/bin/validate_torrent_and_block_headers.rs b/src/bin/validate_torrent_and_block_headers.rs new file mode 100644 index 0000000..d9d092f --- /dev/null +++ b/src/bin/validate_torrent_and_block_headers.rs @@ -0,0 +1,189 @@ +use blockchain::common::binary_conversions::hex_to_u64; +use blockchain::common::network_paths_and_settings::block_extension_and_paths; +use blockchain::common::skein::{skein_256_hash_data, skein_128_hash_bytes}; +use blockchain::encode; +use blockchain::env; +use blockchain::records::unpack_block::unpack_header::load_block_header; +use blockchain::records::wallet_registry::resolve_pubkey_from_short_address; +use blockchain::torrent::structs::Torrent; +use blockchain::wallets::structures::Wallet; +use blockchain::{AsyncReadExt, File}; +use colored::*; +use std::process; + +#[tokio::main] +async fn main() { + // Validate that a local block file, block header, and torrent metadata agree. + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: {} ", args[0]); + process::exit(1); + } + + let block_number: u32 = match args[1].parse() { + Ok(n) => n, + Err(_) => { + eprintln!("Block number must be an integer."); + process::exit(1); + } + }; + + let ( + _network_name, + _padded_base_coin, + block_ext, + torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let block_filename = format!("{block_path}/{block_number}.{block_ext}"); + let torrent_filename = format!("{torrent_path}/{block_number}.torrent"); + + // Load and decode the torrent metadata first because later checks compare against it. + let mut torrent_bytes = Vec::new(); + let mut torrent_file = File::open(&torrent_filename).await.unwrap_or_else(|_| { + eprintln!("Error: cannot open torrent file '{torrent_filename}'"); + process::exit(1); + }); + torrent_file.read_to_end(&mut torrent_bytes).await.unwrap(); + let torrent = Torrent::from_bytes(&torrent_bytes).await.unwrap(); + + // Load the local header and resolve the miner public key for signature/VRF checks. + let header = load_block_header(block_number).await.unwrap(); + let block_hash = header.hash().await; + let block_difficulty = hex_to_u64(&block_hash).await.unwrap(); + let miner_pubkey = resolve_pubkey_from_short_address( + &blockchain::startup::initialize_startup::open_chain_state().await, + &header.unmined_block.miner, + ) + .unwrap_or(None) + .unwrap_or_default(); + let miner_pubkey_hex = encode(&miner_pubkey); + + // Load the raw block bytes for file-size, info-hash, and piece-hash checks. + let mut block_data = Vec::new(); + let mut block_file = File::open(&block_filename).await.unwrap_or_else(|_| { + eprintln!("Error: cannot open block file '{block_filename}'"); + process::exit(1); + }); + block_file.read_to_end(&mut block_data).await.unwrap(); + + let info_hash_computed = skein_128_hash_bytes(&block_data); + + // Rebuild the unmined-block hash used by the miner proof signature. + let unmined_json = serde_json::to_string(&header.unmined_block).unwrap(); + let unmined_hash = skein_256_hash_data(&unmined_json); + let signature_ok = + Wallet::verify_transaction_with_public_key(&unmined_hash, &header.proof, &miner_pubkey_hex) + .await; + + let passed = "[PASSED]".green(); + let failed = "[FAILED]".red(); + + // Compare every header field that is duplicated inside the torrent metadata. + if header.unmined_block.timestamp == torrent.info.timestamp { + println!("timestamp match: {:>90}", format!("{passed}")); + } else { + println!("timestamp match: {:>90}", format!("{failed}")); + } + + if header.unmined_block.nonce == torrent.info.nonce { + println!("nonce match: {:>94}", format!("{passed}")); + } else { + println!("nonce match: {:>94}", format!("{failed}")); + } + + if header.unmined_block.miner == torrent.mined_by { + println!("wallet address match: {:>85}", format!("{passed}")); + } else { + println!("wallet address match: {:>85}", format!("{failed}")); + } + + if block_difficulty < torrent.info.this_block_difficulty { + println!("block difficulty check: {:>83}", format!("{passed}")); + } else { + println!("block difficulty check: {:>83}", format!("{failed}")); + } + + if header.vrf == torrent.info.vrf { + println!("VRF match: {:>96}", format!("{passed}")); + } else { + println!("VRF match: {:>96}", format!("{failed}")); + } + + if block_data.len() == torrent.info.length as usize { + println!("file size check: {:>90}", format!("{passed}")); + } else { + println!("file size check: {:>90}", format!("{failed}")); + } + + if block_hash == torrent.info.block_hash { + println!("block header hash check: {:>82}", format!("{passed}")); + } else { + println!("block header hash check: {:>82}", format!("{failed}")); + } + + let vrf_ok = Wallet::vrf_verify_with_public_key( + header.vrf, + &unmined_hash, + &miner_pubkey_hex, + &header.proof, + ) + .await; + if vrf_ok { + println!("VRF validation check: {:>85}", format!("{passed}")); + } else { + println!("VRF validation check: {:>85}", format!("{failed}")); + } + + if signature_ok { + println!("VRF Proof check: {:>90}", format!("{passed}")); + } else { + println!("VRF Proof check: {:>90}", format!("{failed}")); + } + + let mut all_pieces_passed = true; + // Piece hashes prove the torrent metadata still matches every block-file chunk. + for piece in &torrent.info.pieces { + for (index, expected_hash) in piece.iter() { + let idx = *index as usize; + let start = (idx - 1) * torrent.info.piece_length as usize; + let end = std::cmp::min(start + torrent.info.piece_length as usize, block_data.len()); + let slice = &block_data[start..end]; + let hash = skein_128_hash_bytes(slice); + + if hash == *expected_hash { + println!("piece {} hash check: {:>87}", idx, format!("{passed}")); + } else { + all_pieces_passed = false; + println!("piece {} hash check: {:>87}", idx, format!("{failed}")); + } + } + } + + if info_hash_computed == torrent.info.info_hash { + println!("block hash check: {:>89}", format!("{passed}")); + } else { + println!("block hash check: {:>89}", format!("{failed}")); + } + + // The final pass/fail summary requires every individual validation to pass. + if header.unmined_block.nonce == torrent.info.nonce + && header.unmined_block.miner == torrent.mined_by + && header.unmined_block.timestamp == torrent.info.timestamp + && block_difficulty < torrent.info.this_block_difficulty + && header.vrf == torrent.info.vrf + && block_data.len() == torrent.info.length as usize + && all_pieces_passed + && block_hash == torrent.info.block_hash + && signature_ok + && vrf_ok + { + println!("\nBlock {block_number} fully validated."); + } else { + println!("\nBlock {block_number} FAILED validation."); + } +} diff --git a/src/bin/verify_address.rs b/src/bin/verify_address.rs new file mode 100644 index 0000000..5e04145 --- /dev/null +++ b/src/bin/verify_address.rs @@ -0,0 +1,110 @@ +use blockchain::env; +use blockchain::from_str; +use blockchain::read_to_string; +use blockchain::tilde; +use blockchain::wallets::structures::Wallet; +use blockchain::Value; +use rustyline::completion::FilenameCompleter; +use rustyline::error::ReadlineError; +use rustyline::{history::DefaultHistory, CompletionType, Config, Editor}; +use rustyline_derive::Completer; +use rustyline_derive::Helper as RustyHelper; +use rustyline_derive::Highlighter as RustyHighlighter; +use rustyline_derive::Hinter as RustyHinter; +use rustyline_derive::Validator as RustyValidator; + +async fn read_address_file(path: &str) -> Result { + // Allow ~ in paths and then extract either a raw address or an address from wallet JSON. + let expanded_path = tilde(path).to_string(); + read_to_string(&expanded_path) + .await + .map_err(|e| format!("Failed to read {expanded_path}: {e}")) + .and_then(|contents| extract_address(&contents)) +} + +fn extract_address(contents: &str) -> Result { + // Address files may be a plain address string or a saved wallet JSON object. + let trimmed = contents.trim(); + if trimmed.is_empty() { + return Err("Address file is empty".to_string()); + } + + if trimmed.starts_with('{') { + // Prefer the short address when present, but accept long_address for wallet validation too. + let value: Value = + from_str(trimmed).map_err(|e| format!("Failed to parse wallet JSON: {e}"))?; + let address = value + .get("short_address") + .and_then(|v| v.as_str()) + .or_else(|| value.get("long_address").and_then(|v| v.as_str())) + .ok_or_else(|| "Wallet JSON does not contain a usable address field".to_string())?; + return Ok(address.trim().to_string()); + } + + Ok(trimmed.to_string()) +} + +#[derive(RustyHelper, Completer, RustyHinter, RustyHighlighter, RustyValidator)] +struct PathHelper { + #[rustyline(Completer)] + completer: FilenameCompleter, +} + +fn prompt_for_path(prompt: &str) -> Result { + // Rustyline gives the interactive prompt filesystem completion. + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => Ok(line.trim().to_string()), + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read address file path: {err}")), + } +} + +#[tokio::main] +async fn main() { + // Accept the address file path as an arg or prompt interactively. + let args: Vec = env::args().collect(); + + if args.len() > 1 && args.len() != 2 { + println!("Usage: ./verify_address "); + return; + } + + let address_path = if args.len() == 2 { + args[1].clone() + } else { + match prompt_for_path( + "Please enter the path to the file containing the wallet address: ", + ) { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + } + }; + + let address = match read_address_file(&address_path).await { + Ok(address) => address, + Err(err) => { + println!("{err}"); + return; + } + }; + + // Verify either a long wallet address or a current-network short address. + let message_hash = Wallet::wallet_validation(address.trim()).await + || Wallet::short_address_validation(address.trim()); + + println!("{message_hash}"); +} diff --git a/src/bin/verify_message.rs b/src/bin/verify_message.rs new file mode 100644 index 0000000..460c12d --- /dev/null +++ b/src/bin/verify_message.rs @@ -0,0 +1,150 @@ +use blockchain::common::cli_prompts::prompt_visible; +use blockchain::common::skein::skein_256_hash_data; +use blockchain::env; +use blockchain::from_str; +use blockchain::read_to_string; +use blockchain::tilde; +use blockchain::wallets::structures::Wallet; +use blockchain::Value; +use rustyline::completion::FilenameCompleter; +use rustyline::error::ReadlineError; +use rustyline::{history::DefaultHistory, CompletionType, Config, Editor}; +use rustyline_derive::Completer; +use rustyline_derive::Helper as RustyHelper; +use rustyline_derive::Highlighter as RustyHighlighter; +use rustyline_derive::Hinter as RustyHinter; +use rustyline_derive::Validator as RustyValidator; + +async fn read_input_file(path: &str) -> Result { + // Signature input files are plain text and are trimmed before verification. + let expanded_path = tilde(path).to_string(); + read_to_string(&expanded_path) + .await + .map(|s| s.trim().to_string()) + .map_err(|e| format!("Failed to read {expanded_path}: {e}")) +} + +async fn read_address_file(path: &str) -> Result { + // Allow ~ in paths and then extract either a raw address or an address from wallet JSON. + let expanded_path = tilde(path).to_string(); + read_to_string(&expanded_path) + .await + .map_err(|e| format!("Failed to read {expanded_path}: {e}")) + .and_then(|contents| extract_address(&contents)) +} + +fn extract_address(contents: &str) -> Result { + // Address files may be a plain address string or a saved wallet JSON object. + let trimmed = contents.trim(); + if trimmed.is_empty() { + return Err("Address file is empty".to_string()); + } + + if trimmed.starts_with('{') { + // Signature verification prefers the long address but can also verify with a short address. + let value: Value = + from_str(trimmed).map_err(|e| format!("Failed to parse wallet JSON: {e}"))?; + let address = value + .get("long_address") + .and_then(|v| v.as_str()) + .or_else(|| value.get("short_address").and_then(|v| v.as_str())) + .ok_or_else(|| "Wallet JSON does not contain a usable address field".to_string())?; + return Ok(address.trim().to_string()); + } + + Ok(trimmed.to_string()) +} + +#[derive(RustyHelper, Completer, RustyHinter, RustyHighlighter, RustyValidator)] +struct PathHelper { + #[rustyline(Completer)] + completer: FilenameCompleter, +} + +fn prompt_for_path(prompt: &str) -> Result { + // Rustyline gives the interactive prompt filesystem completion. + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut editor = + Editor::::with_config(config).map_err(|e| e.to_string())?; + editor.set_helper(Some(PathHelper { + completer: FilenameCompleter::new(), + })); + + match editor.readline(prompt) { + Ok(line) => Ok(line.trim().to_string()), + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + Err("Input cancelled".to_string()) + } + Err(err) => Err(format!("Failed to read file path: {err}")), + } +} + +#[tokio::main] +async fn main() { + // Accept all verification inputs as args or prompt interactively. + let args: Vec = env::args().collect(); + let address_path: String; + let signature_path: String; + + if args.len() > 1 && args.len() != 4 { + println!("Usage: ./verify_message \"\" "); + return; + } + + let message = if args.len() == 4 { + args[1].clone() + } else { + prompt_visible("Please enter the message to verify: ").await + }; + + if args.len() == 4 { + address_path = args[2].clone(); + signature_path = args[3].clone(); + } else { + address_path = match prompt_for_path( + "Please enter the path to the file containing the wallet address: ", + ) { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + signature_path = + match prompt_for_path("Please enter the path to the file containing the signature: ") { + Ok(path) => path, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + } + + let address = match read_address_file(&address_path).await { + Ok(address) => address, + Err(err) => { + println!("{err}"); + return; + } + }; + let signature = match read_input_file(&signature_path).await { + Ok(signature) => signature, + Err(err) => { + println!("{err}"); + return; + } + }; + + // Hash the message exactly as sign_message does before verifying the wallet signature. + let message_hash = skein_256_hash_data(message.as_str()); + let signature = Wallet::verify_transaction(&message_hash, &signature, address.trim()).await; + + if signature { + println!("valid signature"); + } else { + println!("invalid signature"); + } +} diff --git a/src/bin/verify_sign_loan_tx.rs b/src/bin/verify_sign_loan_tx.rs new file mode 100644 index 0000000..1409a84 --- /dev/null +++ b/src/bin/verify_sign_loan_tx.rs @@ -0,0 +1,282 @@ +use blockchain::blocks::loans::UnsignedLoanContractTransaction; +use blockchain::common::cli_prompts::{ask_yes_no_question, prompt_hidden_nonempty}; +use blockchain::env; +use blockchain::from_str; +use blockchain::fs; +use blockchain::json; +use blockchain::read_to_string; +use blockchain::records::wallet_registry::resolve_local_input_short_address; +use blockchain::to_string_pretty; +use blockchain::wallets::structures::Wallet; +use blockchain::Value; +use blockchain::{Local, TimeZone}; + +fn display_amount(value: u64) -> f64 { + // Transaction JSON stores atomic units; prompts show whole-coin decimals. + value as f64 / 100_000_000.0 +} + +fn display_payment_period(value: &str) -> &'static str { + // Loan transactions store the payment period as a compact one-letter code. + match value.trim().to_lowercase().as_str() { + "d" => "daily", + "w" => "weekly", + "m" => "monthly", + _ => "unknown", + } +} + +fn display_start_date(timestamp: u32) -> String { + // Show the stored timestamp as a local calendar date for borrower review. + match Local.timestamp_opt(timestamp as i64, 0).single() { + Some(datetime) => datetime.format("%Y-%m-%d").to_string(), + None => "invalid date".to_string(), + } +} + +fn normalize_short_address_input(address: &str) -> Result { + // Accept local vanity/short input and resolve it into the real short address. + resolve_local_input_short_address(address.trim()) +} + +#[tokio::main] +async fn main() { + // Borrowers use this tool to review a lender-signed loan and add signature2. + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./validate_sign_loan_tx "); + return; + } + + let filename = &args[1]; + + let contents = match read_to_string(filename).await { + Ok(contents) => contents, + Err(_) => { + println!("Error reading file: {filename}"); + return; + } + }; + + let json: Result = from_str(&contents); + let json = match json { + Ok(json) => json, + Err(_) => { + println!("Error parsing JSON in file: {filename}"); + return; + } + }; + + // Extract every field that participates in the hash so the borrower signs the same bytes. + let txtype = 7; + let timestamp = json["timestamp"].as_u64().unwrap_or_default() as u32; + let loan_coin = json["loan_coin"].as_str().unwrap_or_default().to_string(); + let loan_amount = json["loan_amount"].as_u64().unwrap_or_default(); + let lender = match normalize_short_address_input(json["lender"].as_str().unwrap_or_default()) { + Ok(address) => address, + Err(_) => { + println!("Transaction is not valid. Lender address is not a valid short address."); + return; + } + }; + let collateral = json["collateral"].as_str().unwrap_or_default().to_string(); + let collateral_amount = json["collateral_amount"].as_u64().unwrap_or_default(); + let borrower = + match normalize_short_address_input(json["borrower"].as_str().unwrap_or_default()) { + Ok(address) => address, + Err(_) => { + println!( + "Transaction is not valid. Borrower address is not a valid short address." + ); + return; + } + }; + let payment_period = json["payment_period"] + .as_str() + .unwrap_or_default() + .to_string(); + let payment_number = json["payment_number"].as_u64().unwrap_or_default() as u8; + let payment_amount = json["payment_amount"].as_u64().unwrap_or_default(); + let grace_period = json["grace_period"].as_u64().unwrap_or_default() as u8; + let max_late_value = json["max_late_value"].as_u64().unwrap_or_default(); + let txfee = json["txfee"].as_u64().unwrap_or_default(); + let original_hash = json["hash"].as_str().unwrap_or_default().to_string(); + let signature1 = json["signature1"].as_str().unwrap_or_default().to_string(); + + // The borrower must explicitly confirm the human-readable loan terms before signing. + let question1 = format!( + "Are you expecting to receive {} {}?", + display_amount(loan_amount), + loan_coin.trim() + ); + let question2 = format!( + "Are you willing to lock {} {} as collateral?", + display_amount(collateral_amount), + collateral.trim() + ); + let question3 = format!( + "Do you agree that payments should be made {}?", + display_payment_period(&payment_period) + ); + let question4 = format!( + "Do you agree that this loan requires {payment_number} total payments?" + ); + let question5 = format!( + "Do you agree that each payment should be {} {}?", + display_amount(payment_amount), + loan_coin.trim() + ); + let question6 = format!( + "Do you agree that {grace_period} missed payments allows the lender to claim the collateral?" + ); + let question7 = format!( + "Do you agree that being {} {} behind allows the lender to claim the collateral?", + display_amount(max_late_value), + loan_coin.trim() + ); + let question8 = format!( + "Do you agree that this loan begins on {}?", + display_start_date(timestamp) + ); + let question9 = format!("Do you agree that the lender wallet is {}?", lender.trim()); + + if !ask_yes_no_question(&question1).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question2).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question3).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question4).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question5).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question6).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question7).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question8).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question9).await { + println!("Transaction is not valid"); + return; + } + + // Load the borrower wallet and ensure it matches the borrower address in the offer. + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + let private_key = &wallet.saved.private_key; + let address = &wallet.saved.short_address; + + if borrower.trim() != address.trim() { + println!( + "Transaction is not valid for your wallet address. Expected {borrower} found {address}" + ); + return; + } + + if !Wallet::short_address_validation(address.trim()) { + println!("Your wallet short address is not valid: {address}"); + return; + } + + // Rebuild the unsigned loan and sign it with the borrower wallet. + let unsigned_loan = UnsignedLoanContractTransaction::new( + txtype, + timestamp, + &loan_coin, + loan_amount, + &lender, + &collateral, + collateral_amount, + &borrower, + &payment_period, + payment_number, + payment_amount, + grace_period, + max_late_value, + txfee, + ) + .await; + + let (hash, signature2) = match unsigned_loan.hash_and_sign(private_key).await { + Ok(value) => value, + Err(err) => { + println!("Signing transaction failed: {err}"); + return; + } + }; + + // If the rebuilt hash differs, the JSON was changed after the lender signed it. + if hash != original_hash { + println!("Signing transaction failed. The included hash was incorrect."); + return; + } + + // Save the fully signed loan contract JSON for broadcast. + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "loan_coin": loan_coin, + "loan_amount": loan_amount, + "lender": lender, + "collateral": collateral, + "collateral_amount": collateral_amount, + "borrower": borrower, + "payment_period": payment_period, + "payment_number": payment_number, + "payment_amount": payment_amount, + "grace_period": grace_period, + "max_late_value": max_late_value, + "txfee": txfee, + "hash": hash, + "signature1": signature1, + "signature2": signature2, + }); + let output_str = to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Transaction creation tools all write into ./transactions using the transaction hash. + let dir_path = "./transactions"; + if let Err(e) = fs::create_dir_all(dir_path) { + eprintln!("Failed to create directory: {e}"); + return; + } + + let file_path = format!( + "{}/{}.json", + dir_path, + output["hash"].as_str().unwrap_or_default() + ); + if let Err(e) = fs::write(&file_path, &output_str) { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("Transaction: {output_str}"); +} diff --git a/src/bin/verify_sign_swap_tx.rs b/src/bin/verify_sign_swap_tx.rs new file mode 100644 index 0000000..b17cdf7 --- /dev/null +++ b/src/bin/verify_sign_swap_tx.rs @@ -0,0 +1,249 @@ +use blockchain::blocks::swap::UnsignedSwapTransaction; +use blockchain::common::cli_prompts::{ask_yes_no_question, prompt_hidden_nonempty}; +use blockchain::common::network_paths_and_settings::block_extension_and_paths; +use blockchain::env; +use blockchain::fs; +use blockchain::json; +use blockchain::read_to_string; +use blockchain::records::wallet_registry::resolve_local_input_short_address; +use blockchain::wallets::structures::Wallet; +use blockchain::Value; + +// padd the coin to ensure 15 characters +fn pad_to_width(input: &str, width: usize) -> String { + let mut result = String::with_capacity(width); // Pre-allocate string with capacity + let _ = std::fmt::write( + &mut result, + format_args!("{input: Result { + resolve_local_input_short_address(address.trim()) +} + +#[tokio::main] +async fn main() { + // Get the filename from the command line arguments + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage:./sign_swap "); + return; + } + let filename = &args[1]; + let decryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + + let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("Wallet decryption failed: {err}"); + return; + } + }; + + let private_key = &wallet.saved.private_key; + let address = &wallet.saved.short_address; + + // Read the contents of the file + let contents = match read_to_string(filename).await { + Ok(contents) => contents, + Err(_) => { + println!("Error reading file: {filename}"); + return; + } + }; + + // Parse the JSON + let json: Result = serde_json::from_str(&contents); + let json = match json { + Ok(json) => json, + Err(_) => { + println!("Error parsing JSON in file: {filename}"); + return; + } + }; + + let txtype = 6; + + let timestamp = json["timestamp"].as_u64().unwrap_or_default() as u32; + let offer_expiration = json["offer_expiration"].as_u64().unwrap_or_default() as u32; + + // get values from transaction json + let token_name1 = json["ticker1"] + .as_str() + .unwrap_or_default() + .trim() + .to_lowercase(); + let nft_series1: u32 = json["nft_series1"].as_u64().unwrap_or_default() as u32; + let value1: u64 = json["value1"].as_u64().unwrap_or_default(); + let receive_amount = value1 as f64 / 100000000.0; + + let token_name2 = json["ticker2"] + .as_str() + .unwrap_or_default() + .trim() + .to_lowercase(); + let nft_series2: u32 = json["nft_series2"].as_u64().unwrap_or_default() as u32; + let value2: u64 = json["value2"].as_u64().unwrap_or_default(); + let send_amount = value2 as f64 / 100000000.0; + + let txfee1_value: u64 = json["txfee1"].as_u64().unwrap_or_default(); + let tip1_value: u64 = json["tip1"].as_u64().unwrap_or_default(); + + let sender1 = match normalize_short_address_input(json["sender1"].as_str().unwrap_or_default()) + { + Ok(address) => address, + Err(_) => { + println!("sender1 wallet invalid"); + return; + } + }; + + let txfee2_value: u64 = json["txfee2"].as_u64().unwrap_or_default(); + let txfee2 = txfee2_value as f64 / 100000000.0; + let tip2_value: u64 = json["tip2"].as_u64().unwrap_or_default(); + let tip2 = tip2_value as f64 / 100000000.0; + + let sender2 = match normalize_short_address_input(json["sender2"].as_str().unwrap_or_default()) + { + Ok(address) => address, + Err(_) => { + println!("sender2 wallet invalid"); + return; + } + }; + + // ensure wallet and sender2 match + if sender2 != address.trim() { + println!( + "Transaction is not valid for your wallet address. Expected {sender2} found {address}" + ); + return; + } + + let ( + _network_name, + network_coin, + _suffix, + _torrent_path, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + // setup validation questions + let question1 = format!( + "Are you expecting to receive {receive_amount} {token_name1}?" + ); + let question2 = format!("Are you expecting to send {send_amount} {token_name2}?"); + let question3 = format!( + "Are you willing to spend {txfee2} {network_coin} in fees?" + ); + let question4 = format!("Are you willing to tip {tip2} {token_name2}?"); + + // ask validation questions + if !ask_yes_no_question(&question1).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question2).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question3).await { + println!("Transaction is not valid"); + return; + } + if !ask_yes_no_question(&question4).await { + println!("Transaction is not valid"); + return; + } + + let padded_token_name1 = pad_to_width(&token_name1, 15); + let padded_token_name2 = pad_to_width(&token_name2, 15); + + let unsigned_swap = UnsignedSwapTransaction::new( + txtype, + timestamp, + offer_expiration, + &padded_token_name1, + nft_series1, + value1, + &padded_token_name2, + nft_series2, + value2, + &sender1, + address.trim(), + tip1_value, + tip2_value, + txfee1_value, + txfee2_value, + ) + .await; + + let hashed_data = unsigned_swap.hash().await; + + let original_hash = &json["hash"].as_str().unwrap_or_default().trim(); + let signature1 = &json["signature1"].as_str().unwrap_or_default().trim(); + + let signature2 = if hashed_data == *original_hash.to_string() { + match unsigned_swap.hash_and_sign(&private_key.to_string()).await { + Ok(signature) => signature, + Err(err) => { + println!("Signing transaction failed: {err}"); + return; + } + } + } else { + println!("Signing transaction failed. The included hash was incorrect."); + return; + }; + + let output = json!({ + "txtype": txtype, + "timestamp": timestamp, + "offer_expiration": offer_expiration, + "ticker1": padded_token_name1, + "nft_series1": nft_series1, + "value1": value1, + "ticker2": padded_token_name2, + "nft_series2": nft_series2, + "value2": value2, + "sender1": sender1, + "sender2": address.trim(), + "tip1": tip1_value, + "tip2": tip2_value, + "txfee1": txfee1_value, + "txfee2": txfee2_value, + "hash": hashed_data, + "signature1": signature1, + "signature2": signature2 + }); + let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); + + // Define the directory path + let dir_path = "./transactions"; + + // Create the directory if it doesn't exist + if let Err(e) = fs::create_dir_all(dir_path) { + eprintln!("Failed to create directory: {e}"); + return; + } + + // Define the file path + let file_path = format!("{dir_path}/{hashed_data}.json"); + + // Write the JSON string to the file + if let Err(e) = fs::write(&file_path, &output_str) { + eprintln!("Failed to write file: {e}"); + return; + } + + println!("Transaction: {output_str}"); +} diff --git a/src/blocks/block.rs b/src/blocks/block.rs new file mode 100644 index 0000000..ed4573c --- /dev/null +++ b/src/blocks/block.rs @@ -0,0 +1,323 @@ +use crate::common::skein::{skein_256_hash_data, skein_512_hash_data}; +use crate::common::types::Transaction; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::averages::{calculate_averages, update_block_data}; +use crate::sled::Db; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Duration; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +pub const TIMESTAMP_OFFSET: usize = 0; +pub const MINER_OFFSET: usize = TIMESTAMP_OFFSET + 4; +pub const PREVIOUS_HASH_OFFSET: usize = MINER_OFFSET + Wallet::SHORT_ADDRESS_BYTES_LENGTH; +pub const DIFFICULTY_OFFSET: usize = PREVIOUS_HASH_OFFSET + 32; +pub const NONCE_OFFSET: usize = DIFFICULTY_OFFSET + 8; +pub const VRF_OFFSET: usize = NONCE_OFFSET + 1; +pub const PROOF_OFFSET: usize = VRF_OFFSET + 16; +pub const UNMINED_BLOCK_BYTES: usize = 4 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 32 + 8 + 1; +pub const VRF_BLOCK_BYTES: usize = UNMINED_BLOCK_BYTES + 16 + Wallet::SIGNATURE_LENGTH; + +// UnminedBlock is the deterministic header data that exists before +// the miner wallet adds the VRF proof. +#[derive(Debug, Serialize, Clone)] // 67 bytes +pub struct UnminedBlock { + pub timestamp: u32, // 4 bytes block timestamp + pub miner: String, // 22 bytes miner short address + pub previous_hash: String, // 32 bytes parent block hash + pub next_block_difficulty: u64, // 8 bytes difficulty for this block + pub nonce: u8, // 1 byte nonce searched by mining workers +} + +// VrfBlock adds the miner's signed proof and derived VRF number to the header. +#[derive(Debug, Serialize, Clone)] // 749 bytes +pub struct VrfBlock { + pub unmined_block: UnminedBlock, // 67 bytes unsigned block header fields + pub vrf: u128, // 16 bytes random number derived from proof + pub proof: String, // 666 bytes miner signature proof +} + +// Block stores the VRF header plus the ordered transaction list. +#[derive(Debug, Serialize)] // header is 749 bytes plus transactions +pub struct Block { + pub vrf_block: VrfBlock, + pub transactions: Vec, +} + +impl UnminedBlock { + // Create the unmined block header fields. + pub async fn new( + timestamp: u32, + miner: &str, + previous_hash: &str, + next_block_difficulty: u64, + nonce: u8, + ) -> Self { + Self { + timestamp, + miner: miner.to_string(), + previous_hash: previous_hash.to_string(), + next_block_difficulty, + nonce, + } + } + + pub async fn generate_random_number(input: &str) -> u128 { + // Hash the proof with Skein512, then fold the 64-byte result + // into one u128 value by XORing four 16-byte chunks. + let hash = skein_512_hash_data(input); + let hash_bytes = decode(&hash).expect("Failed to decode hash"); + + if hash_bytes.len() != 64 { + panic!("Hash must be exactly 64 bytes long."); + } + + let a = u128::from_le_bytes( + hash_bytes[0..16] + .try_into() + .expect("Chunk A must be 16 bytes"), + ); + let b = u128::from_le_bytes( + hash_bytes[16..32] + .try_into() + .expect("Chunk B must be 16 bytes"), + ); + let c = u128::from_le_bytes( + hash_bytes[32..48] + .try_into() + .expect("Chunk C must be 16 bytes"), + ); + let d = u128::from_le_bytes( + hash_bytes[48..64] + .try_into() + .expect("Chunk D must be 16 bytes"), + ); + + a ^ b ^ c ^ d + } + + pub async fn vrf_generate(self, wallet_key: String) -> VrfBlock { + // Sign the unmined header hash with the miner wallet and derive + // the VRF number from that signature. + let hash = self.hash().await; + let wallet = Wallet::try_obtain_wallet(wallet_key, None) + .await + .unwrap_or_else(|err| panic!("Wallet decryption failed: {err}")); + let privkey = &wallet.saved.private_key; + let proof = Wallet::sign_transaction(&hash, privkey).await; + let vrf = Self::generate_random_number(&proof).await; + VrfBlock { + unmined_block: self, + vrf, + proof, + } + } + + // Hash the unmined block header for VRF signing. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Calculate the next difficulty using the rolling average and target block time. + fn calculate_new_difficulty( + current_difficulty: u64, + difficulty_average: u64, + average_duration: Duration, + ) -> u64 { + let lower_bound = Duration::from_secs(14); + let upper_bound = Duration::from_secs(16); + + // When the rolling average is already within the target window, + // use the cached mean difficulty exactly. + if difficulty_average > 0 + && average_duration >= lower_bound + && average_duration <= upper_bound + { + return difficulty_average; + } + + // Outside the target window, apply the capped 30% adjustment + // with integer math to keep the result stable. + let adjustment = current_difficulty.saturating_mul(30).saturating_div(100); + if average_duration > upper_bound { + current_difficulty.saturating_add(adjustment) + } else if average_duration < lower_bound { + current_difficulty.saturating_sub(adjustment) + } else { + current_difficulty + } + } + + // Adjust difficulty based on the latest saved block averages. + pub async fn adjust_difficulty( + current_timestamp: u32, + db: &Db, + current_difficulty: u64, + ) -> u64 { + let block_number = get_height(db); + + // Refresh rolling block data before reading averages. + update_block_data(block_number).await; + + // Get the current rolling difficulty and duration averages. + let (difficulty_average, average_duration) = calculate_averages(current_timestamp).await; + + // Apply the bounded difficulty adjustment. + Self::calculate_new_difficulty(current_difficulty, difficulty_average, average_duration) + } +} + +impl VrfBlock { + pub async fn hash(&self) -> String { + // Hash the full VRF header for indexing and validation. + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize the fixed-width VRF header layout. + let mut buffer = Vec::with_capacity(VRF_BLOCK_BYTES); + let mut cursor = Cursor::new(&mut buffer); + cursor + .write_all(&self.unmined_block.timestamp.to_le_bytes()) + .await?; + let miner_bytes = + Wallet::short_address_to_bytes(&self.unmined_block.miner).ok_or_else(|| { + tokio::io::Error::other("Invalid short miner address") + })?; + cursor.write_all(&miner_bytes).await?; + cursor + .write_all(&decode(&self.unmined_block.previous_hash).unwrap()) + .await?; + cursor + .write_all(&self.unmined_block.next_block_difficulty.to_le_bytes()) + .await?; + cursor + .write_all(&self.unmined_block.nonce.to_le_bytes()) + .await?; + cursor.write_all(&self.vrf.to_le_bytes()).await?; + cursor.write_all(&decode(&self.proof).unwrap()).await?; + Ok(buffer) + } + + pub async fn from_bytes(bytes: &[u8]) -> tokio::io::Result { + // A VRF header must be exactly the fixed header byte length. + if bytes.len() != VRF_BLOCK_BYTES { + return Err(tokio::io::Error::other("Invalid Byte Count for Block", + )); + } + + // Read from the fixed-width VRF header bytes. + let mut cursor = Cursor::new(bytes); + + // Decode timestamp and miner short address. + let timestamp = cursor.read_u32_le().await?; + + let mut miner_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut miner_bytes).await?; + let miner = Wallet::bytes_to_short_address(&miner_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid short miner address") + })?; + + // Decode parent hash, difficulty, nonce, VRF number, and proof. + let mut prev_hash_bytes = vec![0; 32]; + cursor.read_exact(&mut prev_hash_bytes).await?; + let previous_hash = encode(&prev_hash_bytes); + + let next_block_difficulty = cursor.read_u64_le().await?; + let nonce = cursor.read_u8().await?; + let mut vrf_bytes = [0u8; 16]; + cursor.read_exact(&mut vrf_bytes).await?; + let vrf = u128::from_le_bytes(vrf_bytes); + + let mut proof_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut proof_bytes).await?; + let proof = encode(&proof_bytes); + + let unmined_block = UnminedBlock { + timestamp, + miner, + previous_hash, + next_block_difficulty, + nonce, + }; + Ok(VrfBlock { + unmined_block, + vrf, + proof, + }) + } +} + +impl Block { + pub async fn to_bytes(&self) -> tokio::io::Result> { + let mut buffer = Vec::new(); + + // Serialize the fixed-width VRF header before any transactions. + let vrf_bytes = self.vrf_block.to_bytes().await?; + buffer.extend_from_slice(&vrf_bytes); + + // Append each transaction in block order using its own fixed layout. + for transaction in &self.transactions { + match transaction { + Transaction::Genesis(genesis_tx) => { + let tx_bytes = genesis_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Rewards(rewards_tx) => { + let tx_bytes = rewards_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Transfer(transfer_tx) => { + let tx_bytes = transfer_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Token(token_tx) => { + let tx_bytes = token_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::IssueToken(issue_token_tx) => { + let tx_bytes = issue_token_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Burn(burn_tx) => { + let tx_bytes = burn_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Nft(nft_tx) => { + let tx_bytes = nft_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Marketing(marketing_tx) => { + let tx_bytes = marketing_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Swap(swap_tx) => { + let tx_bytes = swap_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Lender(lender_tx) => { + let tx_bytes = lender_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Borrower(borrower_tx) => { + let tx_bytes = borrower_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Collateral(collateral_tx) => { + let tx_bytes = collateral_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::Vanity(vanity_tx) => { + let tx_bytes = vanity_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + } + } + Ok(buffer) + } +} diff --git a/src/blocks/burn.rs b/src/blocks/burn.rs new file mode 100644 index 0000000..deed94e --- /dev/null +++ b/src/blocks/burn.rs @@ -0,0 +1,222 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; +use anyhow::{anyhow, Result}; + +// Burn transactions destroy an existing non-base asset without routing it through a receiver. +#[derive(Debug, Serialize, Clone)] // 62 bytes +pub struct UnsignedBurnTransaction { + pub txtype: u8, // 1 byte txtype should be 10 + pub time: u32, // 4 bytes timestamp + pub address: String, // 22 bytes wallet address of burner + pub coin: String, // 15 bytes token or NFT base name, padded with spaces + pub nft_series: u32, // 4 bytes 0 for tokens or 1-of-1 NFTs, otherwise specific NFT series item + pub value: u64, // 8 bytes amount of token units or NFT unit value to burn + pub txfee: u64, // 8 bytes fee of transaction +} + +#[derive(Debug, Serialize, Clone)] // 728 bytes +pub struct BurnTransaction { + pub unsigned_burn: UnsignedBurnTransaction, // 62 bytes + pub signature: String, // 666 bytes signature of hash +} + +impl BurnTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 15 + 4 + 8 + 8 + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedBurnTransaction { + // Create an unsigned burn transaction. + pub async fn new( + txtype: u8, + time: u32, + address: &str, + coin: &str, + nft_series: u32, + value: u64, + txfee: u64, + ) -> Self { + Self { + txtype, + time, + address: address.to_string(), + coin: coin.to_string(), + nft_series, + value, + txfee, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result> { + let serialized = to_string(self)?; + let hash = skein_256_hash_data(&serialized); + let signature = Wallet::sign_transaction(&hash, private_key).await; + Ok(signature) + } +} + +impl BurnTransaction { + // Create a signed burn transaction. + pub async fn new( + unsigned_burn: UnsignedBurnTransaction, + private_key: &str, + ) -> Result> { + let signature = unsigned_burn.hash_and_sign(private_key).await?; + + Ok(Self { + unsigned_burn, + signature, + }) + } + + // Load an existing burn transaction. + pub async fn load(unsigned_burn: UnsignedBurnTransaction, signature: &str) -> Self { + Self { + unsigned_burn, + signature: signature.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_burn.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_burn.time.to_le_bytes()) + .await?; + let address_bytes = Wallet::short_address_to_bytes(&self.unsigned_burn.address) + .ok_or_else(|| { + tokio::io::Error::other("Invalid burn short address") + })?; + cursor.write_all(&address_bytes).await?; + cursor.write_all(self.unsigned_burn.coin.as_bytes()).await?; + cursor + .write_all(&self.unsigned_burn.nft_series.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_burn.value.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_burn.txfee.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.signature).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + let mut cursor = Cursor::new(bytes); + + let time = cursor.read_u32_le().await?; + + let mut address_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut address_bytes).await?; + let address = Wallet::bytes_to_short_address(&address_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid burn short address bytes", + ) + })?; + + let mut coin_bytes = vec![0; 15]; + cursor.read_exact(&mut coin_bytes).await?; + let coin = binary_to_string(coin_bytes); + + let nft_series = cursor.read_u32_le().await?; + let value = cursor.read_u64_le().await?; + let txfee = cursor.read_u64_le().await?; + + let mut signature_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature_bytes).await?; + let signature = encode(&signature_bytes); + + let unsigned_burn = UnsignedBurnTransaction { + txtype, + time, + address, + coin, + nft_series, + value, + txfee, + }; + + Ok(BurnTransaction { + unsigned_burn, + signature, + }) + } + + pub async fn add_to_memory(&self) -> Result<()> { + let original_data = self + .to_bytes() + .await + .map_err(|_| anyhow!("Failed to serialize transaction"))?; + + let fee = i64::try_from(self.unsigned_burn.txfee) + .map_err(|_| std::io::Error::other("Burn fee exceeds i64 mempool limit"))?; + let time = self.unsigned_burn.time as i32; + let value = i64::try_from(self.unsigned_burn.value) + .map_err(|_| std::io::Error::other("Burn value exceeds i64 mempool limit"))?; + let nft_series = self.unsigned_burn.nft_series as i32; + let address = &self.unsigned_burn.address; + let coin = &self.unsigned_burn.coin; + let hash = &self.unsigned_burn.hash().await; + let signature = &self.signature; + + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO burn ( + time, + fee, + address, + coin, + nft_series, + value, + hash, + signature, + original + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + &[ + &time, + &fee, + address, + coin, + &nft_series, + &value, + hash, + signature, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/collateral.rs b/src/blocks/collateral.rs new file mode 100644 index 0000000..1e7e1b8 --- /dev/null +++ b/src/blocks/collateral.rs @@ -0,0 +1,215 @@ +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 67 bytes +pub struct UnsignedCollateralClaimTransaction { + pub txtype: u8, // 1 byte transaction type, should be 9 + pub time: u32, // 4 bytes transaction timestamp + pub contract_hash: String, // 32 bytes hash of the loan contract + pub address: String, // 22 bytes claimant short address + pub txfee: u64, // 8 bytes transaction fee +} + +#[derive(Debug, Serialize, Clone)] // 733 bytes +pub struct CollateralClaimTransaction { + pub unsigned_collateral_claim: UnsignedCollateralClaimTransaction, // 67 bytes + pub signature: String, // 666 bytes signature of the transaction hash +} + +impl CollateralClaimTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + 32 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 8 + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedCollateralClaimTransaction { + // Create an unsigned collateral-claim transaction. + pub async fn new( + txtype: u8, + time: u32, + contract_hash: &str, + address: &str, + txfee: u64, + ) -> Self { + Self { + txtype, + time, + contract_hash: contract_hash.to_string(), + address: address.to_string(), + txfee, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result> { + // Serialize the unsigned transaction before hashing. + let serialized = to_string(self)?; + + // Hash the serialized unsigned payload. + let hash = skein_256_hash_data(&serialized); + + // Sign the transaction hash with the claimant wallet. + let signature = Wallet::sign_transaction(&hash, private_key).await; + + Ok(signature) + } +} + +impl CollateralClaimTransaction { + pub async fn new( + unsigned_collateral_claim: UnsignedCollateralClaimTransaction, + private_key: &str, + ) -> Result> { + // Hash and sign the unsigned transaction. + let signature = unsigned_collateral_claim.hash_and_sign(private_key).await?; + + // Return the complete collateral-claim transaction. + Ok(Self { + unsigned_collateral_claim, + signature, + }) + } + + // Load an existing collateral-claim transaction. + pub async fn load( + unsigned_collateral_claim: UnsignedCollateralClaimTransaction, + signature: &str, + ) -> Self { + Self { + unsigned_collateral_claim, + signature: signature.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed collateral-claim transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_collateral_claim.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_collateral_claim.time.to_le_bytes()) + .await?; + cursor + .write_all(&decode(&self.unsigned_collateral_claim.contract_hash).unwrap()) + .await?; + let address_bytes = Wallet::short_address_to_bytes(&self.unsigned_collateral_claim.address) + .ok_or_else(|| { + tokio::io::Error::other("Invalid collateral claimant short address", + ) + })?; + cursor.write_all(&address_bytes).await?; + cursor + .write_all(&self.unsigned_collateral_claim.txfee.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.signature).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read the remaining fixed-width collateral-claim bytes. + let mut cursor = Cursor::new(bytes); + + // Decode timestamp and contract hash. + let time = cursor.read_u32_le().await?; + + let mut contract_hash_bytes = vec![0; 32]; + cursor.read_exact(&mut contract_hash_bytes).await?; + let contract_hash = encode(&contract_hash_bytes); + + // Decode claimant short address, fee, and signature. + let mut address_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut address_bytes).await?; + let address = Wallet::bytes_to_short_address(&address_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid collateral claimant short address bytes", + ) + })?; + + let txfee = cursor.read_u64_le().await?; + let mut signature_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature_bytes).await?; + let signature = encode(&signature_bytes); + + let unsigned_collateral_claim = UnsignedCollateralClaimTransaction { + txtype, + time, + contract_hash, + address, + txfee, + }; + Ok(CollateralClaimTransaction { + unsigned_collateral_claim, + signature, + }) + } + + pub async fn add_to_memory(&self) -> Result<(), Box> { + // Store original bytes so the mempool can rebuild the exact + // transaction submitted by the wallet. + let original_data = self.to_bytes().await?; + + // PostgreSQL uses signed integer types for these persisted fields. + let time = self.unsigned_collateral_claim.time as i32; + let fee = i64::try_from(self.unsigned_collateral_claim.txfee) + .map_err(|_| std::io::Error::other("Collateral fee exceeds i64 mempool limit"))?; + let address = &self.unsigned_collateral_claim.address; + let contract_hash = &self.unsigned_collateral_claim.contract_hash; + let hash = &self.unsigned_collateral_claim.hash().await; + let signature = &self.signature; + + // Collateral-claim transactions remain in the mempool table until mined or removed. + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO collateral_claim ( + time, + fee, + address, + contract_hash, + hash, + signature, + original + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7 + ) + "#, + &[ + &time, + &fee, + address, + contract_hash, + hash, + signature, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/genesis.rs b/src/blocks/genesis.rs new file mode 100644 index 0000000..741f21a --- /dev/null +++ b/src/blocks/genesis.rs @@ -0,0 +1,83 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::common::skein::skein_256_hash_data; +use crate::to_string; +use crate::Cursor; +use crate::Serialize; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 49 bytes +pub struct UnsignedGenesisTransaction { + pub txtype: u8, // 1 byte transaction type, should be 0 + pub message: String, // 48 bytes fixed genesis block message +} + +#[derive(Debug, Serialize, Clone)] // 49 bytes +pub struct GenesisTransaction { + pub unsigned: UnsignedGenesisTransaction, // 49 bytes +} + +impl UnsignedGenesisTransaction { + // Create the unsigned genesis transaction. + pub async fn new(txtype: u8, message: &str) -> Self { + Self { + txtype, + message: message.to_string(), + } + } + + pub async fn hash(&self) -> String { + // Genesis hashes the serialized unsigned payload like other + // transaction types, even though it has no wallet signature. + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } +} + +impl GenesisTransaction { + const MESSAGE_LENGTH: usize = 48; + pub const BYTE_LENGTH: usize = 1 + Self::MESSAGE_LENGTH; + + pub async fn new(unsigned: UnsignedGenesisTransaction) -> Self { + // Wrap a freshly created unsigned genesis payload. + Self { unsigned } + } + + pub async fn load(unsigned: UnsignedGenesisTransaction) -> Self { + // Rebuild a genesis transaction already read from storage. + Self { unsigned } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed genesis byte layout: + // type byte followed by the fixed-length launch message. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned.txtype.to_le_bytes()) + .await?; + cursor.write_all(self.unsigned.message.as_bytes()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The transaction type is read by the block parser, so this + // function receives only the remaining genesis bytes. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read from the remaining fixed-width transaction bytes. + let mut cursor = Cursor::new(bytes); + + // Decode the fixed 48-byte genesis message. + let mut message_bytes = vec![0; Self::MESSAGE_LENGTH]; + cursor.read_exact(&mut message_bytes).await?; + let message = binary_to_string(message_bytes); + + let unsigned = UnsignedGenesisTransaction { txtype, message }; + Ok(GenesisTransaction { unsigned }) + } +} diff --git a/src/blocks/issue_token.rs b/src/blocks/issue_token.rs new file mode 100644 index 0000000..ffdc844 --- /dev/null +++ b/src/blocks/issue_token.rs @@ -0,0 +1,221 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 58 bytes +pub struct UnsignedIssueTokenTransaction { + pub txtype: u8, // 1 byte txtype should be 11 + pub time: u32, // 4 bytes timestamp + pub creator: String, // 22 bytes wallet address of token creator + pub ticker: String, // 15 bytes token ticker padded with spaces + pub number: u64, // 8 bytes number of new tokens to issue + pub txfee: u64, // 8 bytes fee of transaction +} + +#[derive(Debug, Serialize, Clone)] // 724 bytes +pub struct IssueTokenTransaction { + pub unsigned_issue_token: UnsignedIssueTokenTransaction, // 58 bytes + pub signature: String, // 666 bytes signature of hash +} + +impl IssueTokenTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 15 + 8 + 8 + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedIssueTokenTransaction { + // Create an unsigned issue-token transaction. + pub async fn new( + txtype: u8, + time: u32, + creator: &str, + ticker: &str, + number: u64, + txfee: u64, + ) -> Self { + Self { + txtype, + time, + creator: creator.to_string(), + ticker: ticker.to_string(), + number, + txfee, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result> { + let serialized = to_string(self)?; + let hash = skein_256_hash_data(&serialized); + let signature = Wallet::sign_transaction(&hash, private_key).await; + Ok(signature) + } +} + +impl IssueTokenTransaction { + // Create a signed issue-token transaction. + pub async fn new( + unsigned_issue_token: UnsignedIssueTokenTransaction, + private_key: &str, + ) -> Result> { + let signature = unsigned_issue_token.hash_and_sign(private_key).await?; + + Ok(Self { + unsigned_issue_token, + signature, + }) + } + + // Load an existing issue-token transaction. + pub async fn load( + unsigned_issue_token: UnsignedIssueTokenTransaction, + signature: &str, + ) -> Self { + Self { + unsigned_issue_token, + signature: signature.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed issue-token transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_issue_token.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_issue_token.time.to_le_bytes()) + .await?; + let creator_bytes = Wallet::short_address_to_bytes(&self.unsigned_issue_token.creator) + .ok_or_else(|| { + tokio::io::Error::other("Invalid issue-token creator short address", + ) + })?; + cursor.write_all(&creator_bytes).await?; + cursor + .write_all(self.unsigned_issue_token.ticker.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_issue_token.number.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_issue_token.txfee.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.signature).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + let mut cursor = Cursor::new(bytes); + + // Decode timestamp and creator short address. + let time = cursor.read_u32_le().await?; + + let mut creator_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut creator_bytes).await?; + let creator = Wallet::bytes_to_short_address(&creator_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid issue-token creator short address bytes", + ) + })?; + + // Decode token ticker, issued amount, fee, and signature. + let mut ticker_bytes = vec![0; 15]; + cursor.read_exact(&mut ticker_bytes).await?; + let ticker = binary_to_string(ticker_bytes); + + let number = cursor.read_u64_le().await?; + let txfee = cursor.read_u64_le().await?; + + let mut signature_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature_bytes).await?; + let signature = encode(&signature_bytes); + + let unsigned_issue_token = UnsignedIssueTokenTransaction { + txtype, + time, + creator, + ticker, + number, + txfee, + }; + + Ok(IssueTokenTransaction { + unsigned_issue_token, + signature, + }) + } + + pub async fn add_to_memory(&self) -> Result<(), Box> { + // Store original bytes so the mempool can rebuild the exact + // transaction submitted by the wallet. + let original_data = self.to_bytes().await?; + + // PostgreSQL uses signed integer types for these persisted fields. + let time = self.unsigned_issue_token.time as i32; + let fee = i64::try_from(self.unsigned_issue_token.txfee) + .map_err(|_| std::io::Error::other("Issue-token fee exceeds i64 mempool limit"))?; + let number = i64::try_from(self.unsigned_issue_token.number) + .map_err(|_| std::io::Error::other("Issue-token amount exceeds i64 mempool limit"))?; + + let creator = &self.unsigned_issue_token.creator; + let ticker = &self.unsigned_issue_token.ticker; + let hash = &self.unsigned_issue_token.hash().await; + let signature = &self.signature; + + // Issue-token transactions remain in the mempool table until mined or removed. + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO issue_token ( + time, + fee, + creator, + number, + ticker, + hash, + signature, + original + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + &[ + &time, + &fee, + creator, + &number, + ticker, + hash, + signature, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/loan_payment.rs b/src/blocks/loan_payment.rs new file mode 100644 index 0000000..5406689 --- /dev/null +++ b/src/blocks/loan_payment.rs @@ -0,0 +1,253 @@ +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 83 bytes +pub struct UnsignedContractPaymentTransaction { + pub txtype: u8, // 1 byte transaction type, should be 8 + pub timestamp: u32, // 4 bytes payment timestamp + pub payback_amount: u64, // 8 bytes amount of loaned token to pay back + pub contract_hash: String, // 32 bytes hash of the loan contract + pub address: String, // 22 bytes payer short address + pub tip: u64, // 8 bytes miner tip paid in the loan token + pub txfee: u64, // 8 bytes transaction fee +} + +#[derive(Debug, Serialize, Clone)] // 781 bytes +pub struct ContractPaymentTransaction { + pub unsigned_contract_payment: UnsignedContractPaymentTransaction, // 83 bytes + pub hash: String, // 32 bytes The hash of the transaction + pub signature: String, // 666 bytes The signature of the transaction hash +} + +impl ContractPaymentTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + 8 + 32 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 8 + 8 + 32 + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedContractPaymentTransaction { + // Create an unsigned loan-payment transaction. + pub async fn new( + txtype: u8, + timestamp: u32, + payback_amount: u64, + contract_hash: &str, + address: &str, + tip: u64, + txfee: u64, + ) -> Self { + Self { + txtype, + timestamp, + payback_amount, + contract_hash: contract_hash.to_string(), + address: address.to_string(), + tip, + txfee, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result<(String, String), Box> { + // Serialize the unsigned transaction before hashing. + let serialized = to_string(self)?; + + // Hash the serialized unsigned payload. + let hash = skein_256_hash_data(&serialized); + + // Sign the transaction hash with the payer wallet. + let signature = Wallet::sign_transaction(&hash, private_key).await; + + Ok((hash, signature)) + } +} + +impl ContractPaymentTransaction { + pub async fn new( + unsigned_contract_payment: UnsignedContractPaymentTransaction, + private_key: &str, + ) -> Result> { + // Hash and sign the unsigned transaction. + let (hash, signature) = unsigned_contract_payment.hash_and_sign(private_key).await?; + + // Return the complete loan-payment transaction with its txid hash. + Ok(Self { + unsigned_contract_payment, + hash, + signature, + }) + } + + // Load an existing loan-payment transaction. + pub async fn load( + unsigned_contract_payment: UnsignedContractPaymentTransaction, + hash: &str, + signature: &str, + ) -> Self { + Self { + unsigned_contract_payment, + hash: hash.to_string(), + signature: signature.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed loan-payment transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_contract_payment.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_contract_payment.timestamp.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_contract_payment.payback_amount.to_le_bytes()) + .await?; + cursor + .write_all(&decode(&self.unsigned_contract_payment.contract_hash).unwrap()) + .await?; + let address_bytes = Wallet::short_address_to_bytes(&self.unsigned_contract_payment.address) + .ok_or_else(|| { + tokio::io::Error::other("Invalid payer short address") + })?; + cursor.write_all(&address_bytes).await?; + cursor + .write_all(&self.unsigned_contract_payment.tip.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_contract_payment.txfee.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.hash).unwrap()).await?; + cursor.write_all(&decode(&self.signature).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read the remaining fixed-width loan-payment bytes. + let mut cursor = Cursor::new(bytes); + + // Decode timestamp, payment amount, and contract hash. + let timestamp = cursor.read_u32_le().await?; + let payback_amount = cursor.read_u64_le().await?; + + let mut contract_hash_bytes = vec![0; 32]; + cursor.read_exact(&mut contract_hash_bytes).await?; + let contract_hash = encode(&contract_hash_bytes); + + // Decode payer short address, miner tip, fee, txid hash, and signature. + let mut address_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut address_bytes).await?; + let address = Wallet::bytes_to_short_address(&address_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid payer short address bytes", + ) + })?; + + let tip = cursor.read_u64_le().await?; + let txfee = cursor.read_u64_le().await?; + + let mut hash_bytes = vec![0; 32]; + cursor.read_exact(&mut hash_bytes).await?; + let hash = encode(&hash_bytes); + + let mut signature_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature_bytes).await?; + let signature = encode(&signature_bytes); + + let unsigned_contract_payment = UnsignedContractPaymentTransaction { + txtype, + timestamp, + payback_amount, + contract_hash, + address, + tip, + txfee, + }; + Ok(ContractPaymentTransaction { + unsigned_contract_payment, + hash, + signature, + }) + } + + pub async fn add_to_memory(&self) -> Result<(), Box> { + // Store original bytes so the mempool can rebuild the exact + // transaction submitted by the wallet. + let original_data = self.to_bytes().await?; + + // PostgreSQL uses signed integer types for these persisted fields. + let fee = i64::try_from(self.unsigned_contract_payment.txfee) + .map_err(|_| std::io::Error::other("Loan payment fee exceeds i64 mempool limit"))?; + let time = self.unsigned_contract_payment.timestamp as i32; + let payback_amount = i64::try_from(self.unsigned_contract_payment.payback_amount) + .map_err(|_| std::io::Error::other("Loan payment amount exceeds i64 mempool limit"))?; + let tip = i64::try_from(self.unsigned_contract_payment.tip) + .map_err(|_| std::io::Error::other("Loan payment tip exceeds i64 mempool limit"))?; + + let contract_hash = &self.unsigned_contract_payment.contract_hash; + let address = &self.unsigned_contract_payment.address; + let txid = &self.hash; + let hash = &self.unsigned_contract_payment.hash().await; + let signature = &self.signature; + + // Loan-payment transactions remain in the mempool table until mined or removed. + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO loan_payment ( + fee, + time, + payback_amount, + contract_hash, + address, + tip, + txid, + hash, + signature, + original + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + ) + "#, + &[ + &fee, + &time, + &payback_amount, + contract_hash, + address, + &tip, + txid, + hash, + signature, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/loans.rs b/src/blocks/loans.rs new file mode 100644 index 0000000..da7ec43 --- /dev/null +++ b/src/blocks/loans.rs @@ -0,0 +1,369 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 122 bytes +pub struct UnsignedLoanContractTransaction { + pub txtype: u8, // 1 byte transaction type, should be 7 + pub timestamp: u32, // 4 bytes contract creation timestamp + pub loan_coin: String, // 15 bytes coin or token being loaned + pub loan_amount: u64, // 8 bytes amount being loaned + pub lender: String, // 22 bytes lender short address + pub collateral: String, // 15 bytes token or NFT used as collateral + pub collateral_amount: u64, // 8 bytes collateral token amount or 1 for NFT collateral + pub borrower: String, // 22 bytes borrower short address + pub payment_period: String, // 1 byte d, w, or m for days, weeks, or months + pub payment_number: u8, // 1 byte total number of payments + pub payment_amount: u64, // 8 bytes amount due for each payment + pub grace_period: u8, // 1 byte missed payments before collateral claim is allowed + pub max_late_value: u64, // 8 bytes max overdue value before collateral claim is allowed + pub txfee: u64, // 8 bytes transaction fee paid by the lender +} + +#[derive(Debug, Serialize, Clone)] // 1486 bytes +pub struct LoanContractTransaction { + pub unsigned_loan_contract: UnsignedLoanContractTransaction, // 122 bytes + pub hash: String, // 32 bytes The hash of the transaction + pub signature1: String, // 666 bytes The signature of lender for the hash + pub signature2: String, // 666 bytes The signature of borrower for the hash +} + +impl LoanContractTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + 15 + 8 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 15 + 8 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + + 1 + 1 + 8 + 1 + 8 + 8 + 32 + Wallet::SIGNATURE_LENGTH + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedLoanContractTransaction { + // Create an unsigned loan-contract transaction. + #[allow(clippy::too_many_arguments)] + pub async fn new( + txtype: u8, + timestamp: u32, + loan_coin: &str, + loan_amount: u64, + lender: &str, + collateral: &str, + collateral_amount: u64, + borrower: &str, + payment_period: &str, + payment_number: u8, + payment_amount: u64, + grace_period: u8, + max_late_value: u64, + txfee: u64, + ) -> Self { + Self { + txtype, + timestamp, + loan_coin: loan_coin.to_string(), + loan_amount, + lender: lender.to_string(), + collateral: collateral.to_string(), + collateral_amount, + borrower: borrower.to_string(), + payment_period: payment_period.to_string(), + payment_number, + payment_amount, + grace_period, + max_late_value, + txfee, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result<(String, String), Box> { + // Serialize the unsigned transaction before hashing. + let serialized = to_string(self)?; + + // Hash the serialized unsigned payload. + let hash = skein_256_hash_data(&serialized); + + // Sign the transaction hash with the current signer wallet. + let signature = Wallet::sign_transaction(&hash, private_key).await; + + Ok((hash, signature)) + } +} + +impl LoanContractTransaction { + // Add the next required signature to a loan-contract transaction. + pub async fn new( + &self, + unsigned_loan_contract: UnsignedLoanContractTransaction, + private_key: &str, + ) -> Result> { + // Hash and sign the unsigned transaction. + let (hash, signature) = unsigned_loan_contract.hash_and_sign(private_key).await?; + + // The first signer fills signature1, and the second signer fills signature2. + let mut signature1 = "".to_string(); + let mut signature2 = "".to_string(); + + if self.signature1.is_empty() { + signature1 = signature; + } else { + signature2 = signature; + } + + // Return the loan contract with the newest signature included. + Ok(Self { + unsigned_loan_contract, + hash, + signature1, + signature2, + }) + } + + // Load an existing loan-contract transaction. + pub async fn load( + unsigned_loan_contract: UnsignedLoanContractTransaction, + hash: &str, + signature1: &str, + signature2: &str, + ) -> Self { + Self { + unsigned_loan_contract, + hash: hash.to_string(), + signature1: signature1.to_string(), + signature2: signature2.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed loan-contract transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_loan_contract.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_loan_contract.timestamp.to_le_bytes()) + .await?; + cursor + .write_all(self.unsigned_loan_contract.loan_coin.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_loan_contract.loan_amount.to_le_bytes()) + .await?; + let lender_bytes = Wallet::short_address_to_bytes(&self.unsigned_loan_contract.lender) + .ok_or_else(|| { + tokio::io::Error::other("Invalid lender short address") + })?; + cursor.write_all(&lender_bytes).await?; + cursor + .write_all(self.unsigned_loan_contract.collateral.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_loan_contract.collateral_amount.to_le_bytes()) + .await?; + let borrower_bytes = Wallet::short_address_to_bytes(&self.unsigned_loan_contract.borrower) + .ok_or_else(|| { + tokio::io::Error::other("Invalid borrower short address", + ) + })?; + cursor.write_all(&borrower_bytes).await?; + cursor + .write_all(self.unsigned_loan_contract.payment_period.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_loan_contract.payment_number.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_loan_contract.payment_amount.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_loan_contract.grace_period.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_loan_contract.max_late_value.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_loan_contract.txfee.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.hash).unwrap()).await?; + cursor.write_all(&decode(&self.signature1).unwrap()).await?; + cursor.write_all(&decode(&self.signature2).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read the remaining fixed-width loan-contract bytes. + let mut cursor = Cursor::new(bytes); + + // Decode timestamp and loan terms. + let timestamp = cursor.read_u32_le().await?; + + let mut loan_coin_bytes = vec![0; 15]; + cursor.read_exact(&mut loan_coin_bytes).await?; + let loan_coin = binary_to_string(loan_coin_bytes); + + let loan_amount = cursor.read_u64_le().await?; + + // Decode lender short address. + let mut lender_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut lender_bytes).await?; + let lender = Wallet::bytes_to_short_address(&lender_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid lender short address bytes", + ) + })?; + + // Decode collateral asset and amount. + let mut collateral_bytes = vec![0; 15]; + cursor.read_exact(&mut collateral_bytes).await?; + let collateral = binary_to_string(collateral_bytes); + + let collateral_amount = cursor.read_u64_le().await?; + + // Decode borrower short address. + let mut borrower_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut borrower_bytes).await?; + let borrower = Wallet::bytes_to_short_address(&borrower_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid borrower short address bytes", + ) + })?; + + // Decode payment schedule and late-payment rules. + let mut payment_period_bytes = vec![0; 1]; + cursor.read_exact(&mut payment_period_bytes).await?; + let payment_period = binary_to_string(payment_period_bytes); + + let payment_number = cursor.read_u8().await?; + let payment_amount = cursor.read_u64_le().await?; + let grace_period = cursor.read_u8().await?; + let max_late_value = cursor.read_u64_le().await?; + + // Decode fee, txid hash, and both signatures. + let txfee = cursor.read_u64_le().await?; + + let mut hash_bytes = vec![0; 32]; + cursor.read_exact(&mut hash_bytes).await?; + let hash = encode(&hash_bytes); + + let mut signature1_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature1_bytes).await?; + let signature1 = encode(&signature1_bytes); + + let mut signature2_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature2_bytes).await?; + let signature2 = encode(&signature2_bytes); + + // Rebuild the unsigned loan-contract payload. + let unsigned_loan_contract = UnsignedLoanContractTransaction { + txtype, + timestamp, + loan_coin, + loan_amount, + lender, + collateral, + collateral_amount, + borrower, + payment_period, + payment_number, + payment_amount, + grace_period, + max_late_value, + txfee, + }; + + // Wrap the rebuilt unsigned payload, txid hash, and signatures. + Ok(LoanContractTransaction { + unsigned_loan_contract, + hash, + signature1, + signature2, + }) + } + + pub async fn add_to_memory(&self) -> Result<(), Box> { + // Store original bytes so the mempool can rebuild the exact + // transaction submitted by the wallets. + let original_data = self.to_bytes().await?; + + // PostgreSQL uses signed integer types for these persisted fields. + let fee = i64::try_from(self.unsigned_loan_contract.txfee) + .map_err(|_| std::io::Error::other("Loan contract fee exceeds i64 mempool limit"))?; + let time = self.unsigned_loan_contract.timestamp as i32; + let loan_amount = i64::try_from(self.unsigned_loan_contract.loan_amount) + .map_err(|_| std::io::Error::other("Loan amount exceeds i64 mempool limit"))?; + let collateral_amount = i64::try_from(self.unsigned_loan_contract.collateral_amount) + .map_err(|_| std::io::Error::other("Collateral amount exceeds i64 mempool limit"))?; + + let loan_coin = &self.unsigned_loan_contract.loan_coin; + let lender = &self.unsigned_loan_contract.lender; + let collateral = &self.unsigned_loan_contract.collateral; + let borrower = &self.unsigned_loan_contract.borrower; + let txid = &self.hash; + let hash = self.unsigned_loan_contract.hash().await; + let signature1 = &self.signature1; + let signature2 = &self.signature2; + + // Loan contracts remain in the mempool table until mined or removed. + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO loan_contract ( + fee, + time, + loan_coin, + loan_amount, + lender, + collateral, + collateral_amount, + borrower, + txid, + hash, + signature1, + signature2, + original + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, $12, $13 + ) + "#, + &[ + &fee, + &time, + loan_coin, + &loan_amount, + lender, + collateral, + &collateral_amount, + borrower, + txid, + &hash, + signature1, + signature2, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/marketing.rs b/src/blocks/marketing.rs new file mode 100644 index 0000000..1a51025 --- /dev/null +++ b/src/blocks/marketing.rs @@ -0,0 +1,271 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 195 bytes +pub struct UnsignedMarketingTransaction { + pub txtype: u8, // 1 byte transaction type, should be 5 + pub time: u32, // 4 bytes transaction timestamp + pub campaign: u64, // 8 bytes campaign identifier + pub ad_type: String, // 6 bytes banner, social, or text padded with spaces + pub keyword: String, // 40 bytes targeted keyword padded with spaces + pub displayed: String, // 100 bytes display location padded or truncated to fit + pub impression: u8, // 1 byte impression count update, 0 to 255 + pub click: u8, // 1 byte click count update, 0 to 255 + pub impression_value: u16, // 2 bytes impression value multiplied by 100 + pub click_value: u16, // 2 bytes click value multiplied by 100 + pub advertiser: String, // 22 bytes ad agency short address, not the client + pub txfee: u64, // 8 bytes transaction fee +} + +#[derive(Debug, Serialize, Clone)] // 861 bytes +pub struct MarketingTransaction { + pub unsigned_marketing: UnsignedMarketingTransaction, // 195 bytes + pub signature: String, // 666 bytes signature of hash +} + +impl MarketingTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + 8 + 6 + 40 + 100 + 1 + 1 + 2 + 2 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 8 + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedMarketingTransaction { + // Create an unsigned marketing transaction. + #[allow(clippy::too_many_arguments)] + pub async fn new( + txtype: u8, + time: u32, + campaign: u64, + ad_type: &str, + keyword: &str, + displayed: &str, + impression: u8, + click: u8, + impression_value: u16, + click_value: u16, + advertiser: &str, + txfee: u64, + ) -> Self { + Self { + txtype, + time, + campaign, + ad_type: ad_type.to_string(), + keyword: keyword.to_string(), + displayed: displayed.to_string(), + impression, + click, + impression_value, + click_value, + advertiser: advertiser.to_string(), + txfee, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result> { + // Serialize the unsigned transaction before hashing. + let serialized = to_string(self)?; + + // Hash the serialized unsigned payload. + let hash = skein_256_hash_data(&serialized); + + // Sign the transaction hash with the advertiser wallet. + let signature = Wallet::sign_transaction(&hash, private_key).await; + + Ok(signature) + } +} + +impl MarketingTransaction { + // Create a signed marketing transaction. + pub async fn new( + unsigned_marketing: UnsignedMarketingTransaction, + private_key: &str, + ) -> Result> { + // Hash and sign the unsigned transaction. + let signature = unsigned_marketing.hash_and_sign(private_key).await?; + + // Return the complete marketing transaction. + Ok(Self { + unsigned_marketing, + signature, + }) + } + + // Load an existing marketing transaction. + pub async fn load(unsigned_marketing: UnsignedMarketingTransaction, signature: &str) -> Self { + Self { + unsigned_marketing, + signature: signature.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed marketing transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_marketing.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_marketing.time.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_marketing.campaign.to_le_bytes()) + .await?; + cursor + .write_all(self.unsigned_marketing.ad_type.as_bytes()) + .await?; + cursor + .write_all(self.unsigned_marketing.keyword.as_bytes()) + .await?; + cursor + .write_all(self.unsigned_marketing.displayed.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_marketing.impression.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_marketing.click.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_marketing.impression_value.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_marketing.click_value.to_le_bytes()) + .await?; + let advertiser_bytes = Wallet::short_address_to_bytes(&self.unsigned_marketing.advertiser) + .ok_or_else(|| { + tokio::io::Error::other("Invalid advertiser short address", + ) + })?; + cursor.write_all(&advertiser_bytes).await?; + cursor + .write_all(&self.unsigned_marketing.txfee.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.signature).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read the remaining fixed-width marketing bytes. + let mut cursor = Cursor::new(bytes); + + // Decode timestamp and campaign identifier. + let time = cursor.read_u32_le().await?; + let campaign = cursor.read_u64_le().await?; + + // Decode fixed-width string fields. + let mut ad_type_bytes = vec![0; 6]; + cursor.read_exact(&mut ad_type_bytes).await?; + let ad_type = binary_to_string(ad_type_bytes); + + let mut keyword_bytes = vec![0; 40]; + cursor.read_exact(&mut keyword_bytes).await?; + let keyword = binary_to_string(keyword_bytes); + + let mut displayed_bytes = vec![0; 100]; + cursor.read_exact(&mut displayed_bytes).await?; + let displayed = binary_to_string(displayed_bytes); + + // Decode impression/click counts and their configured values. + let impression = cursor.read_u8().await?; + let click = cursor.read_u8().await?; + let impression_value = cursor.read_u16_le().await?; + let click_value = cursor.read_u16_le().await?; + + // Decode advertiser short address, fee, and signature. + let mut advertiser_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut advertiser_bytes).await?; + let advertiser = Wallet::bytes_to_short_address(&advertiser_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid advertiser short address bytes", + ) + })?; + + let txfee = cursor.read_u64_le().await?; + let mut signature_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature_bytes).await?; + let signature = encode(&signature_bytes); + + // Rebuild the unsigned marketing payload. + let unsigned_marketing = UnsignedMarketingTransaction { + txtype, + time, + campaign, + ad_type, + keyword, + displayed, + impression, + click, + impression_value, + click_value, + advertiser, + txfee, + }; + + // Wrap the rebuilt unsigned payload and signature. + Ok(MarketingTransaction { + unsigned_marketing, + signature, + }) + } + + pub async fn add_to_memory(&self) -> Result<(), Box> { + // Store original bytes so the mempool can rebuild the exact + // transaction submitted by the wallet. + let original_data = self.to_bytes().await?; + + // PostgreSQL uses signed integer types for these persisted fields. + let time = self.unsigned_marketing.time as i32; + let fee = i64::try_from(self.unsigned_marketing.txfee) + .map_err(|_| std::io::Error::other("Marketing fee exceeds i64 mempool limit"))?; + let advertiser = &self.unsigned_marketing.advertiser; + let hash = &self.unsigned_marketing.hash().await; + let signature = &self.signature; + + // Marketing transactions remain in the mempool table until mined or removed. + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO marketing ( + time, + fee, + advertiser, + hash, + signature, + original + ) VALUES ($1, $2, $3, $4, $5, $6) + "#, + &[&time, &fee, advertiser, hash, signature, &original_data], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/mod.rs b/src/blocks/mod.rs new file mode 100644 index 0000000..51f661f --- /dev/null +++ b/src/blocks/mod.rs @@ -0,0 +1,14 @@ +pub mod block; +pub mod burn; +pub mod collateral; +pub mod genesis; +pub mod issue_token; +pub mod loan_payment; +pub mod loans; +pub mod marketing; +pub mod nft; +pub mod rewards; +pub mod swap; +pub mod token; +pub mod transfer; +pub mod vanity; diff --git a/src/blocks/nft.rs b/src/blocks/nft.rs new file mode 100644 index 0000000..e01bef7 --- /dev/null +++ b/src/blocks/nft.rs @@ -0,0 +1,263 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 255 bytes +pub struct UnsignedCreateNftTransaction { + pub txtype: u8, // 1 byte transaction type, should be 4 + pub time: u32, // 4 bytes transaction timestamp + pub creator: String, // 22 bytes creator short address + pub series: u8, // 1 byte 0 for single NFT, 1 for series + pub nft_name: String, // 15 bytes NFT or collection name padded with spaces + pub item_ipfs: String, // 100 bytes padded CID string + pub count: u32, // 4 bytes 1 for single NFT, otherwise series item count + pub desc: String, // 100 bytes description padded with spaces + pub txfee: u64, // 8 bytes transaction fee +} + +#[derive(Debug, Serialize, Clone)] // 921 bytes +pub struct CreateNftTransaction { + pub unsigned_create_nft: UnsignedCreateNftTransaction, // 255 bytes + pub signature: String, // 666 bytes signature of hash +} + +impl CreateNftTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 1 + 15 + 100 + 4 + 100 + 8 + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedCreateNftTransaction { + // Create an unsigned NFT-creation transaction. + #[allow(clippy::too_many_arguments)] + pub async fn new( + txtype: u8, + time: u32, + creator: &str, + series: u8, + nft_name: &str, + item_ipfs: &str, + count: u32, + desc: &str, + txfee: u64, + ) -> Self { + Self { + txtype, + time, + creator: creator.to_string(), + series, + nft_name: nft_name.to_string(), + item_ipfs: item_ipfs.to_string(), + count, + desc: desc.to_string(), + txfee, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result> { + // Serialize the unsigned transaction before hashing. + let serialized = to_string(self)?; + + // Hash the serialized unsigned payload. + let hash = skein_256_hash_data(&serialized); + + // Sign the transaction hash with the creator wallet. + let signature = Wallet::sign_transaction(&hash, private_key).await; + + Ok(signature) + } +} + +impl CreateNftTransaction { + // Create a signed NFT-creation transaction. + pub async fn new( + unsigned_create_nft: UnsignedCreateNftTransaction, + private_key: &str, + ) -> Result> { + // Hash and sign the unsigned transaction. + let signature = unsigned_create_nft.hash_and_sign(private_key).await?; + + // Return the complete NFT-creation transaction. + Ok(Self { + unsigned_create_nft, + signature, + }) + } + + // Load an existing NFT-creation transaction. + pub async fn load(unsigned_create_nft: UnsignedCreateNftTransaction, signature: &str) -> Self { + Self { + unsigned_create_nft, + signature: signature.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed NFT-creation transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_create_nft.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_create_nft.time.to_le_bytes()) + .await?; + let creator_bytes = Wallet::short_address_to_bytes(&self.unsigned_create_nft.creator) + .ok_or_else(|| { + tokio::io::Error::other("Invalid creator short address") + })?; + cursor.write_all(&creator_bytes).await?; + cursor + .write_all(&self.unsigned_create_nft.series.to_le_bytes()) + .await?; + cursor + .write_all(self.unsigned_create_nft.nft_name.as_bytes()) + .await?; + cursor + .write_all(self.unsigned_create_nft.item_ipfs.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_create_nft.count.to_le_bytes()) + .await?; + cursor + .write_all(self.unsigned_create_nft.desc.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_create_nft.txfee.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.signature).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read the remaining fixed-width NFT-creation bytes. + let mut cursor = Cursor::new(bytes); + + // Decode timestamp and creator short address. + let time = cursor.read_u32_le().await?; + + let mut creator_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut creator_bytes).await?; + let creator = Wallet::bytes_to_short_address(&creator_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid creator short address bytes", + ) + })?; + + // Decode series flag, name, CID, count, description, fee, and signature. + let series = cursor.read_u8().await?; + + let mut nft_name_bytes = vec![0; 15]; + cursor.read_exact(&mut nft_name_bytes).await?; + let nft_name = binary_to_string(nft_name_bytes); + + let mut item_ipfs_bytes = vec![0; 100]; + cursor.read_exact(&mut item_ipfs_bytes).await?; + let item_ipfs = binary_to_string(item_ipfs_bytes); + + let count = cursor.read_u32_le().await?; + + let mut desc_bytes = vec![0; 100]; + cursor.read_exact(&mut desc_bytes).await?; + let desc = binary_to_string(desc_bytes); + + let txfee = cursor.read_u64_le().await?; + + let mut signature_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature_bytes).await?; + let signature = encode(&signature_bytes); + + // Rebuild the unsigned NFT-creation payload. + let unsigned_create_nft = UnsignedCreateNftTransaction { + txtype, + time, + creator, + series, + nft_name, + item_ipfs, + count, + desc, + txfee, + }; + + // Wrap the rebuilt unsigned payload and signature. + Ok(CreateNftTransaction { + unsigned_create_nft, + signature, + }) + } + + pub async fn add_to_memory(&self) -> Result<(), Box> { + // Store original bytes so the mempool can rebuild the exact + // transaction submitted by the wallet. + let original_data = self.to_bytes().await?; + + // PostgreSQL uses signed integer types for these persisted fields. + let time = self.unsigned_create_nft.time as i32; + let fee = i64::try_from(self.unsigned_create_nft.txfee) + .map_err(|_| std::io::Error::other("NFT fee exceeds i64 mempool limit"))?; + let series = self.unsigned_create_nft.series as i16; + let count = i64::from(self.unsigned_create_nft.count); + + let creator = &self.unsigned_create_nft.creator; + let nft_name = &self.unsigned_create_nft.nft_name; + let hash = &self.unsigned_create_nft.hash().await; + let signature = &self.signature; + + // NFT-creation transactions remain in the mempool table until mined or removed. + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO nft ( + fee, + time, + creator, + nft_name, + series, + count, + hash, + signature, + original + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + &[ + &fee, + &time, + creator, + nft_name, + &series, + &count, + hash, + signature, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/rewards.rs b/src/blocks/rewards.rs new file mode 100644 index 0000000..9b8175b --- /dev/null +++ b/src/blocks/rewards.rs @@ -0,0 +1,89 @@ +use crate::common::skein::skein_256_hash_data; +use crate::to_string; +use crate::Cursor; +use crate::Serialize; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 13 bytes +pub struct UnsignedRewardsTransaction { + pub txtype: u8, // 1 byte transaction type, should be 1 + pub timestamp: u32, // 4 bytes timestamp used in the reward payload + pub value: u64, // 8 bytes reward value +} + +#[derive(Debug, Serialize, Clone)] +pub struct RewardsTransaction { + // 13 bytes + pub unsigned: UnsignedRewardsTransaction, // 13 bytes consensus-created reward data +} + +impl UnsignedRewardsTransaction { + // Create an unsigned reward transaction for a mined block. + pub async fn new(txtype: u8, timestamp: u32, value: u64) -> Self { + Self { + txtype, + timestamp, + value, + } + } + + // Hash the unsigned reward data for transaction indexing. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } +} + +impl RewardsTransaction { + pub const BYTE_LENGTH: usize = 1 + 4 + 8; + + pub async fn new(unsigned: UnsignedRewardsTransaction) -> Self { + // Rewards are not wallet-signed, so the final transaction + // only wraps the consensus-created unsigned data. + Self { unsigned } + } + + pub async fn load(unsigned: UnsignedRewardsTransaction) -> Self { + // Rebuild a reward transaction already read from storage. + Self { unsigned } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed reward layout: type, timestamp, value. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + cursor + .write_all(&self.unsigned.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned.timestamp.to_le_bytes()) + .await?; + cursor.write_all(&self.unsigned.value.to_le_bytes()).await?; + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read the remaining fixed reward bytes. + let mut cursor = Cursor::new(bytes); + + // Decode timestamp and reward value in the same order they were written. + let timestamp = cursor.read_u32_le().await?; + let value = cursor.read_u64_le().await?; + + // Rebuild the unsigned reward payload. + let unsigned = UnsignedRewardsTransaction { + txtype, + timestamp, + value, + }; + + // Wrap the rebuilt unsigned reward payload. + Ok(RewardsTransaction { unsigned }) + } +} diff --git a/src/blocks/swap.rs b/src/blocks/swap.rs new file mode 100644 index 0000000..d800c0c --- /dev/null +++ b/src/blocks/swap.rs @@ -0,0 +1,377 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 139 bytes +pub struct UnsignedSwapTransaction { + pub txtype: u8, // 1 byte transaction type, should be 6 + pub timestamp: u32, // 4 bytes offer creation timestamp + pub offer_expiration: u32, // 4 bytes offer expiration timestamp + pub ticker1: String, // 15 bytes asset offered by sender1 + pub nft_series1: u32, // 4 bytes 0 for fungible assets, otherwise NFT series item + pub value1: u64, // 8 bytes amount offered by sender1 + pub ticker2: String, // 15 bytes asset offered by sender2 + pub nft_series2: u32, // 4 bytes 0 for fungible assets, otherwise NFT series item + pub value2: u64, // 8 bytes amount offered by sender2 + pub sender1: String, // 22 bytes sender1 short address + pub sender2: String, // 22 bytes sender2 short address + pub tip1: u64, // 8 bytes miner tip paid in ticker1 + pub tip2: u64, // 8 bytes miner tip paid in ticker2 + pub txfee1: u64, // 8 bytes sender1 fee + pub txfee2: u64, // 8 bytes sender2 fee +} + +#[derive(Debug, Serialize, Clone)] // 1471 bytes +pub struct SwapTransaction { + pub unsigned_swap: UnsignedSwapTransaction, // 139 bytes + pub signature1: String, // 666 bytes The signature of sender1 for the hash + pub signature2: String, // 666 bytes The signature of sender2 for the hash +} + +impl SwapTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + 4 + 15 + 4 + 8 + 15 + 4 + 8 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + Wallet::SHORT_ADDRESS_BYTES_LENGTH + + 8 + 8 + 8 + 8 + Wallet::SIGNATURE_LENGTH + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedSwapTransaction { + // Create an unsigned swap transaction. + #[allow(clippy::too_many_arguments)] + pub async fn new( + txtype: u8, + timestamp: u32, + offer_expiration: u32, + ticker1: &str, + nft_series1: u32, + value1: u64, + ticker2: &str, + nft_series2: u32, + value2: u64, + sender1: &str, + sender2: &str, + tip1: u64, + tip2: u64, + txfee1: u64, + txfee2: u64, + ) -> Self { + Self { + txtype, + timestamp, + offer_expiration, + ticker1: ticker1.to_string(), + nft_series1, + value1, + ticker2: ticker2.to_string(), + nft_series2, + value2, + sender1: sender1.to_string(), + sender2: sender2.to_string(), + tip1, + tip2, + txfee1, + txfee2, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result> { + // Serialize the unsigned transaction before hashing. + let serialized = to_string(self)?; + + // Hash the serialized unsigned payload. + let hash = skein_256_hash_data(&serialized); + + // Sign the transaction hash with the current signer wallet. + let signature = Wallet::sign_transaction(&hash, private_key).await; + + Ok(signature) + } +} + +impl SwapTransaction { + // Add the next required signature to a swap transaction. + pub async fn new( + &self, + unsigned_swap: UnsignedSwapTransaction, + private_key: &str, + ) -> Result> { + // Hash and sign the unsigned transaction. + let signature = unsigned_swap.hash_and_sign(private_key).await?; + + // The first signer fills signature1, and the second signer fills signature2. + let (signed1, signed2) = if self.signature1.is_empty() { + let signed1 = signature; + let signed2 = "".to_string(); + (signed1, signed2) + } else { + let signed2 = signature; + let signed1 = self.signature1.clone(); + (signed1, signed2) + }; + + // Return the swap with the newest signature included. + Ok(Self { + unsigned_swap, + signature1: signed1, + signature2: signed2, + }) + } + + // Load an existing swap transaction. + pub async fn load( + unsigned_swap: UnsignedSwapTransaction, + signature1: &str, + signature2: &str, + ) -> Self { + Self { + unsigned_swap, + signature1: signature1.to_string(), + signature2: signature2.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed swap transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_swap.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_swap.timestamp.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_swap.offer_expiration.to_le_bytes()) + .await?; + cursor + .write_all(self.unsigned_swap.ticker1.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_swap.nft_series1.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_swap.value1.to_le_bytes()) + .await?; + cursor + .write_all(self.unsigned_swap.ticker2.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_swap.nft_series2.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_swap.value2.to_le_bytes()) + .await?; + let sender1_bytes = Wallet::short_address_to_bytes(&self.unsigned_swap.sender1) + .ok_or_else(|| { + tokio::io::Error::other("Invalid sender1 short address") + })?; + let sender2_bytes = Wallet::short_address_to_bytes(&self.unsigned_swap.sender2) + .ok_or_else(|| { + tokio::io::Error::other("Invalid sender2 short address") + })?; + cursor.write_all(&sender1_bytes).await?; + cursor.write_all(&sender2_bytes).await?; + cursor + .write_all(&self.unsigned_swap.tip1.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_swap.tip2.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_swap.txfee1.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_swap.txfee2.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.signature1).unwrap()).await?; + cursor.write_all(&decode(&self.signature2).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read the remaining fixed-width swap bytes. + let mut cursor = Cursor::new(bytes); + + // Decode offer timing. + let timestamp = cursor.read_u32_le().await?; + let offer_expiration = cursor.read_u32_le().await?; + + // Decode sender1 asset, series, and value. + let mut ticker1_bytes = vec![0; 15]; + cursor.read_exact(&mut ticker1_bytes).await?; + let ticker1 = binary_to_string(ticker1_bytes); + + let nft_series1 = cursor.read_u32_le().await?; + let value1 = cursor.read_u64_le().await?; + + // Decode sender2 asset, series, and value. + let mut ticker2_bytes = vec![0; 15]; + cursor.read_exact(&mut ticker2_bytes).await?; + let ticker2 = binary_to_string(ticker2_bytes); + + let nft_series2 = cursor.read_u32_le().await?; + let value2 = cursor.read_u64_le().await?; + + // Decode both sender short addresses. + let mut sender1_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut sender1_bytes).await?; + let sender1 = Wallet::bytes_to_short_address(&sender1_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid sender1 short address bytes", + ) + })?; + + let mut sender2_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut sender2_bytes).await?; + let sender2 = Wallet::bytes_to_short_address(&sender2_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid sender2 short address bytes", + ) + })?; + + // Decode miner tips, fees, and both signatures. + let tip1 = cursor.read_u64_le().await?; + let tip2 = cursor.read_u64_le().await?; + let txfee1 = cursor.read_u64_le().await?; + let txfee2 = cursor.read_u64_le().await?; + + let mut signature1_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature1_bytes).await?; + let signature1 = encode(&signature1_bytes); + + let mut signature2_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature2_bytes).await?; + let signature2 = encode(&signature2_bytes); + + // Rebuild the unsigned swap payload. + let unsigned_swap = UnsignedSwapTransaction { + txtype, + timestamp, + offer_expiration, + ticker1, + nft_series1, + value1, + ticker2, + nft_series2, + value2, + sender1, + sender2, + tip1, + tip2, + txfee1, + txfee2, + }; + + // Wrap the rebuilt unsigned payload and signatures. + Ok(SwapTransaction { + unsigned_swap, + signature1, + signature2, + }) + } + + pub async fn add_to_memory(&self) -> Result<(), Box> { + // Store original bytes so the mempool can rebuild the exact + // transaction submitted by the wallets. + let original_data = self.to_bytes().await?; + + // PostgreSQL uses signed integer types for these persisted fields. + let fee1 = i64::try_from(self.unsigned_swap.txfee1) + .map_err(|_| std::io::Error::other("Swap fee1 exceeds i64 mempool limit"))?; + let fee2 = i64::try_from(self.unsigned_swap.txfee2) + .map_err(|_| std::io::Error::other("Swap fee2 exceeds i64 mempool limit"))?; + let timestamp = self.unsigned_swap.timestamp as i32; + let value1 = i64::try_from(self.unsigned_swap.value1) + .map_err(|_| std::io::Error::other("Swap value1 exceeds i64 mempool limit"))?; + let value2 = i64::try_from(self.unsigned_swap.value2) + .map_err(|_| std::io::Error::other("Swap value2 exceeds i64 mempool limit"))?; + let nft_series1 = self.unsigned_swap.nft_series1 as i32; + let nft_series2 = self.unsigned_swap.nft_series2 as i32; + let tip1 = i64::try_from(self.unsigned_swap.tip1) + .map_err(|_| std::io::Error::other("Swap tip1 exceeds i64 mempool limit"))?; + let tip2 = i64::try_from(self.unsigned_swap.tip2) + .map_err(|_| std::io::Error::other("Swap tip2 exceeds i64 mempool limit"))?; + + let ticker1 = &self.unsigned_swap.ticker1; + let ticker2 = &self.unsigned_swap.ticker2; + let hash = &self.unsigned_swap.hash().await; + let sender1 = &self.unsigned_swap.sender1; + let sender2 = &self.unsigned_swap.sender2; + let signature1 = &self.signature1; + let signature2 = &self.signature2; + + // Swap transactions remain in the mempool table until mined or removed. + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO swap ( + fee1, + fee2, + time, + ticker1, + nft_series1, + ticker2, + nft_series2, + value1, + value2, + sender1, + tip1, + tip2, + sender2, + hash, + signature1, + signature2, + original + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, $14, $15, $16, $17 + ) + "#, + &[ + &fee1, + &fee2, + ×tamp, + ticker1, + &nft_series1, + ticker2, + &nft_series2, + &value1, + &value2, + sender1, + &tip1, + &tip2, + sender2, + hash, + signature1, + signature2, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/token.rs b/src/blocks/token.rs new file mode 100644 index 0000000..2fc1d3d --- /dev/null +++ b/src/blocks/token.rs @@ -0,0 +1,245 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 59 bytes +pub struct UnsignedCreateTokenTransaction { + pub txtype: u8, // 1 byte transaction type, should be 3 + pub time: u32, // 4 bytes transaction timestamp + pub creator: String, // 22 bytes creator short address + pub ticker: String, // 15 bytes token ticker padded with spaces + pub number: u64, // 8 bytes number of tokens to create + pub hard_limit: u8, // 1 byte 1 means capped forever, 0 allows future issuance + pub txfee: u64, // 8 bytes transaction fee +} + +#[derive(Debug, Serialize, Clone)] // 725 bytes +pub struct CreateTokenTransaction { + pub unsigned_create_token: UnsignedCreateTokenTransaction, // 59 bytes + pub signature: String, // 666 bytes signature of hash +} + +impl CreateTokenTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 15 + 8 + 1 + 8 + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedCreateTokenTransaction { + // Create an unsigned token-creation transaction. + pub async fn new( + txtype: u8, + time: u32, + creator: &str, + ticker: &str, + number: u64, + hard_limit: u8, + txfee: u64, + ) -> Self { + Self { + txtype, + time, + creator: creator.to_string(), + ticker: ticker.to_string(), + number, + hard_limit, + txfee, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result> { + // Serialize the unsigned transaction before hashing. + let serialized = to_string(self)?; + + // Hash the serialized unsigned payload. + let hash = skein_256_hash_data(&serialized); + + // Sign the transaction hash with the creator wallet. + let signature = Wallet::sign_transaction(&hash, private_key).await; + + Ok(signature) + } +} + +impl CreateTokenTransaction { + // Create a signed token-creation transaction. + pub async fn new( + unsigned_create_token: UnsignedCreateTokenTransaction, + private_key: &str, + ) -> Result> { + // Hash and sign the unsigned transaction. + let signature = unsigned_create_token.hash_and_sign(private_key).await?; + + // Return the complete token-creation transaction. + Ok(Self { + unsigned_create_token, + signature, + }) + } + + // Load an existing token-creation transaction. + pub async fn load( + unsigned_create_token: UnsignedCreateTokenTransaction, + signature: &str, + ) -> Self { + Self { + unsigned_create_token, + signature: signature.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed token-creation transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_create_token.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_create_token.time.to_le_bytes()) + .await?; + let creator_bytes = Wallet::short_address_to_bytes(&self.unsigned_create_token.creator) + .ok_or_else(|| { + tokio::io::Error::other("Invalid creator short address") + })?; + cursor.write_all(&creator_bytes).await?; + cursor + .write_all(self.unsigned_create_token.ticker.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_create_token.number.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_create_token.hard_limit.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_create_token.txfee.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.signature).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read the remaining fixed-width token-creation bytes. + let mut cursor = Cursor::new(bytes); + + // Decode timestamp. + let time = cursor.read_u32_le().await?; + + // Decode the creator short address. + let mut creator_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut creator_bytes).await?; + let creator = Wallet::bytes_to_short_address(&creator_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid creator short address bytes", + ) + })?; + + // Decode the fixed 15-byte token ticker. + let mut ticker_bytes = vec![0; 15]; + cursor.read_exact(&mut ticker_bytes).await?; + let ticker = binary_to_string(ticker_bytes); + + // Decode token supply, hard-limit flag, and fee. + let number = cursor.read_u64_le().await?; + let hard_limit = cursor.read_u8().await?; + let txfee = cursor.read_u64_le().await?; + + // Decode the Falcon signature. + let mut signature_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature_bytes).await?; + let signature = encode(&signature_bytes); + + // Rebuild the unsigned token-creation payload. + let unsigned_create_token = UnsignedCreateTokenTransaction { + txtype, + time, + creator, + ticker, + number, + hard_limit, + txfee, + }; + + // Wrap the rebuilt unsigned payload and signature. + Ok(CreateTokenTransaction { + unsigned_create_token, + signature, + }) + } + + pub async fn add_to_memory(&self) -> Result<(), Box> { + // Store original bytes so the mempool can rebuild the exact + // transaction submitted by the wallet. + let original_data = self.to_bytes().await?; + + // PostgreSQL uses signed integer types for these persisted fields. + let time = self.unsigned_create_token.time as i32; + let fee = i64::try_from(self.unsigned_create_token.txfee) + .map_err(|_| std::io::Error::other("Create-token fee exceeds i64 mempool limit"))?; + let number = i64::try_from(self.unsigned_create_token.number) + .map_err(|_| std::io::Error::other("Create-token amount exceeds i64 mempool limit"))?; + let hard_limit = self.unsigned_create_token.hard_limit as i16; + + let creator = &self.unsigned_create_token.creator; + let ticker = &self.unsigned_create_token.ticker; + let hash = &self.unsigned_create_token.hash().await; + let signature = &self.signature; + + // Token-creation transactions remain in the mempool table until mined or removed. + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO token ( + time, + fee, + creator, + number, + hard_limit, + ticker, + hash, + signature, + original + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + &[ + &time, + &fee, + creator, + &number, + &hard_limit, + ticker, + hash, + signature, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/transfer.rs b/src/blocks/transfer.rs new file mode 100644 index 0000000..e94cb67 --- /dev/null +++ b/src/blocks/transfer.rs @@ -0,0 +1,267 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; +use anyhow::{anyhow, Result}; + +#[derive(Debug, Serialize, Clone)] // 84 bytes +pub struct UnsignedTransferTransaction { + pub txtype: u8, // 1 byte transaction type, should be 2 + pub time: u32, // 4 bytes transaction timestamp + pub value: u64, // 8 bytes number of coins or tokens to send + pub coin: String, // 15 bytes base coin, token ticker, or NFT name, padded with spaces + pub nft_series: u32, // 4 bytes 0 for coins/tokens/1-of-1 NFTs, otherwise NFT series item + pub sender: String, // 22 bytes sender short address + pub receiver: String, // 22 bytes receiver short address + pub txfee: u64, // 8 bytes transaction fee +} + +#[derive(Debug, Serialize, Clone)] // 750 bytes +pub struct TransferTransaction { + pub unsigned_transfer: UnsignedTransferTransaction, // 84 bytes + pub signature: String, // 666 bytes signature +} + +impl TransferTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + 8 + 15 + 4 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 8 + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedTransferTransaction { + // Create an unsigned transfer transaction. + #[allow(clippy::too_many_arguments)] + pub async fn new( + txtype: u8, + time: u32, + value: u64, + coin: &str, + nft_series: u32, + sender: &str, + receiver: &str, + txfee: u64, + ) -> Self { + Self { + txtype, + time, + value, + coin: coin.to_string(), + nft_series, + sender: sender.to_string(), + receiver: receiver.to_string(), + txfee, + } + } + + // Hash without signing for verification. + pub async fn hash(&self) -> String { + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + // Hash the unsigned transaction and sign it. + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result> { + // Serialize the unsigned transaction before hashing. + let serialized = to_string(self)?; + + // Hash the serialized unsigned payload. + let hash = skein_256_hash_data(&serialized); + + // Sign the transaction hash with the sender wallet. + let signature = Wallet::sign_transaction(&hash, private_key).await; + + Ok(signature) + } +} + +impl TransferTransaction { + // Create a signed transfer transaction. + pub async fn new( + unsigned_transfer: UnsignedTransferTransaction, + private_key: &str, + ) -> Result> { + // Hash and sign the unsigned transaction. + let signature = unsigned_transfer.hash_and_sign(private_key).await?; + + // Return the complete transfer transaction. + Ok(Self { + unsigned_transfer, + signature, + }) + } + + // Load an existing transfer transaction. + pub async fn load(unsigned_transfer: UnsignedTransferTransaction, signature: &str) -> Self { + Self { + unsigned_transfer, + signature: signature.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed transfer transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_transfer.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_transfer.time.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_transfer.value.to_le_bytes()) + .await?; + cursor + .write_all(self.unsigned_transfer.coin.as_bytes()) + .await?; + cursor + .write_all(&self.unsigned_transfer.nft_series.to_le_bytes()) + .await?; + let sender_bytes = Wallet::short_address_to_bytes(&self.unsigned_transfer.sender) + .ok_or_else(|| { + tokio::io::Error::other("Invalid sender short address") + })?; + let receiver_bytes = Wallet::short_address_to_bytes(&self.unsigned_transfer.receiver) + .ok_or_else(|| { + tokio::io::Error::other("Invalid receiver short address", + ) + })?; + cursor.write_all(&sender_bytes).await?; + cursor.write_all(&receiver_bytes).await?; + cursor + .write_all(&self.unsigned_transfer.txfee.to_le_bytes()) + .await?; + cursor.write_all(&decode(&self.signature).unwrap()).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count", + )); + } + + // Read the remaining fixed-width transfer bytes. + let mut cursor = Cursor::new(bytes); + + // Decode timestamp and transfer amount. + let time = cursor.read_u32_le().await?; + let value = cursor.read_u64_le().await?; + + // Decode the fixed 15-byte coin/token/NFT field. + let mut coin_bytes = vec![0; 15]; + cursor.read_exact(&mut coin_bytes).await?; + let coin = binary_to_string(coin_bytes); + + let nft_series = cursor.read_u32_le().await?; + + // Decode the sender short address. + let mut sender_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut sender_bytes).await?; + let sender = Wallet::bytes_to_short_address(&sender_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid sender short address bytes", + ) + })?; + + // Decode the receiver short address. + let mut receiver_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut receiver_bytes).await?; + let receiver = Wallet::bytes_to_short_address(&receiver_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid receiver short address bytes", + ) + })?; + + // Decode fee and signature. + let txfee = cursor.read_u64_le().await?; + let mut signature_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature_bytes).await?; + let signature = encode(&signature_bytes); + + // Rebuild the unsigned transfer payload. + let unsigned_transfer = UnsignedTransferTransaction { + txtype, + time, + value, + coin, + nft_series, + sender, + receiver, + txfee, + }; + + // Wrap the rebuilt unsigned payload and signature. + Ok(TransferTransaction { + unsigned_transfer, + signature, + }) + } + + pub async fn add_to_memory(&self) -> Result<()> { + // Store original bytes so the mempool can rebuild the exact + // transaction submitted by the wallet. + let original_data = self + .to_bytes() + .await + .map_err(|_| anyhow!("Failed to serialize transaction"))?; + + // PostgreSQL uses signed integer types, so reject values that cannot + // fit instead of wrapping them into negative mempool amounts. + let fee = i64::try_from(self.unsigned_transfer.txfee) + .map_err(|_| std::io::Error::other("Transfer fee exceeds i64 mempool limit"))?; + let time = self.unsigned_transfer.time as i32; + let value = i64::try_from(self.unsigned_transfer.value) + .map_err(|_| std::io::Error::other("Transfer value exceeds i64 mempool limit"))?; + let nft_series = self.unsigned_transfer.nft_series as i32; + let sender = &self.unsigned_transfer.sender; + let coin = &self.unsigned_transfer.coin; + let receiver = &self.unsigned_transfer.receiver; + let hash = &self.unsigned_transfer.hash().await; + let signature = &self.signature; + + // Transfer transactions remain in the mempool table until mined or removed. + let client = DB.get().expect("DB not initialized"); + + client + .execute( + r#" + INSERT INTO transfer ( + time, + fee, + sender, + value, + coin, + nft_series, + receiver, + hash, + signature, + original + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + &[ + &time, + &fee, + sender, + &value, + coin, + &nft_series, + receiver, + &hash, + signature, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/blocks/vanity.rs b/src/blocks/vanity.rs new file mode 100644 index 0000000..73a67e0 --- /dev/null +++ b/src/blocks/vanity.rs @@ -0,0 +1,216 @@ +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::mempool::DB; +use crate::to_string; +use crate::wallets::structures::Wallet; +use crate::Cursor; +use crate::Serialize; +use crate::{decode, encode}; +use crate::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Serialize, Clone)] // 57 bytes +pub struct UnsignedVanityAddressTransaction { + pub txtype: u8, // 1 byte transaction type, should be 12 + pub timestamp: u32, // 4 bytes transaction timestamp + pub address: String, // 22 bytes real short address receiving the vanity mapping + pub vanity_address: String, // 22 bytes vanity short address being registered + pub txfee: u64, // 8 bytes fee paid for vanity registration +} + +#[derive(Debug, Serialize, Clone)] // 723 bytes +pub struct VanityAddressTransaction { + pub unsigned_vanity_address: UnsignedVanityAddressTransaction, // 57 bytes + pub signature: String, // 666 bytes +} + +impl VanityAddressTransaction { + pub const BYTE_LENGTH: usize = + 1 + 4 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 8 + Wallet::SIGNATURE_LENGTH; +} + +impl UnsignedVanityAddressTransaction { + // Create the unsigned vanity-address registration payload. + pub async fn new( + txtype: u8, + timestamp: u32, + address: &str, + vanity_address: &str, + txfee: u64, + ) -> Self { + Self { + txtype, + timestamp, + address: address.to_string(), + vanity_address: vanity_address.to_string(), + txfee, + } + } + + pub async fn hash(&self) -> String { + // Hash the unsigned payload for verification and mempool indexing. + let serialized = to_string(self).unwrap(); + skein_256_hash_data(&serialized) + } + + pub async fn hash_and_sign( + &self, + private_key: &str, + ) -> Result> { + // Sign the unsigned vanity transaction with the registering wallet. + let serialized = to_string(self)?; + let hash = skein_256_hash_data(&serialized); + let signature = Wallet::sign_transaction(&hash, private_key).await; + Ok(signature) + } +} + +impl VanityAddressTransaction { + pub async fn new( + unsigned_vanity_address: UnsignedVanityAddressTransaction, + private_key: &str, + ) -> Result> { + // Create a signed vanity-address transaction. + let signature = unsigned_vanity_address.hash_and_sign(private_key).await?; + Ok(Self { + unsigned_vanity_address, + signature, + }) + } + + pub async fn load( + unsigned_vanity_address: UnsignedVanityAddressTransaction, + signature: &str, + ) -> Self { + // Rebuild a vanity transaction already read from storage. + Self { + unsigned_vanity_address, + signature: signature.to_string(), + } + } + + pub async fn to_bytes(&self) -> tokio::io::Result> { + // Serialize into the fixed vanity transaction byte layout. + let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); + let mut cursor = Cursor::new(&mut buffer); + + cursor + .write_all(&self.unsigned_vanity_address.txtype.to_le_bytes()) + .await?; + cursor + .write_all(&self.unsigned_vanity_address.timestamp.to_le_bytes()) + .await?; + + let address_bytes = Wallet::short_address_to_bytes(&self.unsigned_vanity_address.address) + .ok_or_else(|| { + tokio::io::Error::other("Invalid sender short address") + })?; + // Vanity addresses use the same 22-byte width as short addresses + // but are encoded through the vanity-specific byte conversion. + let vanity_bytes = + Wallet::vanity_address_to_bytes(&self.unsigned_vanity_address.vanity_address) + .ok_or_else(|| tokio::io::Error::other("Invalid vanity short address"))?; + + cursor.write_all(&address_bytes).await?; + cursor.write_all(&vanity_bytes).await?; + cursor + .write_all(&self.unsigned_vanity_address.txfee.to_le_bytes()) + .await?; + let signature_bytes = decode(&self.signature).map_err(|err| { + tokio::io::Error::other(format!("Invalid vanity signature hex: {err}")) + })?; + cursor.write_all(&signature_bytes).await?; + + Ok(buffer) + } + + pub async fn from_bytes(txtype: u8, bytes: &[u8]) -> tokio::io::Result { + // The block parser already consumed the transaction type byte. + if bytes.len() != Self::BYTE_LENGTH - 1 { + return Err(tokio::io::Error::other("Invalid Byte Count")); + } + + let mut cursor = Cursor::new(bytes); + + // Decode timestamp, real short address, vanity address, fee, and signature. + let timestamp = cursor.read_u32_le().await?; + + let mut address_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut address_bytes).await?; + let address = Wallet::bytes_to_short_address(&address_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid sender short address bytes") + })?; + + let mut vanity_bytes = vec![0; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + cursor.read_exact(&mut vanity_bytes).await?; + let vanity_address = Wallet::bytes_to_vanity_address(&vanity_bytes).ok_or_else(|| { + tokio::io::Error::other("Invalid vanity short address bytes") + })?; + + let txfee = cursor.read_u64_le().await?; + + let mut signature_bytes = vec![0; Wallet::SIGNATURE_LENGTH]; + cursor.read_exact(&mut signature_bytes).await?; + let signature = encode(&signature_bytes); + + let unsigned_vanity_address = UnsignedVanityAddressTransaction { + txtype, + timestamp, + address, + vanity_address, + txfee, + }; + + Ok(Self { + unsigned_vanity_address, + signature, + }) + } + + pub async fn add_to_memory(&self) -> Result<(), Box> { + // Store the original bytes so the mempool can later rebuild the + // exact transaction submitted by the wallet. + let original_data = self.to_bytes().await?; + + // PostgreSQL uses signed integer types for these persisted fields. + let time = self.unsigned_vanity_address.timestamp as i32; + let fee = i64::try_from(self.unsigned_vanity_address.txfee) + .map_err(|_| std::io::Error::other("Vanity fee exceeds i64 mempool limit"))?; + let address = &self.unsigned_vanity_address.address; + let vanity_address = &self.unsigned_vanity_address.vanity_address; + let hash = &self.unsigned_vanity_address.hash().await; + let signature = &self.signature; + + // Vanity transactions are written to the vanity mempool table + // until the transaction is mined or removed. + let client = DB.get().ok_or_else(|| { + Box::new(std::io::Error::other("DB not initialized")) + as Box + })?; + + client + .execute( + r#" + INSERT INTO vanity_address ( + time, + fee, + address, + vanity_address, + hash, + signature, + original + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + &[ + &time, + &fee, + address, + vanity_address, + hash, + signature, + &original_data, + ], + ) + .await?; + + Ok(()) + } +} diff --git a/src/common/binary_conversions.rs b/src/common/binary_conversions.rs new file mode 100644 index 0000000..ab1baa8 --- /dev/null +++ b/src/common/binary_conversions.rs @@ -0,0 +1,136 @@ +use crate::IpAddr; +use crate::Ipv6Addr; + +// Convert a 256-bit hex hash into the reduced u64 value used by +// the mining difficulty comparison. +pub async fn hex_to_u64(hex_string: &str) -> Result { + // The input must be one full 32-byte hash encoded as 64 hex characters. + if hex_string.len() != 64 { + return Err("Invalid hash length. Must be 64 characters.".to_string()); + } + let mut extracted_string = String::new(); + + // Sample across the whole hash instead of using only the front bytes. + for i in (0..64).step_by(4) { + extracted_string.push(hex_string.chars().nth(i).unwrap()); + } + + // Interpret the sampled hex characters as the mining comparison value. + if let Ok(value) = u64::from_str_radix(&extracted_string, 16) { + Ok(value) + } else { + Err("Failed to convert to u64.".to_string()) + } +} + +// Convert raw bytes into UTF-8 text for command payloads that are +// expected to be plain strings. +pub fn binary_to_string(binary_data: Vec) -> String { + match String::from_utf8(binary_data.clone()) { + Ok(s) => s, + Err(_) => { + "Invalid UTF-8 Data".to_string() + } + } +} + +pub fn ip_to_binary(ip_str: &str) -> Vec { + // Parse the IP string into the standard IP enum before encoding it. + match ip_str.parse::() { + Ok(IpAddr::V4(ipv4_addr)) => { + // Store IPv4 addresses in the same 16-byte field by + // prefixing the 4-byte address with twelve zero bytes. + let mut bytes = vec![0u8; 12]; + bytes.extend_from_slice(&ipv4_addr.octets()); + bytes + } + Ok(IpAddr::V6(ipv6_addr)) => { + // IPv6 already fits the fixed 16-byte network layout. + ipv6_addr.octets().to_vec() + } + Err(_) => { + // Invalid IP strings are rejected by returning no bytes. + Vec::new() + } + } +} + +// Convert the fixed 16-byte network IP layout back into visible text. +pub fn binary_to_ip(bytes: Vec) -> String { + if bytes.len() != 16 { + return "Invalid input length".to_string(); + } + + // Twelve leading zero bytes identify an IPv4 address stored in + // the final four bytes of the fixed-width field. + if bytes[0..12].iter().all(|&b| b == 0) { + let ipv4_addr = (u32::from(bytes[12]) << 24 + | u32::from(bytes[13]) << 16 + | u32::from(bytes[14]) << 8 + | u32::from(bytes[15])) + .into(); + format!("{}", IpAddr::V4(ipv4_addr)) + } else { + // Non-IPv4 layouts are decoded as a full IPv6 address. + let mut ipv6_bytes = [0u8; 16]; + ipv6_bytes.copy_from_slice(&bytes); + format!("{}", IpAddr::V6(Ipv6Addr::from(ipv6_bytes))) + } +} + +// Convert the fixed 18-byte network endpoint layout into ip:port text. +pub fn binary_to_ip_port(bytes: &[u8]) -> String { + if bytes.len() != 18 { + return String::from("Error: Invalid binary length"); + } + + // The first 16 bytes are the IP address and the final two are the port. + let ip_bytes = &bytes[..16]; + let port_bytes = &bytes[16..]; + let ip = binary_to_ip(ip_bytes.to_vec()); + let port_bytes: [u8; 2] = match port_bytes.try_into() { + Ok(bytes) => bytes, + Err(_) => { + return String::from("Error: Invalid port_bytes length"); + } + }; + let port = u16::from_le_bytes(port_bytes); + format!("{ip}:{port}") +} + +// Convert ip:port text into the fixed 18-byte endpoint layout used on the wire. +pub fn ip_port_to_binary(ip_port: &str) -> Result, String> { + // Split from the right so IPv6 addresses with colons still parse correctly. + let (ip_part, port_part) = ip_port + .rsplit_once(':') + .ok_or_else(|| String::from("Invalid format"))?; + + // Accept bracketed IPv6 endpoint strings such as [::1]:50000. + let normalized_ip = ip_part + .strip_prefix('[') + .and_then(|ip| ip.strip_suffix(']')) + .unwrap_or(ip_part); + + let ip: IpAddr = normalized_ip + .parse() + .map_err(|_| String::from("Invalid IP address"))?; + let port: u16 = port_part + .parse() + .map_err(|_| String::from("Invalid port"))?; + + // Store IPv4 and IPv6 in the same fixed-width 16-byte address slot. + let ip_bytes = match ip { + IpAddr::V4(ipv4) => { + let mut ipv6_bytes = [0u8; 16]; + ipv6_bytes[12..].copy_from_slice(&ipv4.octets()); + ipv6_bytes.to_vec() + } + IpAddr::V6(ipv6) => ipv6.octets().to_vec(), + }; + + // Ports are little-endian because the rest of the binary protocol + // stores integer fields in little-endian order. + let mut result = ip_bytes; + result.extend_from_slice(&port.to_le_bytes()); + Ok(result) +} diff --git a/src/common/check_genesis.rs b/src/common/check_genesis.rs new file mode 100644 index 0000000..bca9e18 --- /dev/null +++ b/src/common/check_genesis.rs @@ -0,0 +1,24 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::metadata; +use crate::PathBuf; + +// Check whether the local chain already has the active network's genesis block. +pub async fn genesis_checkup() -> bool { + // Resolve the active network suffix and block directory from the + // shared path helper so mainnet/testnet never duplicate path logic. + let ( + _network_name, + _padded_base_coin, + suffix, + _torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + + // Genesis is always block zero using the current network's block suffix. + let genesis_location = PathBuf::from(block_path).join(format!("0.{suffix}")); + (metadata(genesis_location).await).is_ok() +} diff --git a/src/common/cli_prompts.rs b/src/common/cli_prompts.rs new file mode 100644 index 0000000..1389eb3 --- /dev/null +++ b/src/common/cli_prompts.rs @@ -0,0 +1,90 @@ +use crate::read_password; +use crate::stdout; +use crate::AsyncWriteExt; +use tokio::io::{stdin, AsyncBufReadExt, BufReader}; + +pub async fn prompt_visible_with_default(prompt: &str, default: &str) -> String { + // Show the default in brackets so pressing enter keeps the existing value. + let full_prompt = format!("{prompt} [{default}]: "); + let value = prompt_visible(&full_prompt).await; + + if value.is_empty() { + default.to_string() + } else { + value + } +} + +pub async fn prompt_visible(prompt: &str) -> String { + // Write the prompt manually so async bin tools can share the same + // visible-input behavior without repeating stdout setup. + let mut stdout = stdout(); + stdout + .write_all(prompt.as_bytes()) + .await + .expect("Failed to write to stdout"); + stdout.flush().await.expect("Failed to flush stdout"); + + // Read one line from stdin and trim shell newline characters. + let mut input = String::new(); + let mut reader = BufReader::new(stdin()); + reader + .read_line(&mut input) + .await + .expect("Failed to read visible input"); + + input.trim().to_string() +} + +pub async fn ask_yes_no_question(question: &str) -> bool { + // Keep asking until the user gives a recognizable yes/no answer. + loop { + let answer = prompt_visible(&format!("{question} (yes/no): ")).await; + + match answer.to_lowercase().as_str() { + "yes" | "y" => return true, + "no" | "n" => return false, + _ => println!("Invalid input, please enter 'yes' or 'no'"), + } + } +} + +pub async fn cli_options() -> String { + // Startup never accepts the wallet key as a positional CLI argument, so + // config overrides like --config still fall through to the same hidden prompt. + prompt_hidden_nonempty( + "Please enter your wallet key. If you do not have a wallet yet, enter a new key here: ", + "Wallet key cannot be empty. Please try again.", + ) + .await +} + +async fn prompt_hidden(prompt: &str) -> String { + // Hidden prompts are used for secrets such as wallet keys and + // database passwords. + let mut stdout = stdout(); + stdout + .write_all(prompt.as_bytes()) + .await + .expect("Failed to write to stdout"); + stdout.flush().await.expect("Failed to flush stdout"); + let value = read_password().expect("Failed to read hidden input"); + + // read_password does not echo the typed value, so print a newline + // before returning control to the terminal. + println!(); + value.trim().to_string() +} + +pub async fn prompt_hidden_nonempty(prompt: &str, retry_message: &str) -> String { + // Secret values are required anywhere this helper is used, so empty + // input loops until the caller receives a real value. + loop { + let value = prompt_hidden(prompt).await; + if !value.is_empty() { + return value; + } + + println!("{retry_message}\n"); + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs new file mode 100644 index 0000000..57fe225 --- /dev/null +++ b/src/common/mod.rs @@ -0,0 +1,8 @@ +pub mod binary_conversions; +pub mod check_genesis; +pub mod cli_prompts; +pub mod network_paths_and_settings; +pub mod network_startup; +pub mod nft_assets; +pub mod skein; +pub mod types; diff --git a/src/common/network_paths_and_settings.rs b/src/common/network_paths_and_settings.rs new file mode 100644 index 0000000..e5d071a --- /dev/null +++ b/src/common/network_paths_and_settings.rs @@ -0,0 +1,130 @@ +use crate::PathBuf; +use crate::Settings; + +// Return the active network name, padded base coin, file suffix, and +// runtime paths. Every returned path is scoped by the active network +// so mainnet and testnet keep separate runtime data. +pub fn block_extension_and_paths() -> ( + &'static str, + String, + String, + String, + String, + String, + String, + String, + String, +) { + #[cfg(feature = "mainnet")] + { + // Mainnet files use the clc suffix and mainnet subdirectories. + ( + network_name(), + base_coin(), + "clc".to_string(), + torrent_root_path(), + wallet_root_path(), + block_root_path(), + db_root_path(), + balance_sheet_root_path(), + log_root_path(), + ) + } + #[cfg(feature = "testnet")] + { + // Testnet files use the cltc suffix and testnet subdirectories. + ( + network_name(), + base_coin(), + "cltc".to_string(), + torrent_root_path(), + wallet_root_path(), + block_root_path(), + db_root_path(), + balance_sheet_root_path(), + log_root_path(), + ) + } +} + +// Return the base coin ticker padded to the fixed 15-byte asset field. +fn base_coin() -> String { + #[cfg(feature = "mainnet")] + { + "CLC ".to_string() + } + #[cfg(feature = "testnet")] + { + "CLTC ".to_string() + } +} + +// Return the network name selected by the active compile feature. +fn network_name() -> &'static str { + #[cfg(feature = "mainnet")] + { + "mainnet" + } + #[cfg(feature = "testnet")] + { + "testnet" + } +} + +fn block_root_path() -> String { + // Blocks are stored beneath the configured block root and active network. + let settings = Settings::load().expect("Failed to load settings"); + PathBuf::from(settings.block_path) + .join(network_name()) + .to_string_lossy() + .into_owned() +} + +fn torrent_root_path() -> String { + // Torrent metadata is stored beneath the configured torrent root and active network. + let settings = Settings::load().expect("Failed to load settings"); + PathBuf::from(settings.torrent_path) + .join(network_name()) + .to_string_lossy() + .into_owned() +} + +fn wallet_root_path() -> String { + // The wallet path points at the configured wallet filename inside + // the active network's wallet directory. + let settings = Settings::load().expect("Failed to load settings"); + PathBuf::from(settings.wallet_path) + .join(network_name()) + .join(settings.wallet_name) + .to_string_lossy() + .into_owned() +} + +fn db_root_path() -> String { + // The internal database used for non-mempool data storage. + let settings = Settings::load().expect("Failed to load settings"); + PathBuf::from(settings.db_path) + .join(network_name()) + .to_string_lossy() + .into_owned() +} + +fn balance_sheet_root_path() -> String { + // The balance root is the configured balance-sheet directory scoped + // to the active network name. + let settings = Settings::load().expect("Failed to load settings"); + PathBuf::from(settings.balance_sheet) + .join(network_name()) + .to_string_lossy() + .into_owned() +} + +fn log_root_path() -> String { + // Logs are scoped by network so testnet and mainnet nodes can run + // side by side without writing to the same rotating log files. + let settings = Settings::load().expect("Failed to load settings"); + PathBuf::from(settings.log_path) + .join(network_name()) + .to_string_lossy() + .into_owned() +} diff --git a/src/common/network_startup.rs b/src/common/network_startup.rs new file mode 100644 index 0000000..7f8c980 --- /dev/null +++ b/src/common/network_startup.rs @@ -0,0 +1,165 @@ +use crate::Settings; +use ipnetwork::IpNetwork; +use std::net::{IpAddr, SocketAddr}; +use tokio::net::lookup_host; + +pub fn is_public_network_address(ip_or_endpoint: &str) -> bool { + // These ranges cannot be used as advertised network addresses + // because remote peers cannot route to them over the public network. + let private_ranges: Vec = vec![ + "127.0.0.0/8".parse().unwrap(), + "10.0.0.0/8".parse().unwrap(), + "172.16.0.0/12".parse().unwrap(), + "192.168.0.0/16".parse().unwrap(), + "100.64.0.0/10".parse().unwrap(), + "fd00::/8".parse().unwrap(), + "fe80::/10".parse().unwrap(), + ]; + + // Accept either a bare IP or an ip:port endpoint. Bare IPv6 addresses + // must be tested before splitting on the final port separator. + let parsed_ip = match ip_or_endpoint.parse::() { + Ok(ip) => ip, + Err(_) => { + let host = ip_or_endpoint + .strip_prefix('[') + .and_then(|value| value.split_once(']').map(|(ip, _)| ip)) + .or_else(|| ip_or_endpoint.rsplit_once(':').map(|(ip, _)| ip)) + .unwrap_or(ip_or_endpoint); + let Ok(ip) = host.parse::() else { + return false; + }; + ip + } + }; + + !private_ranges + .iter() + .any(|range: &IpNetwork| range.contains(parsed_ip)) +} + +pub async fn get_connections() -> Vec { + // If settings cannot be loaded, startup simply has no piggyback peers. + let settings = match Settings::load() { + Ok(settings) => settings, + Err(_) => return Vec::new(), + }; + + let default_port = active_rpc_port(&settings); + let connection_limit = settings.piggybacks.len(); + let mut resolved_connections = Vec::new(); + + for server in settings.piggybacks { + if let Some(endpoint) = resolve_bootstrap_peer(&server, &default_port).await { + resolved_connections.push(endpoint); + } + + if resolved_connections.len() >= connection_limit { + break; + } + } + + resolved_connections +} + +fn split_bootstrap_peer(server: &str, default_port: &str) -> Option<(String, u16)> { + let server = server.trim(); + if server.is_empty() { + return None; + } + + let default_port = default_port.parse::().ok()?; + + if let Ok(ip) = server.parse::() { + return Some((ip.to_string(), default_port)); + } + + if let Ok(socket) = server.parse::() { + return Some((socket.ip().to_string(), socket.port())); + } + + if let Some(host_with_suffix) = server.strip_prefix('[') { + let (host, suffix) = host_with_suffix.split_once(']')?; + let port = suffix + .strip_prefix(':') + .and_then(|value| value.parse::().ok()) + .unwrap_or(default_port); + return Some((host.to_string(), port)); + } + + if let Some((host, port)) = server.rsplit_once(':') { + if !host.contains(':') { + let port = port.parse::().ok()?; + return Some((host.to_string(), port)); + } + } + + Some((server.to_string(), default_port)) +} + +async fn resolve_bootstrap_peer(server: &str, default_port: &str) -> Option { + let (host, port) = split_bootstrap_peer(server, default_port)?; + + if let Ok(ip) = host.parse::() { + if is_public_network_address(&ip.to_string()) { + return Some(SocketAddr::new(ip, port).to_string()); + } + return None; + } + + // Hostnames are allowed only here, at the configured bootstrap boundary. + // The resolved endpoint handed to the rest of the protocol is still an IP. + let resolved = lookup_host((host.as_str(), port)).await.ok()?; + for socket in resolved { + if is_public_network_address(&socket.ip().to_string()) { + return Some(socket.to_string()); + } + } + + None +} + +fn active_rpc_port(settings: &Settings) -> String { + // Mainnet and testnet listen on separate configured RPC ports. + #[cfg(feature = "mainnet")] + { + settings.rpc_port.clone() + } + #[cfg(feature = "testnet")] + { + settings.testnet_rpc_port.clone() + } +} + +// Return the public node IP, active-network RPC port, and public endpoint. +pub async fn get_ip_and_port() -> (String, String, String) { + let settings = Settings::load().expect("Failed to load settings"); + + // PUBLIC_IP is the protocol identity announced to peers. + let ip = settings.public_ip.clone(); + let port = active_rpc_port(&settings); + + // Many handshake and network-map paths need the public ip:port + // string as one value. + let combined_ip_port = format!("{ip}:{port}"); + + (ip, port, combined_ip_port) +} + +pub async fn get_listen_socket() -> String { + let settings = Settings::load().expect("Failed to load settings"); + + // LISTEN_IP is local-only bind configuration and must never be + // broadcast as the node's public network identity. + let port = active_rpc_port(&settings); + format!("{}:{}", settings.listen_ip, port) +} + +pub async fn get_listen_ip() -> String { + let settings = Settings::load().expect("Failed to load settings"); + + // Outbound sockets may bind to this only when it is a concrete + // local interface address. Wildcard listeners leave outbound source + // selection to the operating system and NAT/router. + settings.listen_ip +} diff --git a/src/common/nft_assets.rs b/src/common/nft_assets.rs new file mode 100644 index 0000000..42cd855 --- /dev/null +++ b/src/common/nft_assets.rs @@ -0,0 +1,26 @@ +// Build the visible NFT asset name, adding the series suffix only +// for numbered series entries. +pub fn nft_asset_name(name: &str, nft_series: u32) -> String { + if nft_series == 0 { + name.to_string() + } else { + format!("{}_{}", name.trim_end(), nft_series) + } +} + +// Split a visible NFT asset name back into its fixed-width base +// name and numeric series value. +pub fn nft_asset_parts(asset_name: &str) -> (String, u32) { + // A trailing _number is treated as the series suffix. + if let Some((name, series)) = asset_name.rsplit_once('_') { + if !name.is_empty() && !series.is_empty() && series.chars().all(|c| c.is_ascii_digit()) { + if let Ok(series_number) = series.parse::() { + // Asset names stored in transactions use the fixed 15-byte field. + return (format!("{name:<15}"), series_number); + } + } + } + + // Non-series assets keep their original name and use series zero. + (asset_name.to_string(), 0) +} diff --git a/src/common/skein.rs b/src/common/skein.rs new file mode 100644 index 0000000..5404d90 --- /dev/null +++ b/src/common/skein.rs @@ -0,0 +1,56 @@ +use crate::encode; +use crate::Digest; +use crate::Output; +use crate::Ripemd160; +use crate::Skein256; +use crate::Skein512; +use crate::ripemd::Digest as RipemdDigest; + +pub fn skein_128_hash_bytes(data: &[u8]) -> String { + // Contractless 128-bit hashes are Skein256 hashes reduced to 16 bytes. + let mut hasher = Skein256::new(); + hasher.update(data); + let result: Output = hasher.finalize(); + let reduced_result: Vec = result.iter().step_by(2).copied().collect(); + encode(reduced_result) +} + +pub fn skein_128_hash_data(data: &str) -> String { + // Text hashing uses the UTF-8 bytes of the string directly. + let mut hasher = Skein256::new(); + hasher.update(data.as_bytes()); + let result: Output = hasher.finalize(); + let reduced_result: Vec = result.iter().step_by(2).copied().collect(); + encode(reduced_result) +} + +pub fn skein_256_hash_bytes(data: &[u8]) -> String { + // Skein256 returns the full 32-byte hash as hex. + let mut hasher = Skein256::new(); + hasher.update(data); + let result: Output = hasher.finalize(); + encode(result) +} + +pub fn skein_256_hash_data(data: &str) -> String { + // Text hashing uses the UTF-8 bytes of the string directly. + let mut hasher = Skein256::new(); + hasher.update(data.as_bytes()); + let result: Output = hasher.finalize(); + encode(result) +} + +pub fn skein_512_hash_data(data: &str) -> String { + // Text hashing uses the UTF-8 bytes of the string directly. + let mut hasher = Skein512::new(); + hasher.update(data.as_bytes()); + let result: Output = hasher.finalize(); + encode(result) +} + +pub fn ripemd160_hash_bytes(data: &[u8]) -> Vec { + // RIPEMD-160 is used after Skein256 when deriving short-address payloads. + let mut hasher = Ripemd160::new(); + hasher.update(data); + hasher.finalize().to_vec() +} diff --git a/src/common/types.rs b/src/common/types.rs new file mode 100644 index 0000000..ab9bae3 --- /dev/null +++ b/src/common/types.rs @@ -0,0 +1,95 @@ +use crate::blocks::burn::BurnTransaction; +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::genesis::GenesisTransaction; +use crate::blocks::issue_token::IssueTokenTransaction; +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::blocks::marketing::MarketingTransaction; +use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::rewards::RewardsTransaction; +use crate::blocks::swap::SwapTransaction; +use crate::blocks::token::CreateTokenTransaction; +use crate::blocks::transfer::TransferTransaction; +use crate::blocks::vanity::VanityAddressTransaction; +use crate::Serialize; + +// Transaction type bytes are the canonical IDs used in blocks, mempool +// routing, and verification dispatch. +pub const GENESIS_TYPE: u8 = 0; +pub const REWARDS_TYPE: u8 = 1; +pub const TRANSFER_TYPE: u8 = 2; +pub const CREATE_TOKEN_TYPE: u8 = 3; +pub const CREATE_NFT_TYPE: u8 = 4; +pub const MARKETING_TYPE: u8 = 5; +pub const SWAP_TYPE: u8 = 6; +pub const LENDER_TYPE: u8 = 7; +pub const BORROWER_TYPE: u8 = 8; +pub const COLLATERAL_TYPE: u8 = 9; +pub const BURN_TYPE: u8 = 10; +pub const ISSUE_TOKEN_TYPE: u8 = 11; +pub const VANITY_ADDRESS_TYPE: u8 = 12; + +// Coin and asset names are stored in fixed 15-byte fields. +pub const COIN_LENGTH: usize = 15; + +// The fixed parent hash used only by the genesis block. +pub const GENESIS_BLOCK_HASH: &str = + "03c8e5f02c97a4f4a270dbc13ffe2e3f782f0013674b9b5e960124b89e581f38"; + +// The built-in genesis network entry uses localhost. +pub const GENESIS_IP: &str = "127.0.0.1"; + +// Reward halvings are based on this fixed block interval. +pub const BLOCKS_PER_HALVING: u32 = 9600000; + +// Decimal display value for the base transfer fee percentage. +pub const TRANSFER_FEE: f64 = 0.01; + +// Integer numerator and denominator used for fee math. +// example: 1 / 100 = 1%; 25/1000 = 2.5% +pub const BASE_TRANSFER_FEE_NUMERATOR: u64 = 1; +pub const BASE_TRANSFER_FEE_DENOMINATOR: u64 = 100; + +// Fixed transaction fees are stored in the smallest coin unit. +pub const NON_BASE_TRANSFER_MIN_FEE: u64 = 100000000; // 1 CLC +pub const CREATE_TOKEN_FEE: u64 = 50000000000; // cost to create tokens, 500 CLC +pub const CREATE_NFT_FEE: u64 = 50000000; // cost to creat nfts, 0.5 CLC +pub const MARKETING_FEE: u64 = 100000000; // cost to add marketing record, 1 CLC +pub const SWAP_FEE: u64 = 100000000; // cost for a token swap each party must pay, 1 CLC +pub const LENDER_FEE: u64 = 300000000; // cost to create a loan, 3 CLC +pub const BORROWER_FEE: u64 = 1000000; // cost to make loan payment, 0.01 CLC +pub const COLLATERAL_FEE: u64 = 300000000; // cost to clain collateral, 3 CLC +pub const BURN_FEE: u64 = 10000; // cost to burn a token or NFT, 0.0001 CLC +pub const ISSUE_TOKEN_FEE: u64 = 10000000000; // cost to issue more of an existing token, 100 CLC +pub const VANITY_ADDRESS_FEE: u64 = 500000000; // cost to register or update a vanity address, 5 CLC + +pub fn minimum_transfer_fee(value: u64, is_base_coin: bool) -> u64 { + // Base-coin transfers pay the percentage fee using integer math. + if is_base_coin { + value + .saturating_mul(BASE_TRANSFER_FEE_NUMERATOR) + .div_ceil(BASE_TRANSFER_FEE_DENOMINATOR) + } else { + // Token and NFT transfers use the fixed non-base minimum fee. + NON_BASE_TRANSFER_MIN_FEE + } +} + +// Transaction groups every concrete block transaction type so block +// verification and save paths can dispatch through one enum. +#[derive(Debug, Serialize, Clone)] +pub enum Transaction { + Genesis(GenesisTransaction), + Rewards(RewardsTransaction), + Transfer(TransferTransaction), + Burn(BurnTransaction), + Token(CreateTokenTransaction), + IssueToken(IssueTokenTransaction), + Nft(CreateNftTransaction), + Marketing(MarketingTransaction), + Swap(SwapTransaction), + Lender(LoanContractTransaction), + Borrower(ContractPaymentTransaction), + Collateral(CollateralClaimTransaction), + Vanity(VanityAddressTransaction), +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..fde233a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,210 @@ +use crate::env; +use crate::tilde; +use crate::Ini; +use crate::Path; +use crate::PathBuf; + +lazy_static::lazy_static! { + pub static ref SETTINGS: Settings = Settings::load().expect("Failed to load settings"); +} + +#[derive(Debug)] +pub struct Settings { + pub block_path: String, + pub torrent_path: String, + pub db_path: String, + pub wallet_path: String, + pub wallet_name: String, + pub balance_sheet: String, + pub log_path: String, + pub log_level: String, + pub public_ip: String, + pub listen_ip: String, + pub rpc_port: String, + pub testnet_rpc_port: String, + pub outgoing_connections: u8, + pub incoming_connections: u8, + pub threads: u16, + pub piggybacks: Vec, + pub pg_host: String, + pub pg_port: u16, + pub pg_user: String, + pub pg_password: Option, + pub pg_dbname: String, +} + +impl Settings { + fn expand(path: &str, base_dir: &Path) -> String { + let expanded = PathBuf::from(tilde(path).as_ref()); + if expanded.is_relative() { + base_dir.join(expanded).to_string_lossy().into_owned() + } else { + expanded.to_string_lossy().into_owned() + } + } + + fn get_config_path() -> PathBuf { + // Priority: CLI arg > ENV var > ./settings.ini > executable directory > platform fallback + let mut args = env::args().skip(1); // Skip the executable name + + while let Some(arg) = args.next() { + if arg == "--config" { + if let Some(path) = args.next() { + return PathBuf::from(path); + } else { + eprintln!("Error: --config flag requires a path"); + std::process::exit(1); + } + } + } + if let Ok(path) = env::var("SETTINGS_PATH") { + return PathBuf::from(path); + } + + let local_settings = env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("settings.ini"); + + if local_settings.exists() { + return local_settings; + } + + if let Ok(exe_path) = env::current_exe() { + if let Some(exe_dir) = exe_path.parent() { + let exe_settings = exe_dir.join("settings.ini"); + if exe_settings.exists() { + return exe_settings; + } + } + } + + #[cfg(unix)] + { + PathBuf::from("/etc/contractless/settings.ini") + } + #[cfg(not(unix))] + { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("settings.ini") + } + } + + pub fn load() -> Result> { + let config_file_path = Self::get_config_path(); + let config_dir = config_file_path.parent().unwrap_or_else(|| Path::new(".")); + + let conf = Ini::load_from_file(&config_file_path)?; + + let section = conf + .section(Some("Piggyback")) + .ok_or("Piggyback section not found")?; + #[cfg(feature = "mainnet")] + let pg_section = conf + .section(Some("Postgres")) + .ok_or("Postgres section not found")?; + #[cfg(feature = "testnet")] + let pg_section = conf + .section(Some("Postgres-Testnet")) + .ok_or("Postgres-Testnet section not found")?; + + let mut piggybacks = Vec::new(); + for (key, value) in section.iter() { + if key.to_uppercase().starts_with("PIGGYBACK_") { + piggybacks.push(value.to_string()); + } + } + + let threads = conf + .get_from(Some("Settings"), "THREADS") + .unwrap_or("1") + .parse::()?; + + if threads != 1 && threads != 2 && threads % 4 != 0 { + return Err("THREADS must be 1, 2, or a multiple of 4".into()); + } + + if threads == 0 || threads > 256 { + return Err("THREADS must be between 1 and 256".into()); + } + + Ok(Settings { + block_path: Self::expand( + conf.get_from(Some("Paths"), "BLOCK_PATH") + .ok_or("BLOCK_PATH not found")?, + config_dir, + ), + torrent_path: Self::expand( + conf.get_from(Some("Paths"), "TORRENT_PATH") + .ok_or("TORRENT_PATH not found")?, + config_dir, + ), + db_path: Self::expand( + conf.get_from(Some("Paths"), "DB_PATH") + .ok_or("DB_PATH not found")?, + config_dir, + ), + wallet_path: Self::expand( + conf.get_from(Some("Paths"), "WALLET_PATH") + .ok_or("WALLET_PATH not found")?, + config_dir, + ), + wallet_name: conf + .get_from(Some("Paths"), "WALLET_NAME") + .ok_or("WALLET_NAME not found")? + .to_string(), + balance_sheet: Self::expand( + conf.get_from(Some("Paths"), "BALANCE_SHEET") + .ok_or("BALANCE_SHEET not found")?, + config_dir, + ), + log_path: Self::expand( + conf.get_from(Some("Paths"), "LOG_PATH") + .unwrap_or("./logs"), + config_dir, + ), + log_level: conf + .get_from(Some("Settings"), "LOG_LEVEL") + .unwrap_or("info") + .to_string(), + public_ip: conf + .get_from(Some("Settings"), "PUBLIC_IP") + .or_else(|| conf.get_from(Some("Settings"), "IP")) + .ok_or("PUBLIC_IP not found")? + .to_string(), + listen_ip: conf + .get_from(Some("Settings"), "LISTEN_IP") + .unwrap_or("0.0.0.0") + .to_string(), + rpc_port: conf + .get_from(Some("Settings"), "RPC_PORT") + .ok_or("RPC_PORT not found")? + .to_string(), + testnet_rpc_port: conf + .get_from(Some("Settings"), "TESTNET_RPC_PORT") + .ok_or("TESTNET_RPC_PORT not found")? + .to_string(), + outgoing_connections: conf + .get_from(Some("Settings"), "OUTGOING_CONNECTIONS") + .ok_or("OUTGOING_CONNECTIONS not found")? + .parse::()?, + incoming_connections: conf + .get_from(Some("Settings"), "INCOMING_CONNECTIONS") + .ok_or("INCOMING_CONNECTIONS not found")? + .parse::()?, + threads, + pg_host: pg_section.get("host").unwrap_or("127.0.0.1").to_string(), + pg_port: pg_section.get("port").unwrap_or("5432").parse::()?, + pg_user: pg_section + .get("user") + .ok_or("postgres user not set")? + .to_string(), + pg_password: pg_section.get("password").map(|s| s.to_string()), + pg_dbname: pg_section + .get("dbname") + .ok_or("postgres dbname not set")? + .to_string(), + piggybacks, + }) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8a8d7f3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,79 @@ +// we user this file to include all external libs +// in our library as well as definitions for local +// mod groups for out lib file. Internally all +// external libs are called from our own crate or +// our own lib in the case of standalone tools. +pub mod blocks; +pub mod common; +pub mod config; +pub mod miner; +pub mod orphans; +pub mod records; +pub mod rpc; +pub mod standalone_tools; +pub mod startup; +pub mod torrent; +pub mod verifications; +pub mod wallets; + +pub use chrono::{ + DateTime, Datelike, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc, +}; +pub use cid::Cid; +pub use config::Settings; +pub use encrypted_images::decryption::images::decode_image_and_extract_text; +pub use encrypted_images::decryption::text::decrypts; +pub use encrypted_images::encryption::images::create_img; +pub use encrypted_images::encryption::text::encrypts; +pub use falcon::{ + DomainSeparation, FalconError, FalconKeyPair, FalconSignature, FnDsaExpandedKey, FnDsaKeyPair, + FnDsaSignature, PreHashAlgorithm, +}; +pub use flexi_logger; +pub use hex::{decode, encode, encode_upper}; +pub use ini::Ini; +pub use ipnetwork::IpNetwork; +pub use lazy_static::lazy_static; +pub use log; +pub use rand::rngs::{OsRng, StdRng}; +pub use rand::seq::{IteratorRandom, SliceRandom}; +pub use rand::{thread_rng, Rng, RngCore, SeedableRng}; +pub use rayon; +pub use rayon::iter::IntoParallelIterator; +pub use rayon::iter::ParallelIterator; +pub use ripemd; +pub use ripemd::Ripemd160; +pub use rpassword::read_password; +pub use serde::{Deserialize, Serialize}; +pub use serde_json::{ + from_slice, from_str, json, to_string, to_string_pretty, to_value, Map, Value, +}; +pub use shellexpand::tilde; +pub use skein::digest::{Digest, Output}; +pub use skein::{Skein256, Skein512}; +pub use sled; +pub use std::cmp::Ordering; +pub use std::collections::{BinaryHeap, HashMap}; +pub use std::convert::TryInto; +pub use std::error::Error; +pub use std::fmt::Write; +pub use std::fs::{self, OpenOptions}; +pub use std::io::Cursor; +pub use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +pub use std::path::{Path, PathBuf}; +pub use std::process::exit; +pub use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; +pub use std::sync::{Arc, OnceLock}; +pub use std::{env, fmt, io, panic}; +pub use tokio; +pub use tokio::fs::{create_dir_all, metadata, read, read_dir, read_to_string, remove_file, File}; +pub use tokio::io::{ + stdin, stdout, AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufReader, Result, + SeekFrom, +}; +pub use tokio::net::{TcpListener, TcpStream}; +pub use tokio::runtime::{Builder, Runtime}; +pub use tokio::sync::{mpsc, oneshot, Mutex, RwLock}; +pub use tokio::task; +pub use tokio::time::{sleep, timeout, Duration, Instant}; +pub use tokio_postgres::NoTls; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e53cba1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,83 @@ +use blockchain::exit; +use blockchain::log::{error, logger}; +use blockchain::startup::daemonize::daemonize_after_wallet_prompt; +use blockchain::startup::daemonize::handle_control_command; +use blockchain::startup::initialize_startup::obtain_startup_wallet_key; +use blockchain::startup::initialize_startup::prepare_pre_wallet_startup; +use blockchain::startup::node_runtime::initialize_node_logging; +use blockchain::startup::node_runtime::install_panic_cleanup; +use blockchain::startup::node_runtime::run_unlocked_node; +use blockchain::startup::windows_service::handle_windows_service_command; +use blockchain::startup::windows_service::try_run_as_windows_service; +use blockchain::Runtime; +use tokio::runtime::Builder; + +fn main() { + // Linux-specific control commands are handled before any startup work begins. + match handle_control_command() { + Ok(true) => return, + Ok(false) => {} + Err(e) => { + eprintln!("Control command failed: {e}"); + exit(1); + } + } + + // Windows service management commands are also handled before normal node startup. + match handle_windows_service_command() { + Ok(true) => return, + Ok(false) => {} + Err(e) => { + eprintln!("Windows service command failed: {e}"); + exit(1); + } + } + + // If the binary was launched by the Windows Service Control Manager, the service + // entrypoint takes over and the normal console path stops here. + match try_run_as_windows_service() { + Ok(true) => return, + Ok(false) => {} + Err(e) => { + eprintln!("Failed to start Windows service path: {e}"); + exit(1); + } + } + + // The pre-wallet startup work runs in a temporary runtime so Linux can daemonize + // only after the wallet key is obtained, while Windows console launches still use + // the same shared startup sequence. + let startup_runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create startup runtime"); + startup_runtime.block_on(prepare_pre_wallet_startup()); + let wallet_key = startup_runtime.block_on(obtain_startup_wallet_key()); + drop(startup_runtime); + + // Linux detaches after the wallet prompt unless --foreground is supplied. + if let Err(e) = daemonize_after_wallet_prompt() { + eprintln!("Failed to daemonize: {e}"); + exit(1); + } + + // Once the platform-specific startup path is settled, the shared node runtime + // takes over for normal unlocked operation. + let runtime = Runtime::new().expect("Failed to create main runtime"); + let _log_handle = runtime.block_on(async { + match initialize_node_logging().await { + Ok(handle) => Some(handle), + Err(e) => { + eprintln!("Failed to initialize logging: {e}"); + None + } + } + }); + install_panic_cleanup(); + if let Err(e) = runtime.block_on(run_unlocked_node(wallet_key, true)) { + error!("Failed to start unlocked node runtime: {e}"); + logger().flush(); + eprintln!("Failed to start unlocked node runtime: {e}"); + exit(1); + } +} diff --git a/src/miner/block_rewards.rs b/src/miner/block_rewards.rs new file mode 100644 index 0000000..f1554a8 --- /dev/null +++ b/src/miner/block_rewards.rs @@ -0,0 +1,50 @@ +use crate::blocks::rewards::{RewardsTransaction, UnsignedRewardsTransaction}; +use crate::common::types::BLOCKS_PER_HALVING; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::network_mapping::NodeInfo; +use crate::sled::Db; + +pub async fn calculate_block_reward(block_height: u32) -> u64 { + // Apply the fixed halving schedule based on the block + // height currently being mined or verified. + let halving_count = block_height / BLOCKS_PER_HALVING; + + // Rewards are stored in the smallest coin unit, so this represents + // 416.66666666 base coins before the first halving. + let base_reward = 41666666666; // Represents 416.66666666 * 100000000 + + // The first interval receives the full base reward. Later intervals + // divide by two for each completed halving period. + let reward: u64 = if halving_count == 0 { + base_reward + } else { + base_reward / (2u64.pow(halving_count)) + }; + reward +} + +pub async fn create_rewards_transaction( + short_address: &str, + timestamp: u32, + db: &Db, +) -> RewardsTransaction { + // Rewards are created as the first transaction in every + // mined block using the current reward schedule. + let txtype = 1; + + // The reward belongs to the block being created, not the current tip. + let block_height = get_height(db) + 1; + + // New miners must first prove participation before receiving + // the block subsidy, so early mined blocks pay a zero reward. + let value = if NodeInfo::get_mined_count(short_address).await < 100 { + 0_u64 + } else { + calculate_block_reward(block_height).await + }; + + // Reward transactions are unsigned because they are created by + // consensus rules rather than by a wallet spending funds. + let unsigned_rewards = UnsignedRewardsTransaction::new(txtype, timestamp, value).await; + RewardsTransaction::new(unsigned_rewards).await +} diff --git a/src/miner/fairness.rs b/src/miner/fairness.rs new file mode 100644 index 0000000..9da27c2 --- /dev/null +++ b/src/miner/fairness.rs @@ -0,0 +1,82 @@ +use crate::log::info; +use crate::miner::flag::{is_mining_stop_requested, is_normal_mode, set_mining_state, MiningState}; +use crate::records::block_height::get_block_height::get_height; +use crate::records::unpack_block::unpack_header::load_block_header; +use crate::sled::Db; +use crate::sleep; +use crate::Duration; + +pub async fn fairness_difficulty(block_height: u32, miner_wallet: &str) -> bool { + // The fairness window tightens once peri tenth of the first halving interval. + let current_interval = (block_height / 960000) + 1; + + // Start by allowing ten consecutive blocks, then reduce the + // allowance over time without ever dropping below one. + let max_consecutive_blocks = u8::max(10 - current_interval as u8, 1) as u32; + + // Only the trailing fairness window needs to be checked. + let start_block = block_height.saturating_sub(max_consecutive_blocks); + let mut consecutive_blocks = 0; + + // Walk backward through the recent headers and count how many + // consecutive blocks were mined by this same miner. + for i in (start_block..=block_height).rev() { + // Load the saved header for this height. + let block = load_block_header(i).await.unwrap(); + + // The header stores the miner short address directly. + let mined_by_miner = block.unmined_block.miner; + + // A different miner breaks the consecutive streak. + if mined_by_miner == miner_wallet { + consecutive_blocks += 1; + + // More than the allowed streak means this miner must wait + // for another miner to advance the chain. + if consecutive_blocks > max_consecutive_blocks { + return false; + } + } else { + consecutive_blocks = 0; + } + } + true +} + +pub async fn wait_for_fairness_gate( + db: &Db, + miner_short: &str, + current_block_number: u32, + fairness_paused_height: &mut Option, +) -> bool { + // The fairness gate temporarily yields this height when the local miner + // should wait for another registered miner to advance the chain. + let previous_block_height = current_block_number.saturating_sub(1); + let fairness_allowed = if previous_block_height > 0 { + fairness_difficulty(previous_block_height, miner_short).await + } else { + true + }; + if fairness_allowed { + // Clear the one-shot log guard once this height is mineable again. + *fairness_paused_height = None; + return true; + } + + // Log only once per paused height so the miner does not flood logs + // while it waits for another node to produce the next block. + if *fairness_paused_height != Some(current_block_number) { + info!("[mining] fairness gate active for block {current_block_number}, waiting for another miner to advance the chain"); + *fairness_paused_height = Some(current_block_number); + } + set_mining_state(MiningState::Idle); + + // Stay idle until the chain moves, node mode changes, or mining is stopped. + while is_normal_mode() + && !is_mining_stop_requested() + && get_height(db) + 1 == current_block_number + { + sleep(Duration::from_millis(250)).await; + } + false +} diff --git a/src/miner/flag.rs b/src/miner/flag.rs new file mode 100644 index 0000000..c14b419 --- /dev/null +++ b/src/miner/flag.rs @@ -0,0 +1,142 @@ +use crate::sleep; +use crate::Duration; +use crate::{AtomicBool, AtomicOrdering}; +use std::sync::atomic::AtomicU8; + +static REORG_GATE: AtomicBool = AtomicBool::new(false); +static MINING_STOP_REQUESTED: AtomicBool = AtomicBool::new(false); +static NODE_MODE: AtomicU8 = AtomicU8::new(NodeMode::Startup as u8); +static MINING_STATE: AtomicU8 = AtomicU8::new(MiningState::Idle as u8); + +// NodeMode is stored as a byte so lightweight background tasks can +// coordinate startup, syncing, mining, and reorg work without a mutex. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NodeMode { + Startup, + Syncing, + Normal, + Reorganizing, +} + +fn node_mode_from_bits(value: u8) -> NodeMode { + // Unknown byte values fall back to startup so mining remains disabled. + match value { + 1 => NodeMode::Syncing, + 2 => NodeMode::Normal, + 3 => NodeMode::Reorganizing, + _ => NodeMode::Startup, + } +} + +// MiningState is separate from NodeMode because the node can be normal +// while the mining task is temporarily idle between rounds. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MiningState { + Idle, + Running, +} + +fn mining_state_from_bits(value: u8) -> MiningState { + // Unknown byte values are treated as idle so reorg and shutdown code + // can wait safely instead of assuming a worker is active. + match value { + 1 => MiningState::Running, + _ => MiningState::Idle, + } +} + +pub fn set_node_mode(mode: NodeMode) { + // Store the current node mode for all async tasks that need to + // decide whether mining or reorg work is allowed. + NODE_MODE.store(mode as u8, AtomicOrdering::SeqCst); +} + +pub fn is_syncing_mode() -> bool { + // Syncing mode prevents local mining while the node catches up. + node_mode_from_bits(NODE_MODE.load(AtomicOrdering::SeqCst)) == NodeMode::Syncing +} + +pub fn is_normal_mode() -> bool { + // Normal mode is the only mode where the miner may actively search. + node_mode_from_bits(NODE_MODE.load(AtomicOrdering::SeqCst)) == NodeMode::Normal +} + +pub fn is_reorganizing_mode() -> bool { + // Reorganizing mode is held while chain state is being rewritten. + node_mode_from_bits(NODE_MODE.load(AtomicOrdering::SeqCst)) == NodeMode::Reorganizing +} + +pub fn set_mining_state(state: MiningState) { + // Publish whether the mining task is actively running or idle. + MINING_STATE.store(state as u8, AtomicOrdering::SeqCst); +} + +pub fn request_mining_stop() { + // Stop requests let reorg, shutdown, and mode transitions ask the + // mining loop to leave its current nonce round. + MINING_STOP_REQUESTED.store(true, AtomicOrdering::SeqCst); +} + +pub fn clear_mining_stop_request() { + // Once the blocking operation finishes, mining may resume when + // the node returns to normal mode. + MINING_STOP_REQUESTED.store(false, AtomicOrdering::SeqCst); +} + +pub fn is_mining_idle() -> bool { + // Reorg code uses this to wait until no nonce worker is saving a block. + mining_state_from_bits(MINING_STATE.load(AtomicOrdering::SeqCst)) == MiningState::Idle +} + +pub fn is_mining_running() -> bool { + // Genesis creation uses this to avoid repeatedly publishing + // the same running state inside its nonce loop. + mining_state_from_bits(MINING_STATE.load(AtomicOrdering::SeqCst)) == MiningState::Running +} + +pub fn is_mining_stop_requested() -> bool { + // Mining workers poll this flag between nonce attempts. + MINING_STOP_REQUESTED.load(AtomicOrdering::SeqCst) +} + +pub async fn wait_for_mining_idle() { + // Yield until the active mining round has reported idle. + while !is_mining_idle() { + sleep(Duration::from_millis(10)).await; + } +} + +fn try_acquire_reorg_gate() -> bool { + // Only one reorg path may own the gate at a time. + REORG_GATE + .compare_exchange(false, true, AtomicOrdering::SeqCst, AtomicOrdering::SeqCst) + .is_ok() +} + +pub async fn begin_reorg_lock() { + // Reorg starts by asking mining to stop and waiting for the + // current worker round to leave the save path. + request_mining_stop(); + wait_for_mining_idle().await; + if is_reorganizing_mode() { + return; + } + // Wait for any competing reorg attempt to release the gate. + while !try_acquire_reorg_gate() { + sleep(Duration::from_millis(5)).await; + } + // Reorganizing mode blocks new mining rounds until the lock ends. + set_node_mode(NodeMode::Reorganizing); +} + +pub fn end_reorg_lock() { + // Restore normal mode only if this task still owns the reorg state. + if is_reorganizing_mode() { + set_node_mode(NodeMode::Normal); + } + // Clear the stop flag last so mining cannot resume before mode is restored. + clear_mining_stop_request(); + REORG_GATE.store(false, AtomicOrdering::SeqCst); +} diff --git a/src/miner/genesis.rs b/src/miner/genesis.rs new file mode 100644 index 0000000..8dda40f --- /dev/null +++ b/src/miner/genesis.rs @@ -0,0 +1,156 @@ +use crate::blocks::block::{Block, UnminedBlock}; +use crate::blocks::genesis::{GenesisTransaction, UnsignedGenesisTransaction}; +use crate::common::check_genesis::genesis_checkup; +use crate::common::types::{Transaction, GENESIS_BLOCK_HASH}; +use crate::miner::flag::{is_mining_running, is_mining_stop_requested, is_normal_mode, set_mining_state, MiningState}; +use crate::records::memory::connections::outgoing_connection_count; +use crate::records::memory::response_channels::Command; +use crate::records::record_chain::save::save_block; +use crate::records::record_chain::structs::{SaveBlockParams, SaveType}; +use crate::verifications::verification_service::VerificationService; +use crate::wallets::structures::Wallet; +use crate::log::{error, info}; +use crate::sled::Db; +use crate::Arc; +use crate::Duration; +use crate::Error; +use crate::Mutex; +use crate::sleep; +use crate::Utc; + +pub async fn create_genesis_transaction( + db: &Db, + verification_service: Arc, + wallet_key: String, + map: Arc>, +) { + // Load the local wallet so the genesis block records the miner's + // current short address in the header. + let wallet = match Wallet::try_obtain_wallet(wallet_key.clone(), None).await { + Ok(wallet) => wallet, + Err(err) => { + error!("Wallet decryption failed: {err}"); + return; + } + }; + let miner = wallet.saved.short_address; + + // The genesis transaction carries the fixed launch message and + // uses transaction type zero. + let txtype = 0; + let message = "We are all Satoshi. In 15 years nothing changed.".to_string(); + + // Genesis has no spender signature, but it still follows the + // normal unsigned/signed transaction struct flow. + let unsigned_genesis = UnsignedGenesisTransaction::new(txtype, &message).await; + let genesis_transaction = GenesisTransaction::new(unsigned_genesis).await; + + let _ = create_genesis_block( + genesis_transaction, + &miner, + wallet_key, + db, + verification_service, + map, + ) + .await; +} + +async fn create_genesis_block( + signed_genesis_transaction: GenesisTransaction, + miner: &str, + wallet_key: String, + db: &Db, + verification_service: Arc, + map: Arc>, +) -> Result<(), Box> { + // Genesis searches the one-byte nonce space until the block verifies + // or another task creates the genesis file first. + let mut nonce: u8 = 0; + let mut genesis_search_logged = false; + let mut disconnected_logged = false; + loop { + // The genesis miner obeys the same mode and stop flags as + // normal mining so startup, sync, reorg, and disconnected + // node states can pause it. + while !is_normal_mode() + || is_mining_stop_requested() + || outgoing_connection_count().await == 0 + { + if is_normal_mode() && !is_mining_stop_requested() && !disconnected_logged { + info!("[genesis] waiting for an outgoing peer connection before mining genesis"); + disconnected_logged = true; + } + set_mining_state(MiningState::Idle); + let _ = sleep(Duration::from_millis(100)).await; + } + disconnected_logged = false; + // If sync or another task already saved genesis, stop before + // publishing any mining state or logging a search that will not run. + if genesis_checkup().await { + set_mining_state(MiningState::Idle); + break Ok(()); + } + // Mark the miner running once per active stretch. + if !is_mining_running() { + set_mining_state(MiningState::Running); + } + if !genesis_search_logged { + info!("[genesis] mining genesis block"); + genesis_search_logged = true; + } + + // Genesis uses the fixed parent hash and launch difficulty. + let timestamp = Utc::now().timestamp() as u32; + let next_block_difficulty = 3000000000000000_u64; + let block_struct = UnminedBlock::new( + timestamp, + miner, + GENESIS_BLOCK_HASH, + next_block_difficulty, + nonce, + ) + .await; + + // The VRF binds the candidate header to the mining wallet. + let vrf_block = UnminedBlock::vrf_generate(block_struct, wallet_key.clone()).await; + + let header_hash = vrf_block.hash().await; + + // The genesis block contains exactly one genesis transaction. + let block = Block { + vrf_block, + transactions: vec![Transaction::Genesis(signed_genesis_transaction.clone())], + }; + + // Reuse normal block verification before saving so genesis + // still passes through consensus validation. + if let Ok(transactions) = block.verify(db, verification_service.clone()).await { + // Save through the chain writer so indexes and in-memory + // state are updated the same way as later mined blocks. + match save_block(SaveBlockParams { + block, + db: db.clone(), + header_hash: header_hash.to_string(), + timestamp, + signatures: transactions, + save_type: SaveType::Mining, + allow_during_reorg: false, + map: map.clone(), + }) + .await + { + Ok(()) => { + set_mining_state(MiningState::Idle); + break Ok(()); + } + Err(err) => { + error!("[genesis] save_block failed: {err}"); + } + } + } + // Try the next nonce; wrapping is intentional because the + // timestamp changes while the loop keeps searching. + nonce = nonce.wrapping_add(1); + } +} diff --git a/src/miner/mining.rs b/src/miner/mining.rs new file mode 100644 index 0000000..e68e4a5 --- /dev/null +++ b/src/miner/mining.rs @@ -0,0 +1,274 @@ +use crate::blocks::block::{Block, UnminedBlock}; +use crate::common::types::Transaction; +use crate::log::{error, info}; +use crate::miner::block_rewards::create_rewards_transaction; +use crate::miner::fairness::wait_for_fairness_gate; +use crate::miner::flag::{is_mining_stop_requested, is_normal_mode, set_mining_state, MiningState}; +use crate::miner::nonce::run_nonce_round; +use crate::miner::structs::MiningAttemptContext; +use crate::miner::winner::{handle_mining_winner, verify_and_save_block}; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::connections::outgoing_connection_count; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::memory::response_channels::Command; +use crate::records::unpack_block::unpack_header::load_block_header; +use crate::sled::Db; +use crate::sleep; +use crate::verifications::verification_service::VerificationService; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::Duration; +use crate::Error; +use crate::Mutex; +use crate::Utc; + +pub async fn mine_block( + db: &Db, + verification_service: Arc, + wallet_key: String, + map: Arc>, +) -> Result<(), Box> { + // Mining runs continuously, rebuilding its context from the + // latest saved tip before each one-second nonce round. + let wallet = match Wallet::try_obtain_wallet(wallet_key.clone(), None).await { + Ok(wallet) => wallet, + Err(err) => { + return Err(std::io::Error::other(format!("Wallet decryption failed: {err}")).into()); + } + }; + let miner_short = wallet.saved.short_address; + + // Track the height this miner expects to produce next so nonce workers + // can stop quickly when another peer advances the chain. + let mut expected_block_height = get_height(db) + 1; + let mut fairness_paused_height: Option = None; + let mut was_stopped = true; + + loop { + // Pause here until the node is in normal mode and no stop + // request is pending from reorg or shutdown logic. + was_stopped = wait_until_mining_allowed(was_stopped).await; + + // Re-read height each round because peers may have saved a block + // while this miner was paused or waiting for the next second. + let current_block_number = get_height(db) + 1; + if current_block_number != expected_block_height { + expected_block_height = current_block_number; + } + + if !NodeInfo::address_checkup(&miner_short, current_block_number).await { + // Mining requires the local node wallet to be announced in + // the network map. Offline/solo nodes stay idle instead of + // producing private blocks that can later be submitted in bulk. + set_mining_state(MiningState::Idle); + was_stopped = true; + sleep(Duration::from_millis(250)).await; + continue; + } + + if !wait_for_fairness_gate( + db, + &miner_short, + current_block_number, + &mut fairness_paused_height, + ) + .await + { + was_stopped = true; + continue; + } + + if was_stopped { + info!("mining started"); + was_stopped = false; + } + set_mining_state(MiningState::Running); + + // rebuild the mining context for the current expected height + // before splitting the one-second nonce space across workers + let attempt_context = match prepare_attempt_context( + db, + miner_short.clone(), + wallet_key.clone(), + current_block_number, + verification_service.clone(), + ) + .await + { + Some(context) => context, + None => { + was_stopped = true; + continue; + } + }; + let round_second = Utc::now().timestamp() as u32; + + // Each nonce round searches all 256 nonces for a single timestamp. + let winning_block = run_nonce_round(attempt_context).await; + if handle_mining_winner(winning_block, db, map.clone()).await { + was_stopped = true; + } else if wait_for_next_second_or_chain_change(db, expected_block_height, round_second) + .await + { + continue; + } else { + set_mining_state(MiningState::Idle); + was_stopped = true; + } + } +} + +async fn wait_until_mining_allowed(mut was_stopped: bool) -> bool { + // Mining pauses whenever the node is syncing, reorganizing, starting, + // disconnected, or a stop request has been raised by another task. + let mut disconnected_logged = false; + while !is_normal_mode() || is_mining_stop_requested() || outgoing_connection_count().await == 0 + { + if is_normal_mode() && !is_mining_stop_requested() && !disconnected_logged { + info!("[mining] waiting for an outgoing peer connection before mining"); + disconnected_logged = true; + } + set_mining_state(MiningState::Idle); + was_stopped = true; + sleep(Duration::from_millis(25)).await; + } + was_stopped +} + +async fn prepare_attempt_context( + db: &Db, + miner_short: String, + wallet_key: String, + current_block_number: u32, + verification_service: Arc, +) -> Option { + // Before each worker round, capture a single parent header and difficulty + // so all nonce tasks mine against the same chain tip. + if !is_normal_mode() || is_mining_stop_requested() { + set_mining_state(MiningState::Idle); + return None; + } + + match build_attempt_context( + db, + miner_short, + wallet_key, + current_block_number, + verification_service, + ) + .await + .map_err(|err| err.to_string()) + { + Ok(context) => Some(context), + Err(err_string) => { + error!("[mining] unable to build attempt context: {err_string}"); + set_mining_state(MiningState::Idle); + sleep(Duration::from_millis(50)).await; + None + } + } +} + +async fn wait_for_next_second_or_chain_change( + db: &Db, + expected_block_height: u32, + round_second: u32, +) -> bool { + // If no worker found a block, avoid re-mining the same timestamp/nonce + // space unless the chain tip or node mode changes first. + if !(is_normal_mode() + && !is_mining_stop_requested() + && get_height(db) + 1 == expected_block_height) + { + return false; + } + + while is_normal_mode() + && !is_mining_stop_requested() + && get_height(db) + 1 == expected_block_height + { + let now_second = Utc::now().timestamp() as u32; + if now_second != round_second { + break; + } + sleep(Duration::from_millis(10)).await; + } + true +} + +async fn build_attempt_context( + db: &Db, + miner_short: String, + wallet_key: String, + current_block_number: u32, + verification_service: Arc, +) -> Result> { + // Capture the previous header hash and difficulty once per + // round so all worker tasks mine against the same parent. + if !is_normal_mode() || is_mining_stop_requested() { + return Err("Mining paused before loading previous block header".into()); + } + let previous_block_height = current_block_number - 1; + let previous_block = load_block_header(previous_block_height).await?; + let previous_hash = previous_block.hash().await; + let previous_difficulty = previous_block.unmined_block.next_block_difficulty; + + Ok(MiningAttemptContext { + db: db.clone(), + miner_short, + wallet_key, + current_block_number, + previous_hash, + previous_difficulty, + verification_service, + }) +} + +pub async fn mine_block_internal( + ctx: &MiningAttemptContext, + nonce: u8, +) -> Result, Box> { + // Build a candidate block for this timestamp/nonce pair and + // return it only if full verification succeeds locally. + if !is_normal_mode() || is_mining_stop_requested() { + return Ok(None); + } + let timestamp = Utc::now().timestamp() as u32; + + // Difficulty is calculated from the previous block's difficulty + // and the new candidate timestamp. + let new_difficulty = + UnminedBlock::adjust_difficulty(timestamp, &ctx.db, ctx.previous_difficulty).await; + + // Build the unmined header from the captured parent context and + // this worker's nonce. + let unmined_block = UnminedBlock::new( + timestamp, + &ctx.miner_short, + &ctx.previous_hash, + new_difficulty, + nonce, + ) + .await; + + // Add the wallet VRF proof before hashing and verifying the candidate. + let vrf_block = UnminedBlock::vrf_generate(unmined_block, ctx.wallet_key.clone()).await; + let block_hash = vrf_block.hash().await; + + // Every mined block begins with a consensus-created reward transaction. + let rewards_transaction = + create_rewards_transaction(&ctx.miner_short, timestamp, &ctx.db).await; + let new_block = Block { + vrf_block, + transactions: vec![Transaction::Rewards(rewards_transaction)], + }; + + // Return only candidates that pass local verification. + if let Some(new_block) = + verify_and_save_block(new_block, &ctx.db, ctx.verification_service.clone()).await + { + Ok(Some((new_block, block_hash, timestamp))) + } else { + Ok(None) + } +} diff --git a/src/miner/mod.rs b/src/miner/mod.rs new file mode 100644 index 0000000..aabe778 --- /dev/null +++ b/src/miner/mod.rs @@ -0,0 +1,10 @@ +// Miner modules cover block production, mining state, fairness gates, +// genesis creation, nonce workers, and mined-block save handling. +pub mod block_rewards; +pub mod fairness; +pub mod flag; +pub mod genesis; +pub mod mining; +pub mod nonce; +pub mod structs; +pub mod winner; diff --git a/src/miner/nonce.rs b/src/miner/nonce.rs new file mode 100644 index 0000000..b7e705a --- /dev/null +++ b/src/miner/nonce.rs @@ -0,0 +1,123 @@ +use crate::blocks::block::Block; +use crate::config::SETTINGS; +use crate::log::error; +use crate::miner::flag::{is_mining_stop_requested, is_normal_mode}; +use crate::miner::mining::mine_block_internal; +use crate::miner::structs::MiningAttemptContext; +use crate::records::block_height::get_block_height::get_height; +use crate::task; +use crate::Arc; +use crate::AtomicBool; +use crate::AtomicOrdering; +use crate::Mutex; + +pub async fn run_nonce_round( + attempt_context: MiningAttemptContext, +) -> Option<(Block, String, u32)> { + // Split one mining timestamp across worker tasks and collect the first + // locally verified block candidate they find. + let stop_flag = Arc::new(AtomicBool::new(false)); + + // The winning block is shared between workers so only the first + // valid candidate is returned to the save path. + let winner = Arc::new(Mutex::new(None::<(Block, String, u32)>)); + let ranges = build_nonce_ranges(SETTINGS.threads); + let mut handles = Vec::with_capacity(ranges.len()); + + // Each task receives a disjoint nonce range but shares the same + // parent block context and stop flag. + for (start_nonce, stop_nonce) in ranges { + let ctx = attempt_context.clone(); + let stop = stop_flag.clone(); + let winner_clone = winner.clone(); + handles.push(task::spawn(async move { + nonce_range(ctx, start_nonce, stop_nonce, stop, winner_clone).await + })); + } + + for handle in handles { + match handle.await { + Ok(Ok(())) => {} + Ok(Err(err)) => { + error!("[mining] worker error: {err}"); + } + Err(err) => { + error!("[mining] task join error: {err}"); + } + } + } + + // Take the winner out of the shared slot after all workers exit. + let mut winner_guard = winner.lock().await; + winner_guard.take() +} + +pub fn build_nonce_ranges(threads: u16) -> Vec<(u8, u8)> { + // Split the 0-255 nonce space across the configured worker + // count so each task searches a disjoint slice. + let threads = threads.min(256) as usize; + let base = 256 / threads; + let remainder = 256 % threads; + let mut ranges = Vec::with_capacity(threads); + let mut start = 0usize; + + for idx in 0..threads { + // Spread the remainder over the first ranges so all 256 nonce + // values are covered without overlap. + let width = base + if idx < remainder { 1 } else { 0 }; + let end = start + width - 1; + ranges.push((start as u8, end as u8)); + start = end + 1; + } + + ranges +} + +async fn nonce_range( + ctx: MiningAttemptContext, + start_nonce: u8, + stop_nonce: u8, + stop_flag: Arc, + winner: Arc>>, +) -> Result<(), String> { + // Scan this worker's nonce slice until a winner is found, + // the tip changes, or the assigned range is exhausted. + let mut nonce = start_nonce; + loop { + // Stop immediately when another worker wins or the node leaves + // normal mining mode. + if stop_flag.load(AtomicOrdering::SeqCst) || !is_normal_mode() || is_mining_stop_requested() + { + return Ok(()); + } + + // If the chain tip changed, this round is stale for every worker. + if get_height(&ctx.db) + 1 != ctx.current_block_number { + stop_flag.store(true, AtomicOrdering::SeqCst); + return Ok(()); + } + + // A verified candidate claims the shared winner slot and stops + // the rest of the nonce workers. + if let Some(found) = mine_block_internal(&ctx, nonce) + .await + .map_err(|e| e.to_string())? + { + let mut winner_guard = winner.lock().await; + if winner_guard.is_none() { + *winner_guard = Some(found); + stop_flag.store(true, AtomicOrdering::SeqCst); + } + return Ok(()); + } + + // The assigned range is inclusive, so stop after checking the + // final nonce value. + if nonce == stop_nonce { + break; + } + nonce = nonce.wrapping_add(1); + } + + Ok(()) +} diff --git a/src/miner/structs.rs b/src/miner/structs.rs new file mode 100644 index 0000000..7528df3 --- /dev/null +++ b/src/miner/structs.rs @@ -0,0 +1,16 @@ +use crate::sled::Db; +use crate::verifications::verification_service::VerificationService; +use crate::Arc; + +// MiningAttemptContext captures one consistent chain tip for a nonce round. +// Worker tasks clone this instead of reloading the parent header independently. +#[derive(Clone)] +pub struct MiningAttemptContext { + pub db: Db, + pub miner_short: String, + pub wallet_key: String, + pub current_block_number: u32, + pub previous_hash: String, + pub previous_difficulty: u64, + pub verification_service: Arc, +} diff --git a/src/miner/winner.rs b/src/miner/winner.rs new file mode 100644 index 0000000..df4c35a --- /dev/null +++ b/src/miner/winner.rs @@ -0,0 +1,69 @@ +use crate::blocks::block::Block; +use crate::log::{error, info}; +use crate::miner::flag::{is_mining_stop_requested, set_mining_state, MiningState}; +use crate::records::memory::response_channels::Command; +use crate::records::record_chain::save::save_block; +use crate::records::record_chain::structs::{SaveBlockParams, SaveType}; +use crate::sled::Db; +use crate::verifications::verification_service::VerificationService; +use crate::Arc; +use crate::Mutex; + +pub async fn handle_mining_winner( + winning_block: Option<(Block, String, u32)>, + db: &Db, + map: Arc>, +) -> bool { + // A found block is saved through the normal chain-save path; if mining + // was stopped before save, the caller simply starts over idle. + let Some((new_block, hashed, timestamp)) = winning_block else { + return false; + }; + + // If a stop was requested after the worker found a block, leave the + // save path alone and let the caller restart cleanly. + if is_mining_stop_requested() { + set_mining_state(MiningState::Idle); + return true; + } + + // Mining-generated reward blocks have already been locally verified, + // so there are no external transaction signatures to forward here. + let empty_vec: Vec = Vec::new(); + + // Save through the shared chain writer so block files, indexes, and + // in-memory state are updated consistently. + if let Err(err) = save_block(SaveBlockParams { + block: new_block, + db: db.clone(), + header_hash: hashed, + timestamp, + signatures: empty_vec, + save_type: SaveType::Mining, + allow_during_reorg: false, + map, + }) + .await + { + if err.starts_with("Stale mining candidate:") { + info!("[mining] stale block candidate skipped: {err}"); + } else { + error!("[mining] save_block skipped/failed: {err}"); + } + } + set_mining_state(MiningState::Idle); + true +} + +pub async fn verify_and_save_block( + new_block: Block, + db: &Db, + verification_service: Arc, +) -> Option { + // Local verification prevents invalid candidates from + // reaching the shared save path. + match new_block.verify(db, verification_service).await { + Ok(_) => Some(new_block), + Err(_) => None, + } +} diff --git a/src/orphans/add_genesis.rs b/src/orphans/add_genesis.rs new file mode 100644 index 0000000..3651dce --- /dev/null +++ b/src/orphans/add_genesis.rs @@ -0,0 +1,46 @@ +use crate::common::check_genesis::genesis_checkup; +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::torrent::torrenting_system::torrent_requests::{ + handle_response_and_save_torrent, send_request_torrent_message, +}; +use crate::sled::Db; +use crate::torrent::structs::Torrent; +use crate::Arc; +use crate::Duration; +use crate::Mutex; +use crate::TcpStream; +use crate::timeout; + +pub async fn create_genesis_block( + local_height: u32, + map: Arc>, + stream: Arc>, + db: Db, + wallet_key: &str, + connections_key: String, +) { + // if no local genesis exists, request the remote genesis + // torrent and save it through the normal torrent path + if local_height == 0 && !genesis_checkup().await { + // Reserve a reply slot so the remote torrent response can be matched + // back to this specific genesis request. + let (hashmap_key, _genesis_tx, genesis_rx) = reserve_entry(map.clone()).await; + + let _ = send_request_torrent_message(stream.clone(), 0, hashmap_key, connections_key).await; + + let mut rx = genesis_rx.lock().await; + if let Ok(Some(remote_torrent)) = timeout(Duration::from_secs(30), rx.recv()).await { + // parse the returned torrent before sending it + // through the normal save/verify pipeline + let torrent = match Torrent::from_bytes(&remote_torrent).await { + Ok(torrent) => torrent, + Err(_) => { + return; + } + }; + handle_response_and_save_torrent(0, &db, torrent, wallet_key, map.clone(), false) + .await + .ok(); + } + } +} diff --git a/src/orphans/deep_sync_rollback.rs b/src/orphans/deep_sync_rollback.rs new file mode 100644 index 0000000..e70bfef --- /dev/null +++ b/src/orphans/deep_sync_rollback.rs @@ -0,0 +1,70 @@ +use crate::log::warn; +use crate::miner::flag::begin_reorg_lock; +use crate::orphans::structs::{CheckUp, UndoTransactions}; +use crate::orphans::undo_block_transactions::undo_transactions; +use crate::torrent::unpack_local_torrent::load_torrent; +use crate::torrent::unpack_remote_torrent::request_torrent; + +pub async fn deep_sync_rollback(mut params: CheckUp, wallet_key: &str) { + if params.local_height < params.remote_height { + // This pass only handles deeper sync gaps. Near-tip disagreements + // are left for the orphan-window check. + while (params.remote_height as i32 - params.local_height as i32).abs() >= 10 { + // Compare the local block torrent against the peer's torrent at + // the same height to find a shared ancestor. + match load_torrent(¶ms.db.clone(), params.local_height).await { + Ok(local_torrent) => { + match request_torrent( + params.stream.clone(), + params.local_height, + params.map.clone(), + ¶ms.connections_key.clone(), + ) + .await + { + Ok(remote_torrent) => { + // A hash mismatch this far behind means the local + // chain must step back until the torrents agree. + if local_torrent.info.info_hash != remote_torrent.info.info_hash { + if !params.node_syncing { + begin_reorg_lock().await; + } + // Undo one height, then repeat the comparison + // against the next lower local height. + let undo_transactions_params = UndoTransactions { + start_height: params.local_height, + db: params.db.clone(), + stream: params.stream.clone(), + map: params.map.clone(), + node_syncing: params.node_syncing, + connections_key: params.connections_key.clone(), + }; + undo_transactions(undo_transactions_params, wallet_key) + .await + .ok(); + params.local_height -= 1; + } else { + // Matching torrents mark the shared ancestor. + break; + } + } + Err(_) => { + warn!( + "[orphan][deep_sync_rollback] remote torrent request failed at height {}", + params.local_height + ); + break; + } + } + } + Err(_) => { + warn!( + "[orphan][deep_sync_rollback] local torrent load failed at height {}", + params.local_height + ); + break; + } + } + } + } +} diff --git a/src/orphans/get_path_names.rs b/src/orphans/get_path_names.rs new file mode 100644 index 0000000..4880f98 --- /dev/null +++ b/src/orphans/get_path_names.rs @@ -0,0 +1,28 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::PathBuf; + +pub async fn get_file_names(start_height: u32) -> (String, String) { + // build the canonical block and torrent filenames + // for the height currently being undone + let ( + _network_name, + _padded_base_coin, + block_ext, + torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + + let torrent_name = PathBuf::from(torrent_path) + .join(format!("{start_height}.torrent")) + .to_string_lossy() + .into_owned(); + let block_name = PathBuf::from(block_path) + .join(format!("{start_height}.{block_ext}")) + .to_string_lossy() + .into_owned(); + (torrent_name, block_name) +} diff --git a/src/orphans/mod.rs b/src/orphans/mod.rs new file mode 100644 index 0000000..8237477 --- /dev/null +++ b/src/orphans/mod.rs @@ -0,0 +1,14 @@ +pub mod add_genesis; +pub mod deep_sync_rollback; +pub mod get_path_names; +pub mod orphan_checkup; +pub mod orphan_window_check; +pub mod replay_errors; +pub mod save_blocks; +pub mod snapshot_check; +pub mod structs; +pub mod sync_check; +pub mod torrent_candidates; +pub mod undo_block; +pub mod undo_block_transactions; +pub mod undo_transactions; diff --git a/src/orphans/orphan_checkup.rs b/src/orphans/orphan_checkup.rs new file mode 100644 index 0000000..7237885 --- /dev/null +++ b/src/orphans/orphan_checkup.rs @@ -0,0 +1,133 @@ +use crate::log::info; +use crate::miner::flag::begin_reorg_lock; +use crate::orphans::structs::{OrphanCheckup, UndoTransactions}; +use crate::orphans::undo_block_transactions::undo_transactions; +use crate::records::memory::torrent_status::{ + get_torrent_status, set_torrent_status, TorrentStatus, +}; +use crate::torrent::structs::Torrent; +use crate::torrent::torrenting_system::save_torrent::{ + list_staged_torrents_for_height, read_staged_torrent, +}; +use crate::torrent::unpack_local_torrent::load_torrent; + +async fn staged_candidates_for_height(height: u32) -> Vec { + let mut candidates = Vec::new(); + + if let Ok(staged_paths) = list_staged_torrents_for_height(height).await { + for staged_path in staged_paths { + // Staged files may be incomplete or stale, so bad entries are + // skipped instead of failing the whole orphan pass. + let torrent_bytes = match read_staged_torrent(&staged_path).await { + Ok(bytes) => bytes, + Err(_) => continue, + }; + let torrent = match Torrent::from_bytes(&torrent_bytes).await { + Ok(torrent) => torrent, + Err(_) => continue, + }; + candidates.push(torrent); + } + } + + candidates +} + +fn torrent_beats(left: &Torrent, right: &Torrent) -> bool { + left.info + .timestamp + .cmp(&right.info.timestamp) + .then(left.info.nonce.cmp(&right.info.nonce)) + .then(left.info.vrf.cmp(&right.info.vrf)) + .is_lt() +} + +async fn best_competing_candidate( + height: u32, + local_torrent: &Torrent, + candidates: &[Torrent], +) -> Option { + let mut preferred: Option = None; + + for torrent in candidates { + // Identical info hashes are the same block candidate, not a competing fork. + if torrent.info.info_hash == local_torrent.info.info_hash { + continue; + } + + // Invalid candidates have already lost a prior replay attempt and + // should not be used to trigger another rollback. + if matches!( + get_torrent_status(height, &torrent.info.info_hash).await, + TorrentStatus::Invalid + ) { + continue; + } + + match &preferred { + Some(current_torrent) => { + // Among candidates that have not been ruled out, choose the + // same deterministic winner the block fight uses. + if torrent_beats(torrent, current_torrent) { + preferred = Some(torrent.clone()); + } + } + None => preferred = Some(torrent.clone()), + } + } + + preferred +} + +pub async fn checkup(params: OrphanCheckup, wallet_key: &str) -> Result<(), String> { + // The orphan window check only reasons over local canonical/staged evidence inside the + // orphan window. If we do not yet have a competing staged torrent, + // there is nothing to compare and the local chain remains current. + for height in params.stop_check..=params.start_check { + // Work from the oldest candidate in the window toward the tip so the + // rollback begins at the first height where the local chain loses. + let local_torrent = load_torrent(¶ms.db, height).await?; + let staged_candidates = staged_candidates_for_height(height).await; + + if let Some(competing_torrent) = + best_competing_candidate(height, &local_torrent, &staged_candidates).await + { + let competing_info_hash = competing_torrent.info.info_hash.clone(); + // If the best staged torrent wins this height, rollback starts + // here and replay rebuilds forward from staged candidates. + let undo_transactions_params = UndoTransactions { + start_height: height, + db: params.db.clone(), + stream: params.stream.clone(), + map: params.map.clone(), + node_syncing: params.node_syncing, + connections_key: params.connections_key.clone(), + }; + + if torrent_beats(&competing_torrent, &local_torrent) { + set_torrent_status(height, &competing_info_hash, TorrentStatus::Valid).await; + if !params.node_syncing { + begin_reorg_lock().await; + } + info!("[orphan] adopting competing staged chain from height {height}"); + undo_transactions(undo_transactions_params, wallet_key).await?; + return Ok(()); + } + + // The local block remains the winner at this height, so every + // staged competitor for the same height has now been checked. + for staged_torrent in staged_candidates { + if staged_torrent.info.info_hash != local_torrent.info.info_hash { + set_torrent_status( + height, + &staged_torrent.info.info_hash, + TorrentStatus::Invalid, + ) + .await; + } + } + } + } + + Ok(()) +} diff --git a/src/orphans/orphan_window_check.rs b/src/orphans/orphan_window_check.rs new file mode 100644 index 0000000..ddfcfff --- /dev/null +++ b/src/orphans/orphan_window_check.rs @@ -0,0 +1,75 @@ +use crate::orphans::orphan_checkup::checkup; +use crate::orphans::structs::{CheckUp, OrphanCheckup}; + +pub async fn orphan_window_check(params: CheckUp, wallet_key: &str) -> Result<(), String> { + // orphan window check handles near-tip comparisons where the local and + // remote chains are within the orphan correction window + let height_diff = match params.local_height.cmp(¶ms.remote_height) { + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => params.local_height - params.remote_height, + std::cmp::Ordering::Less => params.remote_height - params.local_height, + }; + + if height_diff == 0 { + // same height means compare the last ten blocks directly + let start_check = params.local_height; + let original_start_check = params.local_height; + let stop_check = params.local_height.saturating_sub(10); + let orphan_checkup_params = OrphanCheckup { + start_check, + stop_check, + original_start_check, + local_height: params.local_height, + remote_height: params.remote_height, + stream: params.stream, + db: params.db, + map: params.map.clone(), + node_syncing: params.node_syncing, + connections_key: params.connections_key, + }; + checkup(orphan_checkup_params, wallet_key).await?; + } else if height_diff <= 10 && params.local_height > params.remote_height { + // if the local chain is slightly ahead, begin comparison from + // the remote height and only search within the overlap window + let start_check = params.remote_height; + let original_start_check = params.remote_height; + // The farther apart the tips are, the less backward overlap remains + // inside the ten-block correction window. + let stop_check = params.remote_height.saturating_sub(10 - height_diff); + let orphan_checkup_params = OrphanCheckup { + start_check, + stop_check, + original_start_check, + local_height: params.local_height, + remote_height: params.remote_height, + stream: params.stream, + db: params.db, + map: params.map.clone(), + node_syncing: params.node_syncing, + connections_key: params.connections_key, + }; + checkup(orphan_checkup_params, wallet_key).await?; + } else if height_diff <= 10 && params.local_height < params.remote_height { + // if the remote chain is slightly ahead, start at the local tip + // and search backward only within the valid orphan range + let start_check = params.local_height; + let original_start_check = params.local_height; + // Search only the portion of local history that could still be + // replaced by staged remote candidates. + let stop_check = params.local_height.saturating_sub(10 - height_diff); + let orphan_checkup_params = OrphanCheckup { + start_check, + stop_check, + original_start_check, + local_height: params.local_height, + remote_height: params.remote_height, + stream: params.stream, + db: params.db, + map: params.map.clone(), + node_syncing: params.node_syncing, + connections_key: params.connections_key, + }; + checkup(orphan_checkup_params, wallet_key).await?; + } + Ok(()) +} diff --git a/src/orphans/replay_errors.rs b/src/orphans/replay_errors.rs new file mode 100644 index 0000000..8902e7f --- /dev/null +++ b/src/orphans/replay_errors.rs @@ -0,0 +1,12 @@ +pub fn should_retry_staged_candidate(error: &str) -> bool { + // These errors mean the torrent metadata may still describe the winning + // block, but this node could not fetch enough block data to prove it yet. + error.contains("No available peer could provide remaining pieces") + || error.contains("piece not found") + || error.contains("Requested candidate not found") + || error.contains("Block not found") + || error.contains("Timed out waiting for piece") + || error.contains("Timed out waiting for replacement torrent") + || error.contains("No replacement torrent received") + || error.contains("Piece reply channel closed") +} diff --git a/src/orphans/save_blocks.rs b/src/orphans/save_blocks.rs new file mode 100644 index 0000000..686499c --- /dev/null +++ b/src/orphans/save_blocks.rs @@ -0,0 +1,209 @@ +use crate::orphans::structs::UndoTransactions; +use crate::orphans::replay_errors::should_retry_staged_candidate; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::response_channels::reserve_entry; +use crate::records::memory::torrent_status::{ + get_torrent_status, mark_other_torrent_statuses_invalid, set_torrent_status, TorrentStatus, +}; +use crate::torrent::structs::Torrent; +use crate::torrent::torrenting_system::save_torrent::{ + list_staged_torrents_for_height, read_staged_torrent, +}; +use crate::torrent::torrenting_system::torrent_requests::{ + handle_response_and_save_torrent, send_request_torrent_message, +}; +use crate::{timeout, Duration}; + +pub async fn save_new_blocks( + params: &UndoTransactions, + max_height: u32, + wallet_key: &str, + mut true_start_height: u32, +) -> Result<(), String> { + // after rollback, request and save each remote block from the + // divergence point up to the height we need to restore + loop { + let mut resolved_from_staging = false; + let staged_candidates = list_staged_torrents_for_height(true_start_height).await?; + let mut ordered_candidates = Vec::new(); + for staged_path in staged_candidates { + // Try already-staged torrents before asking the peer again. + // Bad staged entries are ignored here and marked below if tested. + let torrent_bytes = match read_staged_torrent(&staged_path).await { + Ok(bytes) => bytes, + Err(_) => continue, + }; + let torrent = match Torrent::from_bytes(&torrent_bytes).await { + Ok(torrent) => torrent, + Err(_) => continue, + }; + if matches!( + get_torrent_status(true_start_height, &torrent.info.info_hash).await, + TorrentStatus::Invalid + ) { + continue; + } + ordered_candidates.push(torrent); + } + + // Status only filters out known-bad candidates. Among every candidate + // still eligible for replay, use the normal block-fight ordering. + ordered_candidates.sort_by(|a, b| { + a.info + .timestamp + .cmp(&b.info.timestamp) + .then(a.info.nonce.cmp(&b.info.nonce)) + .then(a.info.vrf.cmp(&b.info.vrf)) + }); + + for torrent in &ordered_candidates { + let torrent_info_hash = torrent.info.info_hash.clone(); + + // Height advancement is the proof that the candidate actually + // extended the chain rather than merely parsing successfully. + let local_height_before = get_height(¶ms.db); + match handle_response_and_save_torrent( + true_start_height, + ¶ms.db, + torrent.clone(), + wallet_key, + params.map.clone(), + true, + ) + .await + { + Ok(()) => { + if get_height(¶ms.db) > local_height_before { + set_torrent_status( + true_start_height, + &torrent_info_hash, + TorrentStatus::Valid, + ) + .await; + mark_other_torrent_statuses_invalid(true_start_height, &torrent_info_hash) + .await; + resolved_from_staging = true; + break; + } else { + set_torrent_status( + true_start_height, + &torrent_info_hash, + TorrentStatus::Invalid, + ) + .await; + } + } + Err(err) => { + let status = if should_retry_staged_candidate(&err) { + // Missing pieces mean the candidate has not been + // tested yet, so keep it eligible for a later replay. + TorrentStatus::Pending + } else { + TorrentStatus::Invalid + }; + set_torrent_status( + true_start_height, + &torrent_info_hash, + status, + ) + .await; + } + } + } + + if resolved_from_staging { + // Continue replaying if staged data supplied this height and more + // replacement heights remain. + match max_height.cmp(&true_start_height) { + std::cmp::Ordering::Equal => break, + std::cmp::Ordering::Greater => { + true_start_height += 1; + continue; + } + std::cmp::Ordering::Less => break, + } + } + + // No staged candidate worked, so request the replacement torrent + // directly from the connected peer. + let (hashmap_key, _save_tx, save_rx) = reserve_entry(params.map.clone()).await; + + send_request_torrent_message( + params.stream.clone(), + true_start_height, + hashmap_key, + params.connections_key.clone(), + ) + .await + .map_err(|e| e.to_string())?; + + let mut rx = save_rx.lock().await; + + if let Some(remote_torrent) = + timeout(Duration::from_secs(30), rx.recv()) + .await + .map_err(|_| { + format!( + "Timed out waiting for replacement torrent at height {true_start_height}" + ) + })? + { + if let Ok(text) = String::from_utf8(remote_torrent.clone()) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + // Torrent replies should be raw torrent bytes. Text here + // usually means the peer returned an error response. + return Err(format!( + "Unexpected textual torrent response while replaying height {true_start_height}: {trimmed}" + )); + } + } + + let torrent = match Torrent::from_bytes(&remote_torrent).await { + Ok(torrent) => torrent, + Err(err) => { + return Err(err.to_string()); + } + }; + let torrent_info_hash = torrent.info.info_hash.clone(); + let local_height_before = get_height(¶ms.db); + // Save through the normal torrent path so all validation and + // record updates stay identical to a live broadcast. + handle_response_and_save_torrent( + true_start_height, + ¶ms.db, + torrent, + wallet_key, + params.map.clone(), + true, + ) + .await?; + if get_height(¶ms.db) <= local_height_before { + set_torrent_status( + true_start_height, + &torrent_info_hash, + TorrentStatus::Invalid, + ) + .await; + return Err(format!( + "Replacement torrent at height {true_start_height} did not advance the chain" + )); + } + set_torrent_status(true_start_height, &torrent_info_hash, TorrentStatus::Valid).await; + mark_other_torrent_statuses_invalid(true_start_height, &torrent_info_hash).await; + } else { + return Err(format!( + "No replacement torrent received while replaying height {true_start_height}" + )); + } + + // continue until the requested replacement range + // has been fully saved locally + match max_height.cmp(&true_start_height) { + std::cmp::Ordering::Equal => break, + std::cmp::Ordering::Greater => true_start_height += 1, + std::cmp::Ordering::Less => break, + } + } + Ok(()) +} diff --git a/src/orphans/snapshot_check.rs b/src/orphans/snapshot_check.rs new file mode 100644 index 0000000..12cb792 --- /dev/null +++ b/src/orphans/snapshot_check.rs @@ -0,0 +1,115 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::log::error; +use crate::miner::flag::begin_reorg_lock; +use crate::orphans::structs::UndoTransactions; +use crate::orphans::undo_block_transactions::undo_transactions; +use crate::records::unpack_block::unpack_header::load_block_header; +use crate::sled::Db; +use crate::torrent::unpack_remote_torrent::request_torrent; + +async fn get_snapshot(db: &Db) -> Option<(u32, String)> { + // snapshots store a trusted height/hash pair used to + // short-circuit deeper orphan scanning when possible + let key = b"snapshot"; + if let Ok(Some(value)) = db.get(key) { + let values = binary_to_string(value.to_vec()); + if let Some((height_str, hash)) = values.split_once(':') { + if let Ok(block_height) = height_str.parse::() { + return Some((block_height, hash.to_string())); + } + } + } + None +} + +pub async fn snapshot_height(db: &Db) -> Option { + get_snapshot(db).await.map(|(height, _)| height) +} + +pub async fn update_snapshot(db: &Db, current_height: u32) -> Result<(), String> { + // Genesis is always a valid snapshot, then later snapshots lag the tip + // so normal orphan correction still has room to operate. + let snapshot_height = if current_height == 0 { + Some(0) + } else if current_height > 21 && current_height % 10 == 0 { + Some(current_height - 21) + } else { + None + }; + + let Some(snapshot_height) = snapshot_height else { + return Ok(()); + }; + + // Store only the header hash at the snapshot height. Full block data is + // still loaded from disk when the snapshot is checked. + let header = load_block_header(snapshot_height).await?; + let hash = header.hash().await; + let value = format!("{snapshot_height}:{hash}"); + let key = b"snapshot"; + db.insert(key, value.as_bytes()).map_err(|e| { + format!( + "Failed to store snapshot at height {snapshot_height}: {e}" + ) + })?; + Ok(()) +} + +pub async fn snapshot_verified(params: UndoTransactions, wallet_key: &str) -> bool { + // if the local chain disagrees with the stored snapshot, + // roll back to the snapshot point before continuing + if let Some((snap_height, snap_hash)) = get_snapshot(¶ms.db).await { + if params.start_height >= snap_height { + match load_block_header(snap_height).await { + Ok(local_header) => { + let local_hash = local_header.hash().await; + if local_hash != snap_hash { + // Local state no longer matches the trusted checkpoint, + // so rollback starts at the snapshot height. + if !params.node_syncing { + begin_reorg_lock().await; + } + let undo_transactions_params = UndoTransactions { + start_height: snap_height, + db: params.db, + stream: params.stream, + map: params.map, + node_syncing: params.node_syncing, + connections_key: params.connections_key, + }; + let _ = undo_transactions(undo_transactions_params, wallet_key).await; + return false; + } + // also make sure the remote peer still agrees + // with the same snapshot hash before trusting it + match request_torrent( + params.stream.clone(), + snap_height, + params.map.clone(), + ¶ms.connections_key, + ) + .await + { + Ok(snap_torrent) => { + if snap_torrent.info.block_hash != snap_hash { + error!("Snapshot torrent hash mismatch at height {snap_height}"); + return true; + } + } + _ => { + error!("Unable to verify remote snapshot torrent at height {snap_height}"); + return true; + } + } + } + _ => { + error!("Unable to load local snapshot header at height {snap_height}"); + return true; + } + } + } else { + return true; + } + } + true +} diff --git a/src/orphans/structs.rs b/src/orphans/structs.rs new file mode 100644 index 0000000..b18285e --- /dev/null +++ b/src/orphans/structs.rs @@ -0,0 +1,56 @@ +use crate::records::memory::response_channels::Command; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; + +// parameters used while searching backward for a shared +// ancestor and deciding whether to replace the local tip +pub struct OrphanCheckup { + pub start_check: u32, + pub stop_check: u32, + pub original_start_check: u32, + pub local_height: u32, + pub remote_height: u32, + pub stream: Arc>, + pub db: Db, + pub map: Arc>, + pub node_syncing: bool, + pub connections_key: String, +} + +// top-level orphan check context used by sync and broadcast +// flows before the deeper rollback logic is chosen +pub struct OrphanCheckup2 { + pub stream: Arc>, + pub db: Db, + pub local_height: u32, + pub remote_height: u32, + pub map: Arc>, + pub node_syncing: bool, + pub connections_key: String, +} + +// shared rollback context used by undo/save paths once a +// reorganization decision has already been made +pub struct UndoTransactions { + pub start_height: u32, + pub db: Db, + pub stream: Arc>, + pub map: Arc>, + pub node_syncing: bool, + pub connections_key: String, +} + +#[derive(Clone)] +// lightweight cloneable context passed into the rule checks +// before it is expanded into rollback-specific parameters +pub struct CheckUp { + pub local_height: u32, + pub remote_height: u32, + pub db: Db, + pub stream: Arc>, + pub map: Arc>, + pub node_syncing: bool, + pub connections_key: String, +} diff --git a/src/orphans/sync_check.rs b/src/orphans/sync_check.rs new file mode 100644 index 0000000..f717be5 --- /dev/null +++ b/src/orphans/sync_check.rs @@ -0,0 +1,235 @@ +use crate::common::check_genesis::genesis_checkup; +use crate::log::{error, info, warn}; +use crate::miner::flag::end_reorg_lock; +use crate::orphans::add_genesis::create_genesis_block; +use crate::orphans::deep_sync_rollback::deep_sync_rollback; +use crate::orphans::orphan_window_check::orphan_window_check; +use crate::orphans::replay_errors::should_retry_staged_candidate; +use crate::orphans::snapshot_check::snapshot_verified; +use crate::orphans::structs::CheckUp; +use crate::orphans::structs::OrphanCheckup2; +use crate::orphans::structs::UndoTransactions; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::torrent_status::{ + get_torrent_status, mark_other_torrent_statuses_invalid, set_torrent_status, TorrentStatus, +}; +use crate::torrent::structs::Torrent; +use crate::torrent::torrenting_system::save_torrent::{ + list_staged_torrents, read_staged_torrent, remove_staged_torrent, +}; +use crate::torrent::torrenting_system::torrent_requests::handle_response_and_save_torrent; + +async fn replay_staged_torrents(params: &OrphanCheckup2, wallet_key: &str) -> Result<(), String> { + // staged torrents are replayed after orphan correction so + // any valid deferred candidates can be reconsidered in order. + // Replay is height-based: all candidates for the current expected + // height are tested before giving up on that height. Valid staged + // torrents are retained inside the orphan window even if they do + // not advance the chain on this replay pass. + loop { + let staged_torrents = list_staged_torrents().await?; + if staged_torrents.is_empty() { + return Ok(()); + } + + // Replay only the next expected height. Later staged torrents have to + // wait until their parent height exists locally. + let local_height = get_height(¶ms.db); + let expected_height = if local_height > 0 || genesis_checkup().await { + local_height + 1 + } else { + local_height + }; + + let mut candidates = Vec::new(); + + for (height, staged_path) in staged_torrents { + // collect all candidates for the current expected height. + // anything beyond that height must wait until a winner is found. + if height == expected_height { + candidates.push(staged_path); + continue; + } + + if height > expected_height { + break; + } + } + + if candidates.is_empty() { + return Ok(()); + } + + let mut ordered_candidates = Vec::new(); + for staged_path in candidates { + // Corrupt staged files are removed so they do not keep blocking + // future replay attempts for this height. + let torrent_bytes = match read_staged_torrent(&staged_path).await { + Ok(bytes) => bytes, + Err(err) => { + error!("[orphan] failed to read staged torrent {expected_height}: {err}"); + remove_staged_torrent(&staged_path).await?; + continue; + } + }; + let torrent = match Torrent::from_bytes(&torrent_bytes).await { + Ok(torrent) => torrent, + Err(err) => { + error!("[orphan] failed to parse staged torrent {expected_height}: {err}"); + remove_staged_torrent(&staged_path).await?; + continue; + } + }; + if matches!( + get_torrent_status(expected_height, &torrent.info.info_hash).await, + TorrentStatus::Invalid + ) { + continue; + } + ordered_candidates.push(torrent); + } + + // Status only filters out known-bad candidates. Among every candidate + // still eligible for replay, use the normal block-fight ordering. + ordered_candidates.sort_by(|a, b| { + a.info + .timestamp + .cmp(&b.info.timestamp) + .then(a.info.nonce.cmp(&b.info.nonce)) + .then(a.info.vrf.cmp(&b.info.vrf)) + }); + + if ordered_candidates.is_empty() { + return Ok(()); + } + + let mut advanced_height = false; + for torrent in ordered_candidates { + let torrent_info_hash = torrent.info.info_hash.clone(); + + // Reuse the normal torrent save/verify pipeline; staged replay + // should behave exactly like receiving the torrent live. + match handle_response_and_save_torrent( + expected_height, + ¶ms.db, + torrent, + wallet_key, + params.map.clone(), + true, + ) + .await + { + Ok(()) => { + // Mark the candidate according to whether it actually + // advanced the local chain height. + advanced_height = get_height(¶ms.db) >= expected_height; + let status = if advanced_height { + TorrentStatus::Valid + } else { + TorrentStatus::Invalid + }; + set_torrent_status(expected_height, &torrent_info_hash, status).await; + if advanced_height { + mark_other_torrent_statuses_invalid(expected_height, &torrent_info_hash) + .await; + break; + } + } + Err(err) => { + if should_retry_staged_candidate(&err) { + // Piece availability is not proof that the candidate + // lost the block fight; leave it pending so a later + // orphan pass can retry after more peers stage it. + set_torrent_status( + expected_height, + &torrent_info_hash, + TorrentStatus::Pending, + ) + .await; + } else { + set_torrent_status( + expected_height, + &torrent_info_hash, + TorrentStatus::Invalid, + ) + .await; + } + error!("[orphan] staged torrent replay candidate failed: height={expected_height} err={err}"); + continue; + } + } + } + + if !advanced_height { + // Every staged candidate for the current expected height was + // exhausted without extending the chain, so stop replay here. + return Ok(()); + } + } +} + +pub async fn sync_checkup(params: OrphanCheckup2, wallet_key: &str) -> Result<(), String> { + // bootstrap missing genesis first so the normal orphan + // correction logic can operate against a valid local chain + if params.local_height == 0 && !genesis_checkup().await { + warn!("[orphan] local genesis missing, creating genesis block"); + create_genesis_block( + params.local_height, + params.map.clone(), + params.stream.clone(), + params.db.clone(), + wallet_key, + params.connections_key.clone(), + ) + .await; + } + let undo_transactions_params = UndoTransactions { + start_height: params.local_height, + db: params.db.clone(), + stream: params.stream.clone(), + map: params.map.clone(), + node_syncing: params.node_syncing, + connections_key: params.connections_key.clone(), + }; + // snapshot verification can trigger an immediate rollback + // if a trusted checkpoint no longer matches local state + if !snapshot_verified(undo_transactions_params, wallet_key).await { + // A snapshot rollback already happened, so replay staged torrents and + // exit instead of running the near-tip rules against stale heights. + match replay_staged_torrents(¶ms, wallet_key).await { + Ok(()) => {} + Err(err) => error!("[orphan] staged torrent replay error: {err}"), + } + if !params.node_syncing { + end_reorg_lock(); + } + return Ok(()); + } + // run the two orphan rules in order, then replay any staged + // torrents that were deferred while reorganization was happening + let checkup_params = CheckUp { + local_height: params.local_height, + remote_height: params.remote_height, + db: params.db.clone(), + stream: params.stream.clone(), + map: params.map.clone(), + node_syncing: params.node_syncing, + connections_key: params.connections_key.clone(), + }; + deep_sync_rollback(checkup_params.clone(), wallet_key).await; + + match orphan_window_check(checkup_params, wallet_key).await { + Ok(()) => {} + Err(err) => error!("[orphan] orphan window check error: {err}"), + } + + match replay_staged_torrents(¶ms, wallet_key).await { + Ok(()) => {} + Err(err) => error!("[orphan] staged torrent replay error: {err}"), + } + if !params.node_syncing { + end_reorg_lock(); + } + info!("[orphan] orphan check completed"); + Ok(()) +} diff --git a/src/orphans/torrent_candidates.rs b/src/orphans/torrent_candidates.rs new file mode 100644 index 0000000..620e6cc --- /dev/null +++ b/src/orphans/torrent_candidates.rs @@ -0,0 +1,78 @@ +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::rpc::command_maps::RPC_TORRENT_CANDIDATES; +use crate::rpc::responses::RpcResponse; +use crate::torrent::torrenting_system::save_torrent::save_staged_torrent; +use crate::{timeout, Arc, Duration, Mutex, TcpStream}; + +pub async fn hydrate_torrent_candidates( + stream: Arc>, + map: Arc>, + connections_key: String, +) -> Result { + // Reserve a reply slot and send a small request packet asking the peer for + // its staged/local torrent candidates. + let (hashmap_key, _tx, rx) = reserve_entry(map.clone()).await; + let mut message = Vec::with_capacity(4); + message.push(RPC_TORRENT_CANDIDATES); + message.extend_from_slice(&hashmap_key); + + RpcResponse::send_raw(&stream, Some(&connections_key), &message).await; + + let response = { + let mut rx = rx.lock().await; + timeout(Duration::from_secs(30), rx.recv()) + .await + .map_err(|_| "Timed out waiting for torrent candidates".to_string())? + .ok_or_else(|| "Torrent candidate response channel closed".to_string())? + }; + + if response.len() < 4 { + return Err("Torrent candidate response was too short".to_string()); + } + + // The response starts with the number of entries, then each entry is + // height + byte length + raw torrent bytes. + let mut offset = 0_usize; + let candidate_count = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to read torrent candidate count".to_string())?, + ); + offset += 4; + + let mut imported = 0_usize; + for _ in 0..candidate_count { + // Refuse truncated entries rather than trying to save partial torrent + // bytes into staging. + if response.len().saturating_sub(offset) < 8 { + return Err("Torrent candidate entry was truncated".to_string()); + } + + let height = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to read torrent candidate height".to_string())?, + ); + offset += 4; + + let torrent_len = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to read torrent candidate length".to_string())?, + ) as usize; + offset += 4; + + if response.len().saturating_sub(offset) < torrent_len { + return Err("Torrent candidate bytes were truncated".to_string()); + } + + let torrent_bytes = &response[offset..offset + torrent_len]; + // Imported candidates are staged locally; normal orphan replay decides + // later whether any of them should become canonical blocks. + save_staged_torrent(height, torrent_bytes).await?; + imported += 1; + offset += torrent_len; + } + + Ok(imported) +} diff --git a/src/orphans/undo_block.rs b/src/orphans/undo_block.rs new file mode 100644 index 0000000..968c92a --- /dev/null +++ b/src/orphans/undo_block.rs @@ -0,0 +1,31 @@ +use crate::records::block_height::decrease_block_height::decrease_height; +use crate::records::memory::averages::{load_initial_blocks, DIFFICULTY_AVERAGE_WINDOW}; +use crate::remove_file; +use crate::sled::Db; + +pub async fn undo_block( + torrent_name: String, + block_name: String, + _start_height: u32, + _true_start_height: u32, + _db: &Db, +) { + // remove the persisted torrent and block files for the + // height currently being rolled back + if let Err(err) = remove_file(&torrent_name).await { + eprintln!("Error deleting torrent file: {err}"); + } + if let Err(err) = remove_file(&block_name).await { + eprintln!("Error deleting block file: {err}"); + } +} + +pub async fn finalize_undo_height(final_height: u32, db: &Db) { + // once rollback is complete, lower the recorded chain + // height and refresh the rolling averages cache + decrease_height(final_height, db); + // Difficulty averages are cached from recent blocks, so they must be + // rebuilt after removing block files. + let start_block = final_height.saturating_sub(DIFFICULTY_AVERAGE_WINDOW.saturating_sub(1)); + load_initial_blocks(start_block, final_height).await; +} diff --git a/src/orphans/undo_block_transactions.rs b/src/orphans/undo_block_transactions.rs new file mode 100644 index 0000000..dce8a2e --- /dev/null +++ b/src/orphans/undo_block_transactions.rs @@ -0,0 +1,138 @@ +use crate::common::types::Transaction; +use crate::orphans::get_path_names::get_file_names; +use crate::orphans::save_blocks::save_new_blocks; +use crate::orphans::structs::UndoTransactions; +use crate::orphans::undo_block::undo_block; +use crate::orphans::undo_transactions::restore_mempool::restore_rolled_back_transaction; +use crate::orphans::undo_transactions::undo_borrower::undo_borrower_transaction; +use crate::orphans::undo_transactions::undo_burn::undo_burn_transaction; +use crate::orphans::undo_transactions::undo_collateral::undo_collateral_transaction; +use crate::orphans::undo_transactions::undo_create_nft::undo_create_nft_transaction; +use crate::orphans::undo_transactions::undo_create_token::undo_create_token_transaction; +use crate::orphans::undo_transactions::undo_issue_token::undo_issue_token_transaction; +use crate::orphans::undo_transactions::undo_loan_creation::undo_loan_creation_transaction; +use crate::orphans::undo_transactions::undo_marketing::undo_marketing_transaction; +use crate::orphans::undo_transactions::undo_rewards::undo_rewards_transaction; +use crate::orphans::undo_transactions::undo_swap::undo_swap_transaction; +use crate::orphans::undo_transactions::undo_transfer::undo_transfer_transaction; +use crate::orphans::undo_transactions::undo_vanity::undo_vanity_transaction; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::memory::torrent_status::reset_all_torrent_statuses; +use crate::records::unpack_block::load_by_block_number::load_block; + +pub async fn undo_transactions(params: UndoTransactions, wallet_key: &str) -> Result<(), String> { + // walk backward from the current tip to the selected + // rollback height and undo each block in reverse order + let true_start_height = params.start_height; + let max_height = get_height(¶ms.db); + let mut current_height = max_height; + let mut rolled_back_transactions = Vec::new(); + loop { + // Load the canonical block before deleting it so every transaction can + // be reversed against the current database state. + let block = load_block(current_height).await?; + let mining_receiver = block.vrf_block.unmined_block.miner; + let transactions = block.transactions; + // undo block transactions in reverse order so balance + // changes unwind in the opposite order they were applied + for transaction in transactions.into_iter().rev() { + match transaction { + Transaction::Rewards(rewards_tx) => { + undo_rewards_transaction(rewards_tx, &mining_receiver, ¶ms.db, current_height).await + } + Transaction::Transfer(transfer_tx) => { + undo_transfer_transaction(transfer_tx.clone(), &mining_receiver, ¶ms.db).await; + rolled_back_transactions.push(Transaction::Transfer(transfer_tx)); + } + Transaction::Burn(burn_tx) => { + undo_burn_transaction(burn_tx.clone(), &mining_receiver, ¶ms.db).await; + rolled_back_transactions.push(Transaction::Burn(burn_tx)); + } + Transaction::Token(create_token_tx) => { + undo_create_token_transaction(create_token_tx.clone(), &mining_receiver, ¶ms.db) + .await; + rolled_back_transactions.push(Transaction::Token(create_token_tx)); + } + Transaction::IssueToken(issue_token_tx) => { + undo_issue_token_transaction(issue_token_tx.clone(), &mining_receiver, ¶ms.db).await; + rolled_back_transactions.push(Transaction::IssueToken(issue_token_tx)); + } + Transaction::Nft(create_nft_tx) => { + undo_create_nft_transaction(create_nft_tx.clone(), &mining_receiver, ¶ms.db).await; + rolled_back_transactions.push(Transaction::Nft(create_nft_tx)); + } + Transaction::Marketing(marketing_tx) => { + undo_marketing_transaction(marketing_tx.clone(), &mining_receiver, ¶ms.db).await; + rolled_back_transactions.push(Transaction::Marketing(marketing_tx)); + } + Transaction::Swap(swap_tx) => { + undo_swap_transaction(swap_tx.clone(), &mining_receiver, ¶ms.db).await; + rolled_back_transactions.push(Transaction::Swap(swap_tx)); + } + Transaction::Lender(loan_tx) => { + undo_loan_creation_transaction(loan_tx.clone(), &mining_receiver, ¶ms.db).await; + rolled_back_transactions.push(Transaction::Lender(loan_tx)); + } + Transaction::Borrower(borrower_tx) => { + undo_borrower_transaction(borrower_tx.clone(), &mining_receiver, ¶ms.db).await?; + rolled_back_transactions.push(Transaction::Borrower(borrower_tx)); + } + Transaction::Collateral(collateral_tx) => { + undo_collateral_transaction(collateral_tx.clone(), &mining_receiver, ¶ms.db).await?; + rolled_back_transactions.push(Transaction::Collateral(collateral_tx)); + } + Transaction::Genesis(_) => { + // Genesis is not part of normal rollback; if we reached it, + // the requested rollback boundary is invalid. + return Err( + "Genesis transaction cannot be undone by orphan rollback".to_string() + ) + } + Transaction::Vanity(vanity_tx) => { + undo_vanity_transaction(vanity_tx.clone(), &mining_receiver, ¶ms.db).await?; + rolled_back_transactions.push(Transaction::Vanity(vanity_tx)); + } + } + } + // The block miner loses one mined-block count when this block is removed. + NodeInfo::decrement_mined(&mining_receiver).await; + let (torrent_name, block_name) = get_file_names(current_height).await; + undo_block( + torrent_name, + block_name, + current_height, + true_start_height, + ¶ms.db, + ) + .await; + + // continue rolling back until the requested starting + // height has been completely removed + match current_height.cmp(&true_start_height) { + std::cmp::Ordering::Equal => break, + std::cmp::Ordering::Greater => current_height -= 1, + std::cmp::Ordering::Less => break, + } + } + + let final_height = true_start_height.saturating_sub(1); + crate::orphans::undo_block::finalize_undo_height(final_height, ¶ms.db).await; + + // Only now that every rolled-back block has been unwound do we test + // whether its former transactions are still spendable on the new base. + for transaction in rolled_back_transactions.iter().rev() { + restore_rolled_back_transaction(transaction, ¶ms.db).await; + } + + // Rollback changes the parent chain, so every remembered staged torrent + // outcome must be reconsidered before replacement blocks are replayed. + reset_all_torrent_statuses().await; + + // rebuild mined counts after rollback, then fetch and save the + // replacement blocks, and finally rebuild mined counts again + NodeInfo::rebuild_mined_counts_from_chain(¶ms.db).await?; + save_new_blocks(¶ms, max_height, wallet_key, true_start_height).await?; + NodeInfo::rebuild_mined_counts_from_chain(¶ms.db).await?; + Ok(()) +} diff --git a/src/orphans/undo_transactions/mod.rs b/src/orphans/undo_transactions/mod.rs new file mode 100644 index 0000000..708361a --- /dev/null +++ b/src/orphans/undo_transactions/mod.rs @@ -0,0 +1,13 @@ +pub mod restore_mempool; +pub mod undo_borrower; +pub mod undo_burn; +pub mod undo_collateral; +pub mod undo_create_nft; +pub mod undo_create_token; +pub mod undo_issue_token; +pub mod undo_loan_creation; +pub mod undo_marketing; +pub mod undo_rewards; +pub mod undo_swap; +pub mod undo_transfer; +pub mod undo_vanity; diff --git a/src/orphans/undo_transactions/restore_mempool.rs b/src/orphans/undo_transactions/restore_mempool.rs new file mode 100644 index 0000000..3964429 --- /dev/null +++ b/src/orphans/undo_transactions/restore_mempool.rs @@ -0,0 +1,207 @@ +use crate::blocks::burn::BurnTransaction; +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::issue_token::IssueTokenTransaction; +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::blocks::marketing::MarketingTransaction; +use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::swap::SwapTransaction; +use crate::blocks::token::CreateTokenTransaction; +use crate::blocks::transfer::TransferTransaction; +use crate::blocks::vanity::VanityAddressTransaction; +use crate::common::nft_assets::nft_asset_name; +use crate::common::types::Transaction; +use crate::records::memory::mempool::{restore_processed_by_signatures, BASECOIN}; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::decode; + +async fn restore_if_spendable(signatures: &[String], spendable: bool, insert: F) +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + if !spendable { + return; + } + + match restore_processed_by_signatures(signatures).await { + Ok(false) | Err(_) => insert().await, + Ok(true) => {} + } +} + +async fn loan_coin_for_contract(db: &Db, contract_hash: &str) -> Option { + let contract_key = decode(contract_hash).ok()?; + let RpcResponse::Binary(bytes) = request_transaction_by_txid(db, contract_key).await; + + if bytes.is_empty() || bytes[0] != 7 { + return None; + } + + LoanContractTransaction::from_bytes(7, &bytes[1..]) + .await + .ok() + .map(|contract| contract.unsigned_loan_contract.loan_coin) +} + +pub async fn restore_transfer(transaction: &TransferTransaction, db: &Db) { + let transfer = &transaction.unsigned_transfer; + let asset = nft_asset_name(&transfer.coin, transfer.nft_series); + let spendable = + balance_checkup(db, transfer.value, transfer.txfee, asset, &transfer.sender).await; + let signature = transaction.signature.clone(); + + restore_if_spendable(&[signature], spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_burn(transaction: &BurnTransaction, db: &Db) { + let burn = &transaction.unsigned_burn; + let asset = nft_asset_name(&burn.coin, burn.nft_series); + let spendable = balance_checkup(db, burn.value, burn.txfee, asset, &burn.address).await; + let signature = transaction.signature.clone(); + + restore_if_spendable(&[signature], spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_create_token(transaction: &CreateTokenTransaction, db: &Db) { + let token = &transaction.unsigned_create_token; + let spendable = balance_checkup(db, 0, token.txfee, BASECOIN.clone(), &token.creator).await; + let signature = transaction.signature.clone(); + + restore_if_spendable(&[signature], spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_issue_token(transaction: &IssueTokenTransaction, db: &Db) { + let token = &transaction.unsigned_issue_token; + let spendable = balance_checkup(db, 0, token.txfee, BASECOIN.clone(), &token.creator).await; + let signature = transaction.signature.clone(); + + restore_if_spendable(&[signature], spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_create_nft(transaction: &CreateNftTransaction, db: &Db) { + let nft = &transaction.unsigned_create_nft; + let spendable = balance_checkup(db, 0, nft.txfee, BASECOIN.clone(), &nft.creator).await; + let signature = transaction.signature.clone(); + + restore_if_spendable(&[signature], spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_marketing(transaction: &MarketingTransaction, db: &Db) { + let marketing = &transaction.unsigned_marketing; + let spendable = + balance_checkup(db, 0, marketing.txfee, BASECOIN.clone(), &marketing.advertiser).await; + let signature = transaction.signature.clone(); + + restore_if_spendable(&[signature], spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_swap(transaction: &SwapTransaction, db: &Db) { + let swap = &transaction.unsigned_swap; + let asset1 = nft_asset_name(&swap.ticker1, swap.nft_series1); + let asset2 = nft_asset_name(&swap.ticker2, swap.nft_series2); + let value1 = swap.value1.saturating_add(swap.tip1); + let value2 = swap.value2.saturating_add(swap.tip2); + let sender1_spendable = + balance_checkup(db, value1, swap.txfee1, asset1, &swap.sender1).await; + let sender2_spendable = + balance_checkup(db, value2, swap.txfee2, asset2, &swap.sender2).await; + let signatures = vec![transaction.signature1.clone(), transaction.signature2.clone()]; + + restore_if_spendable(&signatures, sender1_spendable && sender2_spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_loan_creation(transaction: &LoanContractTransaction, db: &Db) { + let loan = &transaction.unsigned_loan_contract; + let lender_spendable = + balance_checkup(db, loan.loan_amount, loan.txfee, loan.loan_coin.clone(), &loan.lender) + .await; + let borrower_spendable = + balance_checkup(db, loan.collateral_amount, 0, loan.collateral.clone(), &loan.borrower) + .await; + let signatures = vec![transaction.signature1.clone(), transaction.signature2.clone()]; + + restore_if_spendable(&signatures, lender_spendable && borrower_spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_borrower(transaction: &ContractPaymentTransaction, db: &Db) { + let payment = &transaction.unsigned_contract_payment; + let Some(loan_coin) = loan_coin_for_contract(db, &payment.contract_hash).await else { + return; + }; + let value = payment.payback_amount.saturating_add(payment.tip); + let spendable = balance_checkup(db, value, payment.txfee, loan_coin, &payment.address).await; + let signature = transaction.signature.clone(); + + restore_if_spendable(&[signature], spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_collateral(transaction: &CollateralClaimTransaction, db: &Db) { + let collateral = &transaction.unsigned_collateral_claim; + let spendable = + balance_checkup(db, 0, collateral.txfee, BASECOIN.clone(), &collateral.address).await; + let signature = transaction.signature.clone(); + + restore_if_spendable(&[signature], spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_vanity(transaction: &VanityAddressTransaction, db: &Db) { + let vanity = &transaction.unsigned_vanity_address; + let spendable = balance_checkup(db, 0, vanity.txfee, BASECOIN.clone(), &vanity.address).await; + let signature = transaction.signature.clone(); + + restore_if_spendable(&[signature], spendable, || async { + let _ = transaction.add_to_memory().await; + }) + .await; +} + +pub async fn restore_rolled_back_transaction(transaction: &Transaction, db: &Db) { + match transaction { + Transaction::Transfer(tx) => restore_transfer(tx, db).await, + Transaction::Burn(tx) => restore_burn(tx, db).await, + Transaction::Token(tx) => restore_create_token(tx, db).await, + Transaction::IssueToken(tx) => restore_issue_token(tx, db).await, + Transaction::Nft(tx) => restore_create_nft(tx, db).await, + Transaction::Marketing(tx) => restore_marketing(tx, db).await, + Transaction::Swap(tx) => restore_swap(tx, db).await, + Transaction::Lender(tx) => restore_loan_creation(tx, db).await, + Transaction::Borrower(tx) => restore_borrower(tx, db).await, + Transaction::Collateral(tx) => restore_collateral(tx, db).await, + Transaction::Vanity(tx) => restore_vanity(tx, db).await, + Transaction::Genesis(_) | Transaction::Rewards(_) => {} + } +} diff --git a/src/orphans/undo_transactions/undo_borrower.rs b/src/orphans/undo_transactions/undo_borrower.rs new file mode 100644 index 0000000..b7538b4 --- /dev/null +++ b/src/orphans/undo_transactions/undo_borrower.rs @@ -0,0 +1,105 @@ +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::add_payments_db::remove_payment; +use crate::records::record_chain::nft_provenance::remove_nft_history_entry; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn undo_borrower_transaction( + transaction: ContractPaymentTransaction, + mining_receiver: &str, + db: &Db, +) -> Result<(), String> { + // restore balances and database state for a contract payment + // that is being removed during orphan rollback + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + + // reload the original loan contract so the rollback uses the + // same lender and asset information the payment was based on + let contract_hash = decode(&transaction.unsigned_contract_payment.contract_hash) + .map_err(|e| format!("Error decoding contract hash: {e}"))?; + let contract = request_transaction_by_txid(db, contract_hash.clone()).await; + + let loan_txtype = 7; + let loan_tx = match contract { + RpcResponse::Binary(contract_bytes) => { + if contract_bytes.is_empty() { + return Err("Invalid loan contract: empty transaction bytes".to_string()); + } + if contract_bytes[0] != loan_txtype { + return Err( + "Invalid loan contract: referenced transaction is not a loan contract" + .to_string(), + ); + } + LoanContractTransaction::from_bytes(loan_txtype, &contract_bytes[1..]) + .await + .map_err(|e| e.to_string())? + } + }; + + let lender = loan_tx.unsigned_loan_contract.lender; + let loan_coin = loan_tx.unsigned_loan_contract.loan_coin; + let borrower = &transaction.unsigned_contract_payment.address; + let payback_amount = transaction.unsigned_contract_payment.payback_amount; + let tip = transaction.unsigned_contract_payment.tip; + let txfee = transaction.unsigned_contract_payment.txfee; + + // reverse the fee, tip, and repayment movements that were + // applied when the borrower payment was originally saved + let _ = + balance_sheet_operation_with_db(db, mining_receiver, txfee, &type_str, operand_subtraction); + let _ = balance_sheet_operation_with_db(db, borrower, txfee, &type_str, operand_addition); + let _ = + balance_sheet_operation_with_db(db, mining_receiver, tip, &loan_coin, operand_subtraction); + let _ = balance_sheet_operation_with_db(db, borrower, tip, &loan_coin, operand_addition); + let _ = balance_sheet_operation_with_db( + db, + &lender, + payback_amount, + &loan_coin, + operand_subtraction, + ); + let _ = + balance_sheet_operation_with_db(db, borrower, payback_amount, &loan_coin, operand_addition); + + // Remove the payment transaction lookup from the txid tree. + let txid_tree = db + .open_tree("txid") + .map_err(|e| format!("Failed to open txid tree: {e}"))?; + let tx_hash = transaction.unsigned_contract_payment.hash().await; + txid_tree + .remove(decode(&tx_hash).map_err(|e| format!("Error decoding txid: {e}"))?) + .map_err(|e| format!("Failed to remove borrower txid: {e}"))?; + + // Loan payments involving NFTs also add a provenance entry for the loan + // asset, so remove it if this loan coin is tracked as an NFT. + let nft_tree = db + .open_tree("nfts") + .map_err(|e| format!("Failed to open nfts tree: {e}"))?; + let tx_hash_bytes = decode(&tx_hash).map_err(|e| format!("Error decoding txid: {e}"))?; + if nft_tree.contains_key(loan_coin.as_bytes()).unwrap_or(false) { + let _ = remove_nft_history_entry(db, &loan_coin, &tx_hash_bytes); + } + + // The aggregate payment record is reduced by the payment being undone. + let _ = remove_payment(db, contract_hash, payback_amount); + + Ok(()) +} diff --git a/src/orphans/undo_transactions/undo_burn.rs b/src/orphans/undo_transactions/undo_burn.rs new file mode 100644 index 0000000..fdb0f04 --- /dev/null +++ b/src/orphans/undo_transactions/undo_burn.rs @@ -0,0 +1,93 @@ +use crate::blocks::burn::BurnTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::nft_assets::nft_asset_name; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::nft_provenance::remove_nft_history_entry; +use crate::records::record_chain::token_provenance::remove_token_history_entry; +use crate::sled::Db; + +pub async fn undo_burn_transaction(transaction: BurnTransaction, mining_receiver: &str, db: &Db) { + // Reverse the burn fee and burned-asset balance movement before + // restoring the live token or NFT state back into the active chain. + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let burned_asset = nft_asset_name( + &transaction.unsigned_burn.coin, + transaction.unsigned_burn.nft_series, + ); + + // Remove the miner fee, refund the burner fee, and return the burned asset + // to the burner balance. + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + transaction.unsigned_burn.txfee, + &type_str, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db( + db, + &transaction.unsigned_burn.address, + transaction.unsigned_burn.txfee, + &type_str, + operand_addition, + ); + let _ = balance_sheet_operation_with_db( + db, + &transaction.unsigned_burn.address, + transaction.unsigned_burn.value, + &burned_asset, + operand_addition, + ); + + let hash_binary = decode(&transaction.unsigned_burn.hash().await).unwrap(); + + // Delete the txid lookup inserted when the burn was saved. + let txid_tree = db.open_tree("txid").unwrap(); + txid_tree.remove(hash_binary.clone()).unwrap(); + + // Restore NFT rows directly, or add the burned amount back into the + // fungible token supply if this burn targeted a token asset. + let nft_tree = db.open_tree("nfts").unwrap(); + let nft_origin_tree = db.open_tree("nft_origins").unwrap(); + if nft_origin_tree + .contains_key(burned_asset.as_bytes()) + .unwrap_or(false) + { + // NFT burns remove the live NFT row; rollback restores the row and + // removes the burn from NFT history. + let _ = remove_nft_history_entry(db, &burned_asset, &hash_binary); + let _ = nft_tree.insert(burned_asset.as_bytes(), b"1"); + } else { + // Token burns reduce supply; rollback adds the burned amount back. + let _ = remove_token_history_entry(db, &transaction.unsigned_burn.coin, &hash_binary); + let token_tree = db.open_tree("tokens").unwrap(); + let current_supply = token_tree + .get(transaction.unsigned_burn.coin.as_bytes()) + .unwrap() + .map(|bytes| { + let mut supply_bytes = [0u8; 8]; + supply_bytes.copy_from_slice(bytes.as_ref()); + u64::from_le_bytes(supply_bytes) + }) + .unwrap_or(0); + let restored_supply = current_supply.saturating_add(transaction.unsigned_burn.value); + let _ = token_tree.insert( + transaction.unsigned_burn.coin.as_bytes(), + &restored_supply.to_le_bytes(), + ); + } + +} diff --git a/src/orphans/undo_transactions/undo_collateral.rs b/src/orphans/undo_transactions/undo_collateral.rs new file mode 100644 index 0000000..7e11707 --- /dev/null +++ b/src/orphans/undo_transactions/undo_collateral.rs @@ -0,0 +1,108 @@ +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::nft_provenance::remove_nft_history_entry; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn undo_collateral_transaction( + transaction: CollateralClaimTransaction, + mining_receiver: &str, + db: &Db, +) -> Result<(), String> { + // restore balances and contract state for a collateral + // claim that is being removed during orphan rollback + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + + // reload the original loan contract so the collateral + // asset and amount can be restored correctly + let contract_hash = decode(&transaction.unsigned_collateral_claim.contract_hash) + .map_err(|e| format!("Error decoding contract hash: {e}"))?; + let contract = request_transaction_by_txid(db, contract_hash.clone()).await; + + let loan_txtype = 7; + let loan_tx = match contract { + RpcResponse::Binary(contract_bytes) => { + if contract_bytes.is_empty() { + return Err("Invalid loan contract: empty transaction bytes".to_string()); + } + if contract_bytes[0] != loan_txtype { + return Err( + "Invalid loan contract: referenced transaction is not a loan contract" + .to_string(), + ); + } + LoanContractTransaction::from_bytes(loan_txtype, &contract_bytes[1..]) + .await + .map_err(|e| e.to_string())? + } + }; + + let collateral = loan_tx.unsigned_loan_contract.collateral; + let collateral_amount = loan_tx.unsigned_loan_contract.collateral_amount; + let collateral_holding = format!( + "collateral_{}", + transaction.unsigned_collateral_claim.contract_hash + ); + let claimer = &transaction.unsigned_collateral_claim.address; + let txfee = transaction.unsigned_collateral_claim.txfee; + + // reverse the fee and move the collateral back into the + // contract holding wallet until the claim exists again + let _ = + balance_sheet_operation_with_db(db, mining_receiver, txfee, &type_str, operand_subtraction); + let _ = balance_sheet_operation_with_db(db, claimer, txfee, &type_str, operand_addition); + let _ = balance_sheet_operation_with_db( + db, + claimer, + collateral_amount, + &collateral, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db( + db, + &collateral_holding, + collateral_amount, + &collateral, + operand_addition, + ); + + // Remove the collateral-claim transaction lookup from the txid tree. + let txid_tree = db.open_tree("txid").unwrap(); + let tx_hash = transaction.unsigned_collateral_claim.hash().await; + txid_tree + .remove(decode(&tx_hash).map_err(|e| format!("Error decoding txid: {e}"))?) + .unwrap(); + + // NFT collateral claims write provenance for the collateral asset. + let nft_tree = db.open_tree("nfts").unwrap(); + let tx_hash_bytes = decode(&tx_hash).map_err(|e| format!("Error decoding txid: {e}"))?; + if nft_tree + .contains_key(collateral.as_bytes()) + .unwrap_or(false) + { + let _ = remove_nft_history_entry(db, &collateral, &tx_hash_bytes); + } + + // Mark the loan contract active again because the collateral claim no + // longer exists after rollback. + let loan_tree = db.open_tree("loan").unwrap(); + loan_tree.insert(contract_hash, "true".as_bytes()).unwrap(); + + Ok(()) +} diff --git a/src/orphans/undo_transactions/undo_create_nft.rs b/src/orphans/undo_transactions/undo_create_nft.rs new file mode 100644 index 0000000..29e5a6a --- /dev/null +++ b/src/orphans/undo_transactions/undo_create_nft.rs @@ -0,0 +1,81 @@ +use crate::blocks::nft::CreateNftTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::nft_assets::nft_asset_name; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::nft_provenance::{remove_nft_history_entry, remove_nft_origin}; +use crate::sled::Db; + +const NFT_UNIT: u64 = 100_000_000; + +pub async fn undo_create_nft_transaction( + transaction: CreateNftTransaction, + mining_receiver: &str, + db: &Db, +) { + // remove the created nft state and restore the creator balances + // when a create-nft transaction is rolled back + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let (txfee, creator) = ( + &transaction.unsigned_create_nft.txfee, + &transaction.unsigned_create_nft.creator, + ); + + // Remove the miner fee and refund the creator's base-coin fee. + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + *txfee, + &type_str, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db(db, creator, *txfee, &type_str, operand_addition); + let hash_binary = decode(&transaction.unsigned_create_nft.hash().await).unwrap(); + + // Remove the create-NFT transaction lookup from the txid tree. + let tree = db.open_tree("txid").unwrap(); + let key = hash_binary.clone(); + tree.remove(key).unwrap(); + + // remove each created nft item and clear the + // associated provenance entries + let tree = db.open_tree("nfts").unwrap(); + if transaction.unsigned_create_nft.series == 1 { + // Series creation mints numbered items, so each item is removed + // individually from balances, provenance, origins, and the NFT tree. + for item_number in 1..=transaction.unsigned_create_nft.count { + let nft_name = nft_asset_name(&transaction.unsigned_create_nft.nft_name, item_number); + let _ = balance_sheet_operation_with_db( + db, + creator, + NFT_UNIT, + &nft_name, + operand_subtraction, + ); + let _ = remove_nft_history_entry(db, &nft_name, &hash_binary); + let _ = remove_nft_origin(db, &nft_name); + tree.remove(nft_name.as_bytes()).unwrap(); + } + } else { + // Single NFT creation only writes the base NFT name. + let nft_name = &transaction.unsigned_create_nft.nft_name; + let _ = + balance_sheet_operation_with_db(db, creator, NFT_UNIT, nft_name, operand_subtraction); + let _ = remove_nft_history_entry(db, nft_name, &hash_binary); + let _ = remove_nft_origin(db, nft_name); + tree.remove(nft_name.as_bytes()).unwrap(); + } + +} diff --git a/src/orphans/undo_transactions/undo_create_token.rs b/src/orphans/undo_transactions/undo_create_token.rs new file mode 100644 index 0000000..2f98764 --- /dev/null +++ b/src/orphans/undo_transactions/undo_create_token.rs @@ -0,0 +1,69 @@ +use crate::blocks::token::CreateTokenTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::token_provenance::clear_token_history; +use crate::sled::Db; + +pub async fn undo_create_token_transaction( + transaction: CreateTokenTransaction, + mining_receiver: &str, + db: &Db, +) { + // remove the created token state and restore the creator balances + // when a create-token transaction is rolled back + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let (txfee, creator, ticker, number) = ( + &transaction.unsigned_create_token.txfee, + &transaction.unsigned_create_token.creator, + &transaction.unsigned_create_token.ticker, + &transaction.unsigned_create_token.number, + ); + + // Remove the miner fee, refund the creator fee, and remove the created + // supply from the creator balance. + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + *txfee, + &type_str, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db(db, creator, *txfee, &type_str, operand_addition); + let _ = balance_sheet_operation_with_db(db, creator, *number, ticker, operand_subtraction); + + let ticker_binary = &transaction.unsigned_create_token.ticker.as_bytes(); + let hash_binary = decode(&transaction.unsigned_create_token.hash().await).unwrap(); + + // Remove the create-token transaction lookup from the txid tree. + let tree = db.open_tree("txid").unwrap(); + let key = hash_binary.clone(); + tree.remove(key).unwrap(); + + // remove the token definition and origin entry + // created when the token was first saved + let tree = db.open_tree("tokens").unwrap(); + let key = ticker_binary; + tree.remove(key).unwrap(); + + let origin_tree = db.open_tree("token_origins").unwrap(); + origin_tree.remove(key).unwrap(); + + let limit_tree = db.open_tree("token_limits").unwrap(); + limit_tree.remove(key).unwrap(); + // Token history is cleared because the token itself no longer exists. + let _ = clear_token_history(db, ticker); + +} diff --git a/src/orphans/undo_transactions/undo_issue_token.rs b/src/orphans/undo_transactions/undo_issue_token.rs new file mode 100644 index 0000000..6d1669b --- /dev/null +++ b/src/orphans/undo_transactions/undo_issue_token.rs @@ -0,0 +1,62 @@ +use crate::blocks::issue_token::IssueTokenTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::token_provenance::remove_token_history_entry; +use crate::sled::Db; + +pub async fn undo_issue_token_transaction( + transaction: IssueTokenTransaction, + mining_receiver: &str, + db: &Db, +) { + // Reverse the issued supply and fee movements so rollback restores + // both the creator balance and the stored token supply. + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let txfee = &transaction.unsigned_issue_token.txfee; + let creator = &transaction.unsigned_issue_token.creator; + let ticker = &transaction.unsigned_issue_token.ticker; + let number = &transaction.unsigned_issue_token.number; + + // Remove the miner fee, refund the issuer fee, and take the issued amount + // back out of the issuer balance. + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + *txfee, + &type_str, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db(db, creator, *txfee, &type_str, operand_addition); + let _ = balance_sheet_operation_with_db(db, creator, *number, ticker, operand_subtraction); + + let hash_binary = decode(&transaction.unsigned_issue_token.hash().await).unwrap(); + + // Delete the issued-token transaction lookup and provenance record. + let txid_tree = db.open_tree("txid").unwrap(); + txid_tree.remove(hash_binary.clone()).unwrap(); + let _ = remove_token_history_entry(db, ticker, &hash_binary); + + // Restore the previous live token supply by subtracting the issued amount. + let token_tree = db.open_tree("tokens").unwrap(); + if let Ok(Some(existing_supply)) = token_tree.get(ticker.as_bytes()) { + let mut supply_bytes = [0u8; 8]; + supply_bytes.copy_from_slice(existing_supply.as_ref()); + let current_supply = u64::from_le_bytes(supply_bytes); + let restored_supply = current_supply.saturating_sub(*number); + let _ = token_tree.insert(ticker.as_bytes(), &restored_supply.to_le_bytes()); + } + +} diff --git a/src/orphans/undo_transactions/undo_loan_creation.rs b/src/orphans/undo_transactions/undo_loan_creation.rs new file mode 100644 index 0000000..949fa61 --- /dev/null +++ b/src/orphans/undo_transactions/undo_loan_creation.rs @@ -0,0 +1,93 @@ +use crate::blocks::loans::LoanContractTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::nft_provenance::remove_nft_history_entry; +use crate::sled::Db; + +pub async fn undo_loan_creation_transaction( + transaction: LoanContractTransaction, + mining_receiver: &str, + db: &Db, +) { + // remove a loan contract and restore both sides of the + // contract balances when the block is rolled back + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let (txfee, loan_coin, loan_amount, lender, collateral, collateral_amount, borrower, hash) = ( + &transaction.unsigned_loan_contract.txfee, + &transaction.unsigned_loan_contract.loan_coin.clone(), + &transaction.unsigned_loan_contract.loan_amount, + &transaction.unsigned_loan_contract.lender.clone(), + &transaction.unsigned_loan_contract.collateral.clone(), + &transaction.unsigned_loan_contract.collateral_amount, + &transaction.unsigned_loan_contract.borrower.clone(), + &transaction.hash.clone(), + ); + let collateral_wallet = format!("collateral_{hash}"); + + // undo the fee, loan distribution, and collateral + // movement that were applied when the contract was saved + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + *txfee, + &type_str, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db(db, lender, *txfee, &type_str, operand_addition); + let _ = + balance_sheet_operation_with_db(db, borrower, *loan_amount, loan_coin, operand_subtraction); + let _ = balance_sheet_operation_with_db(db, lender, *loan_amount, loan_coin, operand_addition); + let _ = balance_sheet_operation_with_db( + db, + &collateral_wallet, + *collateral_amount, + collateral, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db( + db, + borrower, + *collateral_amount, + collateral, + operand_addition, + ); + + let hash_binary = decode(hash).unwrap(); + + // delete the txid and remove the active loan record + // so the contract no longer exists on-chain + let tree = db.open_tree("txid").unwrap(); + let key = hash_binary.clone(); + tree.remove(key).unwrap(); + + let tree = db.open_tree("loan").unwrap(); + let loan_key = decode(&transaction.unsigned_loan_contract.hash().await).unwrap(); + tree.remove(loan_key).unwrap(); + + let nft_tree = db.open_tree("nfts").unwrap(); + // Loan creation can move NFT collateral or NFT loan assets, so clear + // provenance entries for whichever side is NFT-backed. + if nft_tree + .contains_key(collateral.as_bytes()) + .unwrap_or(false) + { + let _ = remove_nft_history_entry(db, collateral, &hash_binary); + } + if nft_tree.contains_key(loan_coin.as_bytes()).unwrap_or(false) { + let _ = remove_nft_history_entry(db, loan_coin, &hash_binary); + } + +} diff --git a/src/orphans/undo_transactions/undo_marketing.rs b/src/orphans/undo_transactions/undo_marketing.rs new file mode 100644 index 0000000..3f2cbde --- /dev/null +++ b/src/orphans/undo_transactions/undo_marketing.rs @@ -0,0 +1,49 @@ +use crate::blocks::marketing::MarketingTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::sled::Db; + +pub async fn undo_marketing_transaction( + transaction: MarketingTransaction, + mining_receiver: &str, + db: &Db, +) { + // reverse the fee payment and remove the marketing txid + // when a marketing transaction is rolled back + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let (txfee, advertiser) = ( + &transaction.unsigned_marketing.txfee, + &transaction.unsigned_marketing.advertiser, + ); + + // Remove the miner fee and refund the advertiser fee. + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + *txfee, + &type_str, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db(db, advertiser, *txfee, &type_str, operand_addition); + + let hash = decode(&transaction.unsigned_marketing.hash().await).unwrap(); + + // Remove the marketing transaction lookup from the txid tree. + let tree = db.open_tree("txid").unwrap(); + let key = hash; + tree.remove(key).unwrap(); + +} diff --git a/src/orphans/undo_transactions/undo_rewards.rs b/src/orphans/undo_transactions/undo_rewards.rs new file mode 100644 index 0000000..c2a221e --- /dev/null +++ b/src/orphans/undo_transactions/undo_rewards.rs @@ -0,0 +1,44 @@ +use crate::blocks::rewards::RewardsTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::rewards_tx::{remove_reward_credit_marker, reward_credit_applied}; +use crate::sled::Db; + +pub async fn undo_rewards_transaction( + transaction: RewardsTransaction, + mining_receiver: &str, + db: &Db, + block_height: u32, +) { + // remove the miner reward and delete the reward txid + // when the block that minted it is rolled back + let operand = "subtraction"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + + let value = transaction.unsigned.value; + + // Rewards are only spendable after finalization, so rollback subtracts + // them only when that delayed credit has actually been applied. + if reward_credit_applied(db, block_height) { + let _ = balance_sheet_operation_with_db(db, mining_receiver, value, &type_str, operand); + remove_reward_credit_marker(db, block_height); + } + + let hash = decode(transaction.unsigned.hash().await).unwrap(); + + // Remove the reward transaction lookup from the txid tree. + let tree = db.open_tree("txid").unwrap(); + let key = hash; + tree.remove(key).unwrap(); +} diff --git a/src/orphans/undo_transactions/undo_swap.rs b/src/orphans/undo_transactions/undo_swap.rs new file mode 100644 index 0000000..ae112c8 --- /dev/null +++ b/src/orphans/undo_transactions/undo_swap.rs @@ -0,0 +1,103 @@ +use crate::blocks::swap::SwapTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::nft_assets::nft_asset_name; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::nft_provenance::remove_nft_history_entry; +use crate::sled::Db; + +pub async fn undo_swap_transaction(transaction: SwapTransaction, mining_receiver: &str, db: &Db) { + // reverse both sides of the asset exchange and remove the + // swap transaction from chain state during rollback + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let ( + txfee1, + txfee2, + value1, + value2, + ticker1, + ticker2, + nft_series1, + nft_series2, + sender1, + sender2, + tip1, + tip2, + ) = ( + &transaction.unsigned_swap.txfee1, + &transaction.unsigned_swap.txfee2, + &transaction.unsigned_swap.value1, + &transaction.unsigned_swap.value2, + &transaction.unsigned_swap.ticker1, + &transaction.unsigned_swap.ticker2, + &transaction.unsigned_swap.nft_series1, + &transaction.unsigned_swap.nft_series2, + &transaction.unsigned_swap.sender1, + &transaction.unsigned_swap.sender2, + &transaction.unsigned_swap.tip1, + &transaction.unsigned_swap.tip2, + ); + let asset1 = nft_asset_name(ticker1, *nft_series1); + let asset2 = nft_asset_name(ticker2, *nft_series2); + + // Refund both base-coin fees and remove those fees from the miner balance. + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + *txfee1, + &type_str, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db(db, sender1, *txfee1, &type_str, operand_addition); + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + *txfee2, + &type_str, + operand_subtraction, + ); + let _ = balance_sheet_operation_with_db(db, sender2, *txfee2, &type_str, operand_addition); + // Tips are paid in the swapped assets, so they must be reversed per asset. + let _ = + balance_sheet_operation_with_db(db, mining_receiver, *tip1, &asset1, operand_subtraction); + let _ = balance_sheet_operation_with_db(db, sender1, *tip1, &asset1, operand_addition); + let _ = + balance_sheet_operation_with_db(db, mining_receiver, *tip2, &asset2, operand_subtraction); + let _ = balance_sheet_operation_with_db(db, sender2, *tip2, &asset2, operand_addition); + // Reverse the actual two-sided asset exchange. + let _ = balance_sheet_operation_with_db(db, sender1, *value2, &asset2, operand_subtraction); + let _ = balance_sheet_operation_with_db(db, sender2, *value2, &asset2, operand_addition); + let _ = balance_sheet_operation_with_db(db, sender2, *value1, &asset1, operand_subtraction); + let _ = balance_sheet_operation_with_db(db, sender1, *value1, &asset1, operand_addition); + + // Convert the txid hash back to bytes for tree lookup/removal. + let hash = decode(&transaction.unsigned_swap.hash().await).unwrap(); + + // Remove the txid lookup for the rolled-back swap. + let tree = db.open_tree("txid").unwrap(); + let key = hash.clone(); + tree.remove(key).unwrap(); + + let nft_tree = db.open_tree("nfts").unwrap(); + // If either side of the swap was an NFT, remove this swap from that asset's + // provenance history as well. + if nft_tree.contains_key(asset1.as_bytes()).unwrap_or(false) { + let _ = remove_nft_history_entry(db, &asset1, &hash); + } + if nft_tree.contains_key(asset2.as_bytes()).unwrap_or(false) { + let _ = remove_nft_history_entry(db, &asset2, &hash); + } + +} diff --git a/src/orphans/undo_transactions/undo_transfer.rs b/src/orphans/undo_transactions/undo_transfer.rs new file mode 100644 index 0000000..0548681 --- /dev/null +++ b/src/orphans/undo_transactions/undo_transfer.rs @@ -0,0 +1,71 @@ +use crate::blocks::transfer::TransferTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::nft_assets::nft_asset_name; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::nft_provenance::remove_nft_history_entry; +use crate::sled::Db; + +pub async fn undo_transfer_transaction( + transaction: TransferTransaction, + mining_receiver: &str, + db: &Db, +) { + // reverse the transfer and fee movements, then remove the + // transfer transaction from chain state during rollback + let operand_subtraction = "subtraction"; + let operand_addition = "addition"; + let ( + _network_name, + _padded_base_coin, + type_str, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let (coin, nft_series, receiver, sender, value, txfee) = ( + &transaction.unsigned_transfer.coin, + &transaction.unsigned_transfer.nft_series, + &transaction.unsigned_transfer.receiver, + &transaction.unsigned_transfer.sender, + &transaction.unsigned_transfer.value, + &transaction.unsigned_transfer.txfee, + ); + let transfer_asset = nft_asset_name(coin, *nft_series); + + // Remove the miner fee, return the transferred asset to the sender, and + // refund the sender's base-coin fee. + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + *txfee, + &type_str, + operand_subtraction, + ); + let _ = + balance_sheet_operation_with_db(db, receiver, *value, &transfer_asset, operand_subtraction); + let _ = balance_sheet_operation_with_db(db, sender, *value, &transfer_asset, operand_addition); + let _ = balance_sheet_operation_with_db(db, sender, *txfee, &type_str, operand_addition); + + let hash = decode(&transaction.unsigned_transfer.hash().await).unwrap(); + + // Remove the txid lookup so the rolled-back transfer no longer resolves as + // an on-chain transaction. + let tree = db.open_tree("txid").unwrap(); + let key = hash.clone(); + tree.remove(key).unwrap(); + + // NFT transfers also write provenance, so remove this transfer from the + // asset history if the transferred asset is an NFT. + let nft_tree = db.open_tree("nfts").unwrap(); + if nft_tree + .contains_key(transfer_asset.as_bytes()) + .unwrap_or(false) + { + let _ = remove_nft_history_entry(db, &transfer_asset, &hash); + } + +} diff --git a/src/orphans/undo_transactions/undo_vanity.rs b/src/orphans/undo_transactions/undo_vanity.rs new file mode 100644 index 0000000..8504deb --- /dev/null +++ b/src/orphans/undo_transactions/undo_vanity.rs @@ -0,0 +1,70 @@ +use crate::blocks::vanity::VanityAddressTransaction; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + register_or_update_vanity_address, remove_registered_vanity_for_owner, + take_previous_vanity_for_txid, VanityRegistrationResult, +}; +use crate::sled::Db; + +pub async fn undo_vanity_transaction( + transaction: VanityAddressTransaction, + mining_receiver: &str, + db: &Db, +) -> Result<(), String> { + let txfee = transaction.unsigned_vanity_address.txfee; + let owner_address = transaction.unsigned_vanity_address.address.clone(); + let txhash = transaction.unsigned_vanity_address.hash().await; + + // Remove the miner fee and refund the vanity owner's base-coin fee. + let _ = balance_sheet_operation_with_db(db, mining_receiver, txfee, &BASECOIN, "subtraction"); + let _ = balance_sheet_operation_with_db(db, &owner_address, txfee, &BASECOIN, "addition"); + + // Vanity transactions record the owner's previous vanity state so rollback + // can restore it instead of only deleting the new vanity mapping. + let previous_vanity = take_previous_vanity_for_txid(db, &txhash) + .map_err(|err| format!("Could not load vanity rollback state: {err}"))?; + + // Remove the active vanity mapping written by the rolled-back transaction. + let _ = remove_registered_vanity_for_owner(db, &owner_address) + .map_err(|err| format!("Could not remove vanity mapping: {err}"))?; + + if let Some(Some(previous_vanity)) = previous_vanity { + // If the owner had a prior vanity, re-register it exactly as it existed + // before this transaction was applied. + match register_or_update_vanity_address(db, &owner_address, &previous_vanity) + .map_err(|err| format!("Could not restore previous vanity mapping: {err}"))? + { + VanityRegistrationResult::Inserted + | VanityRegistrationResult::Updated + | VanityRegistrationResult::AlreadyRegistered => {} + VanityRegistrationResult::Conflict => { + return Err( + "Could not restore previous vanity mapping because it now conflicts." + .to_string(), + ); + } + VanityRegistrationResult::OwnerNotRegistered => { + return Err("Could not restore previous vanity mapping because the owner is not registered.".to_string()); + } + VanityRegistrationResult::InvalidOwner => { + return Err("Could not restore previous vanity mapping because the owner address is invalid.".to_string()); + } + VanityRegistrationResult::InvalidVanity => { + return Err("Could not restore previous vanity mapping because the prior vanity is invalid.".to_string()); + } + } + } + + // Remove the vanity transaction lookup from the txid tree. + let tree = db + .open_tree("txid") + .map_err(|err| format!("Could not open txid tree during vanity undo: {err}"))?; + let txkey = decode(&txhash) + .map_err(|err| format!("Could not decode vanity txhash during undo: {err}"))?; + tree.remove(txkey) + .map_err(|err| format!("Could not remove vanity txid mapping during undo: {err}"))?; + + Ok(()) +} diff --git a/src/records/balance_sheet/get_wallet_balance.rs b/src/records/balance_sheet/get_wallet_balance.rs new file mode 100644 index 0000000..ea16558 --- /dev/null +++ b/src/records/balance_sheet/get_wallet_balance.rs @@ -0,0 +1,57 @@ +use crate::fs; +use crate::io; +use crate::records::balance_sheet::pathing::{address_root_path, balance_file_path}; +use crate::records::wallet_registry::resolve_canonical_registered_short_address; +use crate::sled::Db; +use crate::AsyncReadExt; +use crate::File; + +pub async fn get_balance(address: &str, coin_type: &str) -> Result { + // Invalid wallet keys should fail instead of creating accidental balance + // paths on disk. + if address_root_path(address).is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid wallet address for balance lookup", + )); + } + let file_path = balance_file_path(address, coin_type); + + let mut buffer = [0; 8]; + + if let Ok(mut file) = File::open(&file_path).await { + let metadata = file.metadata().await?; + if metadata.len() >= 8 { + // Balance files store exactly one little-endian u64 at the front. + file.read_exact(&mut buffer).await?; + return Ok(u64::from_le_bytes(buffer)); + } + return Ok(0); + } + + // Create the parent directory for first-time balances so later writes do + // not fail because the address/asset folder is missing. + if let Some(parent_dir) = file_path.parent() { + let _ = fs::create_dir_all(parent_dir); + } + + Ok(0) +} + +pub async fn get_balance_with_db( + db: &Db, + address: &str, + coin_type: &str, +) -> Result { + // Balance queries accept vanity/registered aliases, but storage always + // resolves back to the canonical short address. + let canonical_address = resolve_canonical_registered_short_address(db, address) + .map_err(|err| { + io::Error::other( + format!("Wallet registry lookup failed: {err}"), + ) + })? + .unwrap_or_else(|| address.to_string()); + + get_balance(&canonical_address, coin_type).await +} diff --git a/src/records/balance_sheet/mod.rs b/src/records/balance_sheet/mod.rs new file mode 100644 index 0000000..41d0a3c --- /dev/null +++ b/src/records/balance_sheet/mod.rs @@ -0,0 +1,5 @@ +// The balance_sheet module handles per-address coin and asset balance storage. +pub mod get_wallet_balance; +pub mod operations; +pub mod pathing; +pub mod tokens_to_lower; diff --git a/src/records/balance_sheet/operations.rs b/src/records/balance_sheet/operations.rs new file mode 100644 index 0000000..3ffa227 --- /dev/null +++ b/src/records/balance_sheet/operations.rs @@ -0,0 +1,153 @@ +use crate::fs; +use crate::io::{self, stdout, Read, Seek, Write}; +use crate::records::balance_sheet::pathing::{address_root_path, balance_file_path}; +use crate::records::wallet_registry::resolve_canonical_registered_short_address; +use crate::sled::Db; +use crate::OpenOptions; +use crate::Path; + +fn prune_empty_balance_directories(address: &str, file_path: &Path) { + // Remove empty nested balance directories after the last asset file for an + // address has been deleted. + let Some(address_root) = address_root_path(address) else { + return; + }; + let mut current = file_path.parent(); + + while let Some(dir) = current { + if dir == address_root.as_path() || dir.starts_with(&address_root) { + if fs::remove_dir(dir).is_ok() { + current = dir.parent(); + } else { + break; + } + } else { + break; + } + } +} + +pub fn balance_sheet_operation( + address: &str, + balance: u64, + coin_type: &str, + operand: &str, +) -> Result<(), io::Error> { + // Reject invalid wallet keys before touching the balance-sheet path. + if address_root_path(address).is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid wallet address for balance-sheet operation", + )); + } + let file_path = balance_file_path(address, coin_type); + let file_exists = Path::new(&file_path).exists(); + + if let Some(parent_dir) = file_path.parent() { + // Balance files are nested by address and asset, so parent folders may + // need to be created before the actual wallet.bal file can be opened. + fs::create_dir_all(parent_dir).map_err(|e| { + eprintln!("Error creating directory: {e}"); + e + })?; + } + + // Open the balance file in place so the existing 8-byte value can be + // replaced without changing the path. + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&file_path) + .map_err(|e| { + eprintln!("Error opening or creating file: {e}"); + e + })?; + + let mut buffer = [0; 8]; + let mut file_balance = if file_exists { + // Existing balances are stored as a single little-endian u64. + file.read_exact(&mut buffer).map_err(|e| { + eprintln!( + "Error reading file balance_sheet address {address}: {e}" + ); + e + })?; + u64::from_le_bytes(buffer) + } else { + 0 + }; + + // Apply the requested delta while preventing an unsigned underflow. + match operand { + "addition" => file_balance += balance, + "subtraction" => { + if balance > file_balance { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid operation: balance too large", + )); + } + file_balance -= balance; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid operand", + )) + } + } + + // Replace the stored balance from byte zero. + file.seek(std::io::SeekFrom::Start(0)).map_err(|e| { + eprintln!("Error seeking file: {e}"); + e + })?; + file.write_all(&file_balance.to_le_bytes()).map_err(|e| { + eprintln!("Error writing file: {e}"); + e + })?; + + stdout().flush().map_err(|e| { + eprintln!("Error flushing stdout: {e}"); + e + })?; + + // Keep nonzero balances on disk; zero balances are removed so empty asset + // folders do not accumulate forever. + if file_balance > 0 { + file.set_len(8).map_err(|e| { + eprintln!("Error truncating file: {e}"); + e + })?; + } else { + fs::remove_file(&file_path).map_err(|e| { + eprintln!("Error removing file: {e}"); + e + })?; + prune_empty_balance_directories(address, &file_path); + } + + Ok(()) +} + +pub fn balance_sheet_operation_with_db( + db: &Db, + address: &str, + balance: u64, + coin_type: &str, + operand: &str, +) -> Result<(), io::Error> { + // Vanity or alternate registered addresses resolve to the canonical short + // address before the filesystem balance is updated. + let canonical_address = resolve_canonical_registered_short_address(db, address) + .map_err(|err| { + io::Error::other( + format!("Wallet registry lookup failed: {err}"), + ) + })? + .unwrap_or_else(|| address.to_string()); + + balance_sheet_operation(&canonical_address, balance, coin_type, operand) +} diff --git a/src/records/balance_sheet/pathing.rs b/src/records/balance_sheet/pathing.rs new file mode 100644 index 0000000..902b033 --- /dev/null +++ b/src/records/balance_sheet/pathing.rs @@ -0,0 +1,92 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::records::balance_sheet::tokens_to_lower::strip_spaces_and_lowercase; +use crate::wallets::structures::Wallet; +use crate::PathBuf; + +pub fn network_name() -> &'static str { + let ( + network_name, + _padded_base_coin, + _suffix, + _torrent_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + network_name +} + +pub fn balance_root_path() -> PathBuf { + // The balance root is the configured balance-sheet directory scoped + // to the active network name. + let ( + _network_name, + _padded_base_coin, + _suffix, + _torrent_path, + _wallet_path, + _block_path, + _db_path, + balance_path, + _log_path, + ) = block_extension_and_paths(); + PathBuf::from(balance_path) +} + +pub fn canonical_balance_address(address: &str) -> Option { + // Balance storage is normalized to the deterministic short address, + // regardless of whether callers still pass a long or short address. + Wallet::normalize_to_short_address(address) +} + +pub fn address_root_path(address: &str) -> Option { + let canonical_address = canonical_balance_address(address)?; + Some(balance_root_path().join(canonical_address)) +} + +pub fn balance_asset_segments(coin_type: &str) -> Vec { + // NFT balances use a nested path of asset name plus item/series + // number, while normal coins and tokens stay as a single segment. + let coin = strip_spaces_and_lowercase(coin_type); + + if let Some((series_name, item_number)) = coin.rsplit_once('_') { + if !series_name.is_empty() + && !item_number.is_empty() + && item_number.chars().all(|c| c.is_ascii_digit()) + { + return vec![series_name.to_string(), item_number.to_string()]; + } + } + + vec![coin] +} + +pub fn balance_file_path(address: &str, coin_type: &str) -> PathBuf { + // Build the canonical wallet balance path for an address and asset + // using the current hierarchical balance-sheet layout. + let mut path = address_root_path(address).unwrap_or_else(balance_root_path); + for segment in balance_asset_segments(coin_type) { + path.push(segment); + } + path.push("wallet.bal"); + path +} + +pub fn asset_name_from_relative_path(relative_path: &std::path::Path) -> Option { + // Convert a relative balance-sheet file path back into the logical + // asset name used by wallet and balance queries. + let segments: Vec = relative_path + .iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect(); + + match segments.as_slice() { + [token_dir, wallet_file] if wallet_file == "wallet.bal" => Some(token_dir.clone()), + [token_dir, item_number, wallet_file] if wallet_file == "wallet.bal" => { + Some(format!("{token_dir}_{item_number}")) + } + _ => None, + } +} diff --git a/src/records/balance_sheet/tokens_to_lower.rs b/src/records/balance_sheet/tokens_to_lower.rs new file mode 100644 index 0000000..deb1625 --- /dev/null +++ b/src/records/balance_sheet/tokens_to_lower.rs @@ -0,0 +1,9 @@ +pub fn strip_spaces_and_lowercase(input: &str) -> String { + // Normalize asset names so balance-sheet paths and lookups are insensitive + // to padding and case differences. + input + .chars() + .filter(|&c| !c.is_whitespace()) + .flat_map(char::to_lowercase) + .collect() +} diff --git a/src/records/block_height/decrease_block_height.rs b/src/records/block_height/decrease_block_height.rs new file mode 100644 index 0000000..975940f --- /dev/null +++ b/src/records/block_height/decrease_block_height.rs @@ -0,0 +1,14 @@ +use crate::sled::Db; +use crate::sled::IVec; + +pub fn decrease_height(new_height: u32, db: &Db) { + // Overwrite the persisted chain height after orphan rollback has removed + // one or more local blocks. + let db_clone = db.clone(); + + let key = b"height"; + + // Store height in the same little-endian u32 layout used by get/increase. + let value_bytes = new_height.to_le_bytes(); + let _ = db_clone.insert(key, IVec::from(&value_bytes[..])); +} diff --git a/src/records/block_height/get_block_height.rs b/src/records/block_height/get_block_height.rs new file mode 100644 index 0000000..31223d3 --- /dev/null +++ b/src/records/block_height/get_block_height.rs @@ -0,0 +1,40 @@ +use crate::sled::Db; +use crate::sled::IVec; + +pub fn get_height(db: &Db) -> u32 { + // Initialize the height key on first use so callers can always treat the + // chain height as present. + let db_clone = db.clone(); + let key = b"height"; + + let current_height_bytes = match db.get(key) { + Ok(Some(bytes)) => bytes, + Ok(None) => { + let new_value: u32 = 0; + let value_bytes = new_value.to_le_bytes(); + let _ = db_clone.insert(key, IVec::from(&value_bytes[..])); + db_clone + .get(key) + .expect("Failed to retrieve the newly inserted value") + .unwrap_or_default() + } + Err(error) => { + eprintln!("Error fetching key 'height': {error:?}"); + return 0; + } + }; + + // Decode the stored 4-byte little-endian height and fall back to genesis + // height if the stored data is malformed. + if current_height_bytes.len() == 4 { + u32::from_le_bytes([ + current_height_bytes[0], + current_height_bytes[1], + current_height_bytes[2], + current_height_bytes[3], + ]) + } else { + eprintln!("Unexpected byte length for current height"); + 0 + } +} diff --git a/src/records/block_height/increase_block_height.rs b/src/records/block_height/increase_block_height.rs new file mode 100644 index 0000000..1dcd613 --- /dev/null +++ b/src/records/block_height/increase_block_height.rs @@ -0,0 +1,23 @@ +use crate::records::block_height::get_block_height::get_height; +use crate::sled::Db; +use crate::sled::IVec; + +pub fn increase_height(db: &Db) -> Result { + // Increment the persisted chain height after a block has been successfully + // saved to local storage. + let db_clone = db.clone(); + + let key = b"height"; + + let current_height = get_height(db); + + let new_value = current_height + 1; + + // Store height as a fixed little-endian u32 in sled. + let value_bytes = new_value.to_le_bytes(); + db_clone + .insert(key, IVec::from(&value_bytes[..])) + .map_err(|err| format!("Failed to increase chain height: {err}"))?; + + Ok(new_value) +} diff --git a/src/records/block_height/mod.rs b/src/records/block_height/mod.rs new file mode 100644 index 0000000..ba9c596 --- /dev/null +++ b/src/records/block_height/mod.rs @@ -0,0 +1,4 @@ +// The block_height module tracks the saved chain height in persistent storage. +pub mod decrease_block_height; +pub mod get_block_height; +pub mod increase_block_height; diff --git a/src/records/ip_score/ban_management.rs b/src/records/ip_score/ban_management.rs new file mode 100644 index 0000000..e2f4369 --- /dev/null +++ b/src/records/ip_score/ban_management.rs @@ -0,0 +1,57 @@ +use crate::common::skein::skein_256_hash_data; +use crate::log::error; +use crate::records::ip_score::get_score::get_ip_score_timestamp; +use crate::rpc::commands::unblock_peer_ip::unblock_peer; +use crate::sled::Db; +use crate::sleep; +use crate::wallets::structures::Wallet; +use crate::Duration; + +pub async fn sign_ip_to_ban(ip: &str, wallet_key: &str) -> String { + // Ban and unban operations reuse the wallet signature flow so peer actions + // can be authenticated by other nodes. + let wallet = match Wallet::try_obtain_wallet(wallet_key.to_string(), None).await { + Ok(wallet) => wallet, + Err(err) => { + error!("Wallet decryption failed while signing IP ban: {err}"); + return String::new(); + } + }; + let privkey = &wallet.saved.private_key; + // The signature is over the IP hash, not the raw IP string. + let ip_hash = skein_256_hash_data(ip); + let signature = Wallet::sign_transaction(&ip_hash, privkey).await; + signature +} + +pub fn spawn_unban(db: Db, ip: String, signature: String, wallet_key: String, duration: Duration) { + // Timed unbans are scheduled in the background so temporary bans can expire + // automatically without blocking the caller. + tokio::spawn(async move { + sleep(duration).await; + unblock_peer(&db, ip.to_string(), signature, wallet_key.to_string()).await; + }); +} + +pub fn point_removal(db: Db, points: u8, ip: String, duration: Duration) { + // Point decay runs in the background so IP scores can recover over time. + tokio::spawn(async move { + sleep(duration).await; + let _ = remove_points(&db, ip.to_string(), points).await; + }); +} + +async fn remove_points(db: &Db, key: String, points: u8) -> sled::Result<()> { + // Point removal updates the stored score while preserving the original + // timestamp bytes. + if let Some((db_score, timestamp)) = get_ip_score_timestamp(&key, db).await? { + let score = db_score.saturating_sub(points); + let mut value = Vec::with_capacity(5); + value.push(score); + value.extend_from_slice(×tamp.to_le_bytes()); + + let tree = db.open_tree("ip_rep_system")?; + tree.insert(key, value)?; + } + Ok(()) +} diff --git a/src/records/ip_score/enums.rs b/src/records/ip_score/enums.rs new file mode 100644 index 0000000..43bffaf --- /dev/null +++ b/src/records/ip_score/enums.rs @@ -0,0 +1,9 @@ +// InfractionType keeps the IP scoring inputs typed all the way to the scoring function. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InfractionType { + BadRpcCall, + BadMinerIpUpdate, + BadTorrent, + RpcFloodAttack, + BadBlock, +} diff --git a/src/records/ip_score/get_score.rs b/src/records/ip_score/get_score.rs new file mode 100644 index 0000000..c02b67b --- /dev/null +++ b/src/records/ip_score/get_score.rs @@ -0,0 +1,19 @@ +use crate::sled::Db; + +pub async fn get_ip_score_timestamp(ip: &str, db: &Db) -> sled::Result> { + // IP scores are stored as one score byte followed by the last-update timestamp. + let tree = db.open_tree("ip_rep_system")?; + let key = ip.as_bytes(); + + if let Some(value) = tree.get(key)? { + let bytes = value.as_ref(); + + if bytes.len() == 5 { + let score = bytes[0]; + let timestamp = u32::from_le_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + return Ok(Some((score, timestamp))); + } + } + + Ok(None) +} diff --git a/src/records/ip_score/mod.rs b/src/records/ip_score/mod.rs new file mode 100644 index 0000000..cbb654b --- /dev/null +++ b/src/records/ip_score/mod.rs @@ -0,0 +1,6 @@ +// The ip_score module manages peer reputation, penalties, and ban state. +pub mod ban_management; +pub mod enums; +pub mod get_score; +pub mod penalty; +pub mod score; diff --git a/src/records/ip_score/penalty.rs b/src/records/ip_score/penalty.rs new file mode 100644 index 0000000..3cfa781 --- /dev/null +++ b/src/records/ip_score/penalty.rs @@ -0,0 +1,105 @@ +use crate::log::warn; +use crate::records::ip_score::ban_management::{sign_ip_to_ban, spawn_unban}; +use crate::records::memory::connections::CONNECTIONS; +use crate::records::memory::enums::ClientType; +use crate::rpc::commands::block_peer_ip::block_peer; +use crate::sled::Db; +use crate::Duration; + +pub async fn issue_penalty( + score: u8, + ip: &str, + client_type: &str, + wallet_key: &str, + db: &Db, +) -> String { + // Penalties only matter for active known connections, so resolve the + // reported client type before taking action. + let mut guard = CONNECTIONS.write().await; + let Ok(client_type) = client_type.parse::() else { + return "No action taken".to_string(); + }; + + let signature = sign_ip_to_ban(ip, wallet_key).await; + if let Some(conn) = guard.as_mut() { + if let Some((connection_type, port)) = + conn.find_connection_info_by_client_type(ip, client_type) + { + // Higher scores escalate from a dropped connection to temporary and + // then permanent bans. + if score > 100 { + warn!("[ip_score] permanently banning ip={ip} score={score}"); + block_peer( + db, + ip.to_string(), + signature.to_string(), + wallet_key.to_string(), + ) + .await; + return format!("IP {ip} permanently banned"); + } else if score > 75 { + warn!("[ip_score] banning ip={ip} duration=24h score={score}"); + + block_peer( + db, + ip.to_string(), + signature.to_string(), + wallet_key.to_string(), + ) + .await; + spawn_unban( + db.clone(), + ip.to_string(), + signature.to_string(), + wallet_key.to_string(), + Duration::from_secs(86400), + ); + return format!("IP {ip} banned for 24 hours"); + } else if score > 50 { + warn!("[ip_score] banning ip={ip} duration=1h score={score}"); + + block_peer( + db, + ip.to_string(), + signature.to_string(), + wallet_key.to_string(), + ) + .await; + spawn_unban( + db.clone(), + ip.to_string(), + signature.to_string(), + wallet_key.to_string(), + Duration::from_secs(3600), + ); + return format!("IP {ip} banned for 1 hour"); + } else if score > 30 { + warn!("[ip_score] banning ip={ip} duration=30m score={score}"); + + block_peer( + db, + ip.to_string(), + signature.to_string(), + wallet_key.to_string(), + ) + .await; + spawn_unban( + db.clone(), + ip.to_string(), + signature.to_string(), + wallet_key.to_string(), + Duration::from_secs(1800), + ); + return format!("IP {ip} banned for 30 minutes"); + } else if score > 10 { + warn!("[ip_score] dropping connection ip={ip} score={score}"); + + // Low-level penalties disconnect the peer but do not add a ban + // record yet. + conn.drop_connection(connection_type, ip.to_string(), port); + return format!("IP {ip} dropped due to score {score}"); + } + } + } + "No action taken".to_string() +} diff --git a/src/records/ip_score/score.rs b/src/records/ip_score/score.rs new file mode 100644 index 0000000..a2691ce --- /dev/null +++ b/src/records/ip_score/score.rs @@ -0,0 +1,164 @@ +use crate::log::warn; +use crate::records::ip_score::ban_management::point_removal; +use crate::records::ip_score::enums::InfractionType; +use crate::records::ip_score::get_score::get_ip_score_timestamp; +use crate::records::ip_score::penalty::issue_penalty; +use crate::sled::Db; +use crate::Duration; + +fn score_subject(ip: &str, client_type: &str) -> String { + // Wallet-backed clients may reconnect from changing IPs, so the + // score key treats them as a separate logical subject. + if client_type == "client" { + format!("client:{ip}") + } else { + ip.to_string() + } +} + +pub async fn update_ip_score( + ip: &str, + client_type: &str, + infraction_type: InfractionType, + timestamp: u32, + db: &Db, + wallet_key: &str, +) -> sled::Result<()> { + // Convert the incoming event into a new score and persist the latest + // score/timestamp pair before penalty handling runs. + let subject = score_subject(ip, client_type); + let key = subject.as_bytes(); + let previous_score = get_ip_score_timestamp(&subject, db) + .await? + .map(|(score, _)| score) + .unwrap_or(0); + let score = match infraction_type { + InfractionType::BadRpcCall => bad_rpc_call(&subject, timestamp, db).await, + InfractionType::BadMinerIpUpdate => bad_miner_ip_update(&subject, timestamp, db).await?, + InfractionType::BadTorrent => bad_torrent(&subject, db).await, + InfractionType::RpcFloodAttack => rpc_flood_attack(&subject, db).await, + InfractionType::BadBlock => bad_block(&subject, db).await, + }; + + // Serialize as 5 bytes: [score (1 byte) | timestamp (4 bytes)] + let mut value = Vec::with_capacity(5); + value.push(score); + value.extend_from_slice(×tamp.to_le_bytes()); + + let tree = db.open_tree("ip_rep_system")?; + tree.insert(key, value)?; + + warn!("[ip_score] update ip={ip} client_type={client_type} subject={subject} infraction={infraction_type:?} previous_score={previous_score} new_score={score} timestamp={timestamp}"); + + // Penalty handling is driven from the updated score so actions like + // temporary bans always reflect the most recent infraction state. + let action = issue_penalty(score, ip, client_type, wallet_key, db).await; + if action != "No action taken" { + warn!("[ip_score] penalty ip={ip} client_type={client_type} subject={subject} infraction={infraction_type:?} previous_score={previous_score} new_score={score} action={action}"); + } + + Ok(()) +} + +async fn bad_torrent(ip: &str, db: &Db) -> u8 { + // Invalid torrent activity is expensive to recover from, so it + // starts with a heavier penalty and a longer decay period. + let mut score: u8 = 15; + + if let Ok(Some((db_score, _))) = get_ip_score_timestamp(ip, db).await { + score = score.saturating_add(db_score); + } + point_removal(db.clone(), score, ip.to_string(), Duration::from_secs(3600)); + score +} + +async fn rpc_flood_attack(ip: &str, db: &Db) -> u8 { + // RPC flooding uses a fixed short-lived penalty that can stack + // quickly without sticking around for days. + let added_points: u8 = 10; + let mut score: u8 = added_points; + + if let Ok(Some((db_score, _))) = get_ip_score_timestamp(ip, db).await { + score = score.saturating_add(db_score); + } + point_removal( + db.clone(), + added_points, + ip.to_string(), + Duration::from_secs(600), + ); + score +} + +async fn bad_block(ip: &str, db: &Db) -> u8 { + // Bad blocks are the highest-severity score event in this system + // because they directly attack consensus and validation work. + let mut score: u8 = 40; + + if let Ok(Some((db_score, _))) = get_ip_score_timestamp(ip, db).await { + score = score.saturating_add(db_score); + } + point_removal( + db.clone(), + score, + ip.to_string(), + Duration::from_secs(172800), + ); + score +} + +async fn bad_miner_ip_update(ip: &str, now: u32, db: &Db) -> sled::Result { + // Repeated node-IP edits ramp up quickly when they arrive too close + // together, discouraging churn or abuse in the network map. + let mut added_points: u8 = 1; + let mut score: u8 = added_points; + + if let Some((db_score, last_timestamp)) = get_ip_score_timestamp(ip, db).await? { + let elapsed = now.saturating_sub(last_timestamp); // avoid underflow + + if elapsed < 2 { + added_points = added_points.saturating_add(6); + } else if elapsed < 15 { + added_points = added_points.saturating_add(4); + } else if elapsed < 60 { + added_points = added_points.saturating_add(2); + } else if elapsed < 120 { + added_points = added_points.saturating_add(1); + } + + score = db_score.saturating_add(added_points); + } + point_removal( + db.clone(), + added_points, + ip.to_string(), + Duration::from_secs(600), + ); + Ok(score) +} + +async fn bad_rpc_call(ip: &str, now: u32, db: &Db) -> u8 { + // Bad RPC calls increase in severity when they arrive in bursts, + // but decay quickly so occasional mistakes do not linger long. + let mut added_points: u8 = 1; + let mut score: u8 = added_points; + + if let Ok(Some((db_score, last_timestamp))) = get_ip_score_timestamp(ip, db).await { + let elapsed = now.saturating_sub(last_timestamp); // avoid underflow + + if elapsed < 2 { + added_points = added_points.saturating_add(2); + } else if elapsed < 15 { + added_points = added_points.saturating_add(1); + } + + score = db_score.saturating_add(added_points); + } + point_removal( + db.clone(), + added_points, + ip.to_string(), + Duration::from_secs(300), + ); + score +} diff --git a/src/records/memory/averages.rs b/src/records/memory/averages.rs new file mode 100644 index 0000000..386a979 --- /dev/null +++ b/src/records/memory/averages.rs @@ -0,0 +1,161 @@ +use crate::blocks::block::DIFFICULTY_OFFSET; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::lazy_static; +use crate::Duration; +use crate::HashMap; +use crate::Mutex; +use crate::PathBuf; + +pub const DIFFICULTY_AVERAGE_WINDOW: u32 = 254; + +lazy_static! { + static ref AVERAGE_DATA: Mutex> = Mutex::new(HashMap::new()); +} + +pub async fn load_initial_blocks(start: u32, stop: u32) { + // Rebuild the rolling average cache from disk, keeping only the + // most recent rolling difficulty window needed by the algorithm. + let mut cache = AVERAGE_DATA.lock().await; + *cache = HashMap::new(); // Clear and reset the cache + let ( + _network_name, + _padded_base_coin, + file_ext, + _torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + + for block_num in start..=stop { + let file_path = PathBuf::from(&block_path).join(format!("{block_num}.{file_ext}")); + + if let Ok(file_content) = tokio::fs::read(file_path).await { + let timestamp = if file_content.len() >= 4 { + u32::from_le_bytes(file_content[0..4].try_into().unwrap_or_default()) + } else { + 0 + }; + + let difficulty = if file_content.len() >= DIFFICULTY_OFFSET + 8 { + u64::from_le_bytes( + file_content[DIFFICULTY_OFFSET..DIFFICULTY_OFFSET + 8] + .try_into() + .unwrap_or_default(), + ) + } else { + 0 + }; + cache.insert(block_num, (timestamp, difficulty)); + + // Ensure only the configured rolling window is kept if starting from a larger range. + if cache.len() > DIFFICULTY_AVERAGE_WINDOW as usize { + let oldest_block = cache.keys().min().copied(); + if let Some(oldest) = oldest_block { + cache.remove(&oldest); + } + } + } + } +} + +pub async fn update_block_data(block_num: u32) { + let ( + _network_name, + _padded_base_coin, + file_ext, + _torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + + // Avoid re-reading blocks that are already present in the rolling cache. + let cache = AVERAGE_DATA.lock().await; + if cache.contains_key(&block_num) { + return; + } + + drop(cache); + + let file_path = PathBuf::from(&block_path).join(format!("{block_num}.{file_ext}")); + + if let Ok(file_content) = tokio::fs::read(file_path).await { + let timestamp = if file_content.len() >= 4 { + u32::from_le_bytes(file_content[0..4].try_into().unwrap_or_default()) + } else { + 0 + }; + + let difficulty = if file_content.len() >= DIFFICULTY_OFFSET + 8 { + u64::from_le_bytes( + file_content[DIFFICULTY_OFFSET..DIFFICULTY_OFFSET + 8] + .try_into() + .unwrap_or_default(), + ) + } else { + 0 + }; + + // Reinsert under the cache lock and trim back to the rolling window. + let mut cache = AVERAGE_DATA.lock().await; + cache.insert(block_num, (timestamp, difficulty)); + + if cache.len() > DIFFICULTY_AVERAGE_WINDOW as usize { + let oldest_block = cache.keys().min().copied(); + if let Some(oldest) = oldest_block { + cache.remove(&oldest); + } + } + } +} + +async fn calculate_time_differences(latest_timestamp: u32) -> Vec { + // Build the interval list from the cached block timestamps plus + // the candidate block timestamp being evaluated right now. + let cache = AVERAGE_DATA.lock().await; + let mut timestamps: Vec<_> = cache.values().map(|&(timestamp, _)| timestamp).collect(); + + timestamps.push(latest_timestamp); + + timestamps.sort(); + + timestamps + .windows(2) + .map(|w| Duration::from_secs((w[1] - w[0]) as u64)) + .collect() +} + +async fn calculate_mean_difficulty() -> u64 { + // Difficulty smoothing uses the rolling mean of the cached prior + // block difficulties rather than just the current tip value. + let cache = AVERAGE_DATA.lock().await; + let difficulties: Vec<_> = cache.values().map(|&(_, difficulty)| difficulty).collect(); + + if difficulties.is_empty() { + 0 + } else { + let total: u128 = difficulties + .iter() + .map(|&difficulty| difficulty as u128) + .sum(); + let average = total / difficulties.len() as u128; + average.min(u64::MAX as u128) as u64 + } +} + +pub async fn calculate_averages(current_timestamp: u32) -> (u64, Duration) { + // Combine the rolling time intervals and rolling mean difficulty + // into the aggregate inputs used by difficulty adjustment. + let time_differences = calculate_time_differences(current_timestamp).await; + let total_duration: Duration = time_differences.iter().sum(); + let average_duration = total_duration / (time_differences.len() as u32); + + let mean_difficulty = calculate_mean_difficulty().await; + + (mean_difficulty, average_duration) +} diff --git a/src/records/memory/connections.rs b/src/records/memory/connections.rs new file mode 100644 index 0000000..621c1c8 --- /dev/null +++ b/src/records/memory/connections.rs @@ -0,0 +1,583 @@ +use crate::common::binary_conversions::{binary_to_ip, ip_to_binary}; +use crate::lazy_static; +use crate::log::{info, warn}; +use crate::records::memory::enums::{ClientType, ConnectionType}; +use crate::records::memory::response_channels::{delete_entry, reserve_entry, Command}; +use crate::records::memory::structs::{Connection, StoreConnectionParams}; +use crate::rpc::client::handshake_processing::{bootstrap_peer_discovery, BootstrapParams}; +use crate::rpc::command_maps::RPC_BLOCK_HEIGHT; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::sleep; +use crate::thread_rng; +use crate::timeout; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::AsyncWriteExt; +use crate::AtomicBool; +use crate::AtomicOrdering; +use crate::Duration; +use crate::IteratorRandom; +use crate::Mutex; +use crate::RwLock; +use crate::TcpStream; + +fn split_ip_port_key(value: &str) -> Option<(String, u16)> { + // Connection keys are stored as ip:port strings; IPv6 addresses may arrive + // bracketed, so strip brackets before parsing the port. + let (ip_part, port_part) = value.rsplit_once(':')?; + let ip = ip_part + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + .unwrap_or(ip_part) + .to_string(); + let port = port_part.parse::().ok()?; + Some((ip, port)) +} + +use crate::records::memory::structs::{ConnectionInfo, ConnectionKey}; + +#[derive(Clone)] +struct ReconnectContext { + db: Db, + wallet_key: String, + map: Arc>, +} + +lazy_static! { + static ref RECONNECT_CONTEXT: Mutex> = Mutex::new(None); + static ref RECONNECT_IN_PROGRESS: AtomicBool = AtomicBool::new(false); +} + +fn try_start_reconnect() -> bool { + // Only one reconnect path should run at a time, whether it came from + // liveness failure or bootstrap recovery. + RECONNECT_IN_PROGRESS + .compare_exchange(false, true, AtomicOrdering::SeqCst, AtomicOrdering::SeqCst) + .is_ok() +} + +fn finish_reconnect() { + // Release the reconnect gate after the async reconnect attempt finishes. + RECONNECT_IN_PROGRESS.store(false, AtomicOrdering::SeqCst); +} + +pub async fn set_reconnect_context( + db: Db, + wallet_key: String, + map: Arc>, +) { + let mut context = RECONNECT_CONTEXT.lock().await; + // Store enough state for later liveness checks to reconnect without + // needing the original startup stack. + *context = Some(ReconnectContext { + db, + wallet_key, + map, + }); +} + +async fn reconnect_dropped_outgoing(excluded_ip: &str) { + if !try_start_reconnect() { + warn!("[reconnect] replacement attempt already in progress, skipping duplicate request"); + return; + } + + async { + // When an outgoing peer disappears, try to replace it with another + // active node that is not already connected and is not the failed IP. + let context = { + let guard = RECONNECT_CONTEXT.lock().await; + guard.clone() + }; + + let Some(context) = context else { + warn!("[reconnect] no reconnect context configured"); + return; + }; + + let excluded_ip_bytes = ip_to_binary(excluded_ip); + let live_connection = { + let guard = CONNECTIONS.read().await; + guard.as_ref().and_then(|conn| { + conn.connection_map.iter().find_map(|(key, info)| { + if key.ip == excluded_ip_bytes { + return None; + } + if ClientType::from_bytes(&info.client_type) != Some(ClientType::Miner) { + return None; + } + let ip = binary_to_ip(key.ip.clone()); + let connections_key = format!("{}:{}", ip, key.port); + Some((connections_key, Arc::clone(&info.stream))) + }) + }) + }; + + let Some((connections_key, stream)) = live_connection else { + warn!("[reconnect] no live stream available for bootstrap recovery"); + return; + }; + + let bootstrap_params = BootstrapParams { + stream, + connections_key, + wallet_key: context.wallet_key, + db: context.db, + map: context.map, + first: false, + }; + + if let Err(err) = bootstrap_peer_discovery(bootstrap_params).await { + warn!("[reconnect] bootstrap recovery failed: {err}"); + } + } + .await; + + finish_reconnect(); +} + +pub fn spawn_reconnect_bootstrap(params: BootstrapParams) { + if !try_start_reconnect() { + warn!("[reconnect] bootstrap recovery already in progress, skipping duplicate request"); + return; + } + + // Bootstrap discovery can perform network requests, so it runs detached + // from the caller that noticed the connection problem. + tokio::spawn(async move { + if let Err(err) = bootstrap_peer_discovery(params).await { + warn!("[reconnect] bootstrap recovery failed: {err}"); + } + finish_reconnect(); + }); +} + +impl Connection { + // Initialize the in-memory connection manager state. + pub fn new() -> Self { + Self::default() + } + + // Store a live socket in memory along with its role, peer identity, + // and session metadata used by the RPC and peer-management paths. + pub fn store_connection(&mut self, params: StoreConnectionParams) -> bool { + let StoreConnectionParams { + connection_type, + ip, + port, + stream, + client_type, + wallet_address, + command_map, + } = params; + + let ip_bytes = ip_to_binary(&ip); + let connection_key = ConnectionKey { + connection_type: connection_type.as_bytes(), + ip: ip_bytes.clone(), + port, + }; + + let connection_key2 = ConnectionKey { + connection_type: connection_type.opposite().as_bytes(), + ip: ip_bytes.clone(), + port, + }; + + // Miner nodes are identified by IP, not by port. A second node + // announcing the same IP is rejected even if it uses another + // socket port. + if client_type == ClientType::Miner + && self.connection_map.iter().any(|(key, info)| { + key.ip == ip_bytes + && ClientType::from_bytes(&info.client_type) == Some(ClientType::Miner) + }) + { + return false; + } + + // Non-miner RPC clients still use the full socket key so short + // request/response connections do not collide unnecessarily. + if self.connection_map.contains_key(&connection_key) + || self.connection_map.contains_key(&connection_key2) + { + return false; + } + + let address = Wallet::long_address_to_bytes(wallet_address); + if address.len() != Wallet::ADDRESS_BYTES_LENGTH { + return false; + } + let connection_info = ConnectionInfo::new( + connection_type.as_bytes(), + ip_bytes, + port, + stream.clone(), + client_type.as_bytes(), + address, + ); + self.connection_map.insert(connection_key, connection_info); + + if client_type == ClientType::Miner { + Connection::client_checkup(stream, connection_type, ip, port, command_map); + } + true + } + + // Remove a specific connection entry by direction, IP, and port. + pub fn drop_connection( + &mut self, + connection_type: ConnectionType, + ip: String, + port: u16, + ) -> Option { + let ip_bytes = ip_to_binary(&ip); + let connection_key = ConnectionKey { + connection_type: connection_type.as_bytes(), + ip: ip_bytes, + port, + }; + let removed = self.connection_map.remove(&connection_key); + if let Some(connection_info) = removed.as_ref() { + let stream = Arc::clone(&connection_info.stream); + tokio::spawn(async move { + let mut stream_guard = stream.lock().await; + let _ = stream_guard.shutdown().await; + }); + } + if let Some(connection_info) = removed.as_ref() { + let client_role = ClientType::from_bytes(&connection_info.client_type) + .map(|client_type| client_type.as_str()) + .unwrap_or("unknown"); + info!( + "[connection_manager] connection dropped: role={} direction={} peer={}:{}", + client_role, + connection_type.as_str(), + ip, + port + ); + } + removed + } + + pub fn client_checkup( + stream: Arc>, + connection_type: ConnectionType, + ip: String, + port: u16, + command_map: Arc>, + ) { + tokio::spawn(async move { + loop { + sleep(Duration::from_secs(30)).await; + let still_registered = { + let guard = CONNECTIONS.read().await; + guard + .as_ref() + .map(|conn| { + let connection_key = ConnectionKey { + connection_type: connection_type.as_bytes(), + ip: ip_to_binary(&ip), + port, + }; + conn.connection_map.contains_key(&connection_key) + }) + .unwrap_or(false) + }; + + if !still_registered { + break; + } + + let message_type = RPC_BLOCK_HEIGHT; // Block-height request used as a lightweight checkup ping. + let (checkup_key, _checkup_tx, checkup_rx_mutex) = + reserve_entry(command_map.clone()).await; + + // Send a lightweight ping message and wait for the reply + // routed back through the shared response hashmap. + let mut message: Vec = Vec::with_capacity(4); + message.push(message_type); + message.extend_from_slice(&checkup_key); + + RpcResponse::send_raw(&stream, None, &message).await; + + let response_result = { + let mut checkup_rx = checkup_rx_mutex.lock().await; + timeout(Duration::from_secs(30), checkup_rx.recv()).await + }; + + match response_result { + Ok(Some(_reply)) => { + info!( + "[connection_manager] liveness check ok: type={} peer={}:{}", + connection_type.as_str(), + ip, + port + ); + } + _ => { + let still_registered = { + let guard = CONNECTIONS.read().await; + guard + .as_ref() + .map(|conn| { + let connection_key = ConnectionKey { + connection_type: connection_type.as_bytes(), + ip: ip_to_binary(&ip), + port, + }; + conn.connection_map.contains_key(&connection_key) + }) + .unwrap_or(false) + }; + + if !still_registered { + delete_entry(command_map.clone(), checkup_key).await; + break; + } + + // Timed-out or missing replies drop the connection, + // and outgoing peers trigger replacement discovery. + warn!( + "[connection_manager] liveness check failed: type={} peer={}:{}", + connection_type.as_str(), + ip, + port + ); + delete_entry(command_map.clone(), checkup_key).await; + let mut guard = CONNECTIONS.write().await; + if let Some(conn) = guard.as_mut() { + conn.drop_connection(connection_type, ip.clone(), port); + } + drop(guard); + if connection_type == ConnectionType::Outgoing { + reconnect_dropped_outgoing(&ip).await; + } + break; + } + } + } + }); + } + + // Count active incoming peer connections. + pub fn count_incoming_connections(&self) -> usize { + self.connection_map + .values() + .filter(|info| { + ConnectionType::from_bytes(&info.connection_type) == Some(ConnectionType::Incoming) + }) + .count() + } + + // Count active outgoing peer connections. + pub fn count_outgoing_connections(&self) -> usize { + self.connection_map + .values() + .filter(|info| { + ConnectionType::from_bytes(&info.connection_type) == Some(ConnectionType::Outgoing) + }) + .count() + } + + // Return all live peer streams so broadcast-style paths can fan out + // messages without caring whether a peer was incoming or outgoing. + pub fn get_all_streams(&self) -> Vec>> { + self.connection_map + .values() + .filter(|connection_info| { + ClientType::from_bytes(&connection_info.client_type) == Some(ClientType::Miner) + }) + .map(|connection_info| Arc::clone(&connection_info.stream)) + .collect() + } + + // Return all non-client peer streams so network-wide broadcasts can + // reach every reachable chain peer. + pub fn get_all_peer_streams(&self) -> Vec>> { + self.connection_map + .values() + .filter(|connection_info| { + ClientType::from_bytes(&connection_info.client_type) == Some(ClientType::Miner) + }) + .map(|connection_info| Arc::clone(&connection_info.stream)) + .collect() + } + + // Resolve a stored outgoing node connection back to its live stream. + pub fn get_stream_for_outgoing(&self, ip: &str, port: u16) -> Option>> { + let ip_bytes = ip_to_binary(ip); + let connection_key = ConnectionKey { + connection_type: ConnectionType::Outgoing.as_bytes(), + ip: ip_bytes, + port, + }; + self.connection_map + .get(&connection_key) + .filter(|info| { + ClientType::from_bytes(&info.client_type) == Some(ClientType::Miner) + }) + .map(|info| Arc::clone(&info.stream)) + } + + // Look up a live miner stream by the exact ip:port connection key. + // Network-map records only store bare IPs, so they must not be used + // to select an arbitrary live socket. + pub async fn get_stream_from_memory(key: &str) -> Option>> { + let (ip, port) = split_ip_port_key(key)?; + let lock = CONNECTIONS.read().await; + let conn = lock.as_ref()?; + + let ip_bytes = ip_to_binary(&ip); + conn.connection_map.iter().find_map(|(connection_key, info)| { + if connection_key.ip == ip_bytes + && connection_key.port == port + && ClientType::from_bytes(&info.client_type) == Some(ClientType::Miner) + { + Some(Arc::clone(&info.stream)) + } else { + None + } + }) + } + + // Build the serialized connection key for a live stream when only + // the stream handle is known. + pub fn connection_key_for_stream(&self, stream: &Arc>) -> Option { + self.connection_map + .iter() + .find_map(|(connection_key, connection_info)| { + if Arc::ptr_eq(&connection_info.stream, stream) { + let ip = binary_to_ip(connection_key.ip.clone()); + Some(format!("{}:{}", ip, connection_key.port)) + } else { + None + } + }) + } + + // Find the first stored connection record for the requested IP. + pub fn find_connection_info(&self, ip: &str) -> Option<(ConnectionType, u16)> { + let ip_bytes = ip_to_binary(ip); + + for (key, _info) in self.connection_map.iter() { + if key.ip == ip_bytes { + let connection_type = ConnectionType::from_bytes(&key.connection_type)?; + return Some((connection_type, key.port)); + } + } + None + } + + // Find a stored connection by IP, constrained to a specific client role. + pub fn find_connection_info_by_client_type( + &self, + ip: &str, + client_type: ClientType, + ) -> Option<(ConnectionType, u16)> { + let ip_bytes = ip_to_binary(ip); + let client_type_bytes = client_type.as_bytes(); + + for (key, info) in self.connection_map.iter() { + if key.ip == ip_bytes && info.client_type == client_type_bytes { + let connection_type = ConnectionType::from_bytes(&key.connection_type)?; + return Some((connection_type, key.port)); + } + } + None + } + + // Find the stored outgoing port for a peer IP so reconnect and + // cleanup logic can target the correct connection entry. + pub fn find_outgoing_port(&self, ip: &str) -> Option { + let ip_bytes = ip_to_binary(ip); + self.connection_map + .iter() + .find(|(key, _)| { + key.connection_type == ConnectionType::Outgoing.as_bytes() && key.ip == ip_bytes + }) + .map(|(key, _)| key.port) + } + + // Prefer a random incoming node connection, falling back to an + // outgoing node connection when no incoming peer is available. + pub fn get_random_connection(&self, excluded_key: Option<&str>) -> Option<(Vec, u16)> { + let mut rng = thread_rng(); + let excluded = excluded_key.and_then(split_ip_port_key); + + if let Some((key, _info)) = self + .connection_map + .iter() + .filter(|(key, info)| { + ConnectionType::from_bytes(&key.connection_type) == Some(ConnectionType::Incoming) + && ClientType::from_bytes(&info.client_type) == Some(ClientType::Miner) + && excluded + .as_ref() + .map(|(ip, _)| key.ip != ip_to_binary(ip)) + .unwrap_or(true) + }) + .choose(&mut rng) + { + return Some((key.ip.clone(), key.port)); + } + + if let Some((key, _info)) = self + .connection_map + .iter() + .filter(|(key, info)| { + ConnectionType::from_bytes(&key.connection_type) == Some(ConnectionType::Outgoing) + && ClientType::from_bytes(&info.client_type) == Some(ClientType::Miner) + && excluded + .as_ref() + .map(|(ip, _)| key.ip != ip_to_binary(ip)) + .unwrap_or(true) + }) + .choose(&mut rng) + { + return Some((key.ip.clone(), key.port)); + } + None + } +} + +lazy_static! { + pub static ref CONNECTIONS: Arc>> = Arc::new(RwLock::new(None)); +} + +pub async fn initialize_connection() { + // Lazily create the singleton connection manager the first time the + // node starts accepting or opening peer connections. + let mut connection_instance = CONNECTIONS.write().await; + if connection_instance.is_none() { + *connection_instance = Some(Connection::new()); + } +} + +pub async fn outgoing_connection_count() -> usize { + // Read the singleton connection manager and count live outgoing peers. + CONNECTIONS + .read() + .await + .as_ref() + .map(|connection| connection.count_outgoing_connections()) + .unwrap_or(0) +} + +pub async fn get_client_type_from_memory(key: &str) -> Option { + // Recover the stored client role from the serialized connection key + // used throughout the RPC layer. + let (ip, port) = split_ip_port_key(key)?; + let ip_bytes = ip_to_binary(&ip); + + let guard = CONNECTIONS.read().await; + let conn = guard.as_ref()?; + + for (connection_key, info) in conn.connection_map.iter() { + if connection_key.ip == ip_bytes && connection_key.port == port { + return ClientType::from_bytes(&info.client_type); + } + } + + None +} diff --git a/src/records/memory/enums.rs b/src/records/memory/enums.rs new file mode 100644 index 0000000..e08a905 --- /dev/null +++ b/src/records/memory/enums.rs @@ -0,0 +1,77 @@ +// ConnectionType keeps the connection-role values consistent across storage and runtime use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionType { + Incoming, + Outgoing, +} + +impl ConnectionType { + // Store connection roles using stable strings across runtime and persistence. + pub fn as_str(self) -> &'static str { + match self { + ConnectionType::Incoming => "incoming_connections", + ConnectionType::Outgoing => "outgoing_connections", + } + } + + pub fn as_bytes(self) -> Vec { + self.as_str().as_bytes().to_vec() + } + + // opposite is used when the same peer relationship is viewed from the remote side. + pub fn opposite(self) -> Self { + match self { + ConnectionType::Incoming => ConnectionType::Outgoing, + ConnectionType::Outgoing => ConnectionType::Incoming, + } + } + + pub fn from_bytes(bytes: &[u8]) -> Option { + match bytes { + b"incoming_connections" => Some(ConnectionType::Incoming), + b"outgoing_connections" => Some(ConnectionType::Outgoing), + _ => None, + } + } +} + +// ClientType keeps the advertised peer capabilities strongly typed inside the connection manager. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientType { + Miner, + Client, +} + +impl ClientType { + // These conversions isolate the stringly-typed values at the storage and wire boundaries. + pub fn as_str(self) -> &'static str { + match self { + ClientType::Miner => "miner", + ClientType::Client => "client", + } + } + + pub fn as_bytes(self) -> Vec { + self.as_str().as_bytes().to_vec() + } + + pub fn from_bytes(bytes: &[u8]) -> Option { + match bytes { + b"miner" => Some(ClientType::Miner), + b"client" => Some(ClientType::Client), + _ => None, + } + } +} + +impl std::str::FromStr for ClientType { + type Err = (); + + fn from_str(value: &str) -> Result { + match value { + "miner" => Ok(ClientType::Miner), + "client" => Ok(ClientType::Client), + _ => Err(()), + } + } +} diff --git a/src/records/memory/mempool/lookups.rs b/src/records/memory/mempool/lookups.rs new file mode 100644 index 0000000..2e8308a --- /dev/null +++ b/src/records/memory/mempool/lookups.rs @@ -0,0 +1,455 @@ +use super::*; + +pub async fn signature_exists(signature: &str, hash: &str) -> Result { + let client = DB.get().expect("DB not initialized"); + + // Check every mempool table because the signature column names differ by + // transaction type, especially for two-party swaps and loans. + let row = client + .query_one( + r#" + SELECT + CASE + WHEN EXISTS (SELECT 1 FROM transfer a WHERE a.signature = $1 AND a.hash = $2 AND a.processed = false) + OR EXISTS (SELECT 1 FROM token b WHERE b.signature = $1 AND b.hash = $2 AND b.processed = false) + OR EXISTS (SELECT 1 FROM issue_token c WHERE c.signature = $1 AND c.hash = $2 AND c.processed = false) + OR EXISTS (SELECT 1 FROM burn d WHERE d.signature = $1 AND d.hash = $2 AND d.processed = false) + OR EXISTS (SELECT 1 FROM nft e WHERE e.signature = $1 AND e.hash = $2 AND e.processed = false) + OR EXISTS (SELECT 1 FROM marketing f WHERE f.signature = $1 AND f.hash = $2 AND f.processed = false) + OR EXISTS (SELECT 1 FROM vanity_address va WHERE va.signature = $1 AND va.hash = $2 AND va.processed = false) + OR EXISTS (SELECT 1 FROM swap g WHERE g.signature1 = $1 AND g.hash = $2 AND g.processed = false) + OR EXISTS (SELECT 1 FROM swap h WHERE h.signature2 = $1 AND h.hash = $2 AND h.processed = false) + OR EXISTS (SELECT 1 FROM loan_contract i WHERE i.signature1 = $1 AND i.hash = $2 AND i.processed = false) + OR EXISTS (SELECT 1 FROM loan_contract j WHERE j.signature2 = $1 AND j.hash = $2 AND j.processed = false) + OR EXISTS (SELECT 1 FROM loan_payment k WHERE k.signature = $1 AND k.hash = $2 AND k.processed = false) + OR EXISTS (SELECT 1 FROM collateral_claim l WHERE l.signature = $1 AND l.hash = $2 AND l.processed = false) + THEN 1 + ELSE 0 + END AS signature_found; + "#, + &[&signature, &hash], + ) + .await?; + + let found: i32 = row.get(0); + Ok(found == 1) +} + +pub async fn transaction_by_signature(signature: &str) -> RpcResponse { + let client = DB.get().expect("DB not initialized"); + + // Return the original serialized transaction bytes, not a reconstructed + // row, so RPC callers receive the same payload that would enter a block. + let result = client + .query_opt( + r#" + SELECT original FROM ( + SELECT original FROM transfer WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM token WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM issue_token WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM burn WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM nft WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM marketing WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM vanity_address WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM swap WHERE signature1 = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM swap WHERE signature2 = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM loan_contract WHERE signature1 = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM loan_contract WHERE signature2 = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM loan_payment WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM collateral_claim WHERE signature = $1 AND processed = false LIMIT 1 + ) AS subquery LIMIT 1 + "#, + &[&signature], + ) + .await; + + match result { + Ok(Some(row)) => { + let bytes: Vec = row.get(0); + RpcResponse::Binary(bytes) + } + _ => RpcResponse::Binary(Vec::new()), + } +} + +pub async fn transactions_by_address(db: &Db, address: &str) -> RpcResponse { + let client = DB.get().expect("DB not initialized"); + // Canonicalize vanity aliases before querying pending rows. + let addresses = canonical_mempool_addresses(db, address); + + // Concatenate original transaction bytes; the RPC/bin caller can split the + // stream by transaction type and fixed byte length. + let rows = match client + .query( + r#" + SELECT original FROM ( + SELECT original FROM transfer WHERE receiver = ANY($1) AND processed = false + UNION ALL + SELECT original FROM token WHERE creator = ANY($1) AND processed = false + UNION ALL + SELECT original FROM issue_token WHERE creator = ANY($1) AND processed = false + UNION ALL + SELECT original FROM burn WHERE address = ANY($1) AND processed = false + UNION ALL + SELECT original FROM nft WHERE creator = ANY($1) AND processed = false + UNION ALL + SELECT original FROM marketing WHERE advertiser = ANY($1) AND processed = false + UNION ALL + SELECT original FROM vanity_address WHERE address = ANY($1) AND processed = false + UNION ALL + SELECT original FROM swap WHERE sender1 = ANY($1) AND processed = false + UNION ALL + SELECT original FROM swap WHERE sender2 = ANY($1) AND processed = false + UNION ALL + SELECT original FROM loan_contract WHERE lender = ANY($1) AND processed = false + UNION ALL + SELECT original FROM loan_contract WHERE borrower = ANY($1) AND processed = false + UNION ALL + SELECT original FROM loan_payment WHERE address = ANY($1) AND processed = false + UNION ALL + SELECT original FROM collateral_claim WHERE address = ANY($1) AND processed = false + ) AS subquery; + "#, + &[&addresses], + ) + .await + { + Ok(r) => r, + Err(_) => return RpcResponse::Binary(Vec::new()), + }; + + let mut bytes = Vec::new(); + for row in rows { + let chunk: Vec = row.get(0); + bytes.extend(chunk); + } + + RpcResponse::Binary(bytes) +} + +pub async fn largest_fee() -> RpcResponse { + let client = DB.get().expect("DB not initialized"); + + // Swaps have two possible fees, so both sides are included in the max. + let row = match client + .query_one( + r#" + SELECT MAX(fee) AS largest_txid FROM ( + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM transfer + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM token + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM issue_token + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM burn + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM nft + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM marketing + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM vanity_address + UNION ALL + SELECT CAST(MAX(fee1) AS BIGINT) AS fee FROM swap + UNION ALL + SELECT CAST(MAX(fee2) AS BIGINT) AS fee FROM swap + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM loan_contract + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM loan_payment + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM collateral_claim + ) AS combined_max_txids; + "#, + &[], + ) + .await + { + Ok(r) => r, + Err(_) => return RpcResponse::Binary(0u32.to_le_bytes().to_vec()), + }; + + let max_fee: Option = row.get(0); + let fee = (max_fee.unwrap_or(0) as u64).to_le_bytes().to_vec(); + + RpcResponse::Binary(fee) +} + +async fn pending_saved_loan_payment_balance( + db: &Db, + addresses: &[String], + coin: &str, +) -> Result> { + let client = DB.get().expect("DB not initialized"); + let rows = client + .query( + r#" + SELECT payback_amount, tip, contract_hash + FROM loan_payment + WHERE address = ANY($1) + AND processed = false + "#, + &[&addresses], + ) + .await?; + let mut total = 0u64; + + for row in rows { + let contract_hash: String = row.get("contract_hash"); + let Ok(contract_key) = decode(&contract_hash) else { + continue; + }; + let RpcResponse::Binary(bytes) = request_transaction_by_txid(db, contract_key).await; + if bytes.is_empty() || bytes[0] != 7 { + continue; + } + let Ok(contract) = LoanContractTransaction::from_bytes(7, &bytes[1..]).await else { + continue; + }; + if !contract + .unsigned_loan_contract + .loan_coin + .trim() + .eq_ignore_ascii_case(coin.trim()) + { + continue; + } + + let payback_amount: i64 = row.get("payback_amount"); + let tip: i64 = row.get("tip"); + let payment_total = (payback_amount.max(0) as u64) + .checked_add(tip.max(0) as u64) + .ok_or_else(|| std::io::Error::other("Pending loan payment reservation overflowed"))?; + total = total + .checked_add(payment_total) + .ok_or_else(|| std::io::Error::other("Pending loan payment reservation overflowed"))?; + } + + Ok(total) +} + +pub async fn get_coin_balance( + db: &Db, + address: &str, + coin: &str, +) -> Result> { + let client = DB.get().expect("DB not initialized"); + // Pending-balance checks use canonical addresses so vanity and short + // address inputs see the same outgoing obligations. + let addresses = canonical_mempool_addresses(db, address); + let (asset_name, nft_series) = nft_asset_parts(coin); + let nft_series = nft_series as i32; + + let row = client + .query_one( + r#" + SELECT CAST(( + COALESCE((SELECT SUM(t.value) + FROM transfer t + WHERE t.sender = ANY($1) AND t.coin = $2 AND t.nft_series = $3 AND t.processed = false), 0) + + + COALESCE((SELECT SUM(tok.number) + FROM token tok + WHERE tok.creator = ANY($1) AND tok.ticker = $2 AND tok.processed = false), 0) + + + COALESCE((SELECT SUM(it.number) + FROM issue_token it + WHERE it.creator = ANY($1) AND it.ticker = $2 AND it.processed = false), 0) + + + COALESCE((SELECT SUM(b.value) + FROM burn b + WHERE b.address = ANY($1) AND b.coin = $2 AND b.nft_series = $3 AND b.processed = false), 0) + + + COALESCE((SELECT SUM(s.value1) + FROM swap s + WHERE s.sender1 = ANY($1) AND s.ticker1 = $2 AND s.nft_series1 = $3 AND s.processed = false), 0) + + COALESCE((SELECT SUM(s.tip1) + FROM swap s + WHERE s.sender1 = ANY($1) AND s.ticker1 = $2 AND s.nft_series1 = $3 AND s.processed = false), 0) + + + COALESCE((SELECT SUM(s.value2) + FROM swap s + WHERE s.sender2 = ANY($1) AND s.ticker2 = $2 AND s.nft_series2 = $3 AND s.processed = false), 0) + + COALESCE((SELECT SUM(s.tip2) + FROM swap s + WHERE s.sender2 = ANY($1) AND s.ticker2 = $2 AND s.nft_series2 = $3 AND s.processed = false), 0) + + + COALESCE((SELECT SUM(lc.loan_amount) + FROM loan_contract lc + WHERE lc.lender = ANY($1) AND lc.loan_coin = $2 AND lc.processed = false), 0) + + + COALESCE((SELECT SUM(lc.collateral_amount) + FROM loan_contract lc + WHERE lc.borrower = ANY($1) AND lc.collateral = $2 AND lc.processed = false), 0) + + + COALESCE(( + SELECT SUM(lp.payback_amount) + FROM loan_payment lp + JOIN loan_contract lc ON lc.txid = lp.contract_hash + WHERE lp.address = ANY($1) AND lc.loan_coin = $2 AND lp.processed = false AND lc.processed = false + ), 0) + + + COALESCE(( + SELECT SUM(lp.tip) + FROM loan_payment lp + JOIN loan_contract lc ON lc.txid = lp.contract_hash + WHERE lp.address = ANY($1) AND lc.loan_coin = $2 AND lp.processed = false AND lc.processed = false + ), 0) + + ) AS BIGINT) AS total + "#, + &[&addresses, &asset_name, &nft_series], + ) + .await?; + + // Negative projections are clamped because callers only need the amount + // already reserved by pending mempool activity. + let total: i64 = row.get(0); + let chain_loan_payments = pending_saved_loan_payment_balance(db, &addresses, coin).await?; + Ok((total.max(0) as u64).saturating_add(chain_loan_payments)) +} + +pub async fn get_basecoin_balance( + db: &Db, + address: &str, +) -> Result> { + let client = DB.get().expect("DB not initialized"); + let addresses = canonical_mempool_addresses(db, address); + + // Base coin projection includes direct base transfers plus all fees and + // any pending loan/swap movements denominated in the base coin. + let row = client + .query_one( + r#" + SELECT CAST(( + COALESCE((SELECT SUM(t.value) + FROM transfer t + WHERE t.sender = ANY($1) AND t.coin = $2 AND t.processed = false), 0) + + COALESCE((SELECT SUM(t.fee) FROM transfer t WHERE t.sender = ANY($1) AND t.processed = false), 0) + + COALESCE((SELECT SUM(b.value) + FROM burn b + WHERE b.address = ANY($1) AND b.coin = $2 AND b.processed = false), 0) + + COALESCE((SELECT SUM(x.fee) FROM token x WHERE x.creator = ANY($1) AND x.processed = false), 0) + + COALESCE((SELECT SUM(it.fee) FROM issue_token it WHERE it.creator = ANY($1) AND it.processed = false), 0) + + COALESCE((SELECT SUM(b.fee) FROM burn b WHERE b.address = ANY($1) AND b.processed = false), 0) + + COALESCE((SELECT SUM(n.fee) FROM nft n WHERE n.creator = ANY($1) AND n.processed = false), 0) + + COALESCE((SELECT SUM(m.fee) FROM marketing m WHERE m.advertiser = ANY($1) AND m.processed = false), 0) + + COALESCE((SELECT SUM(v.fee) FROM vanity_address v WHERE v.address = ANY($1) AND v.processed = false), 0) + + COALESCE((SELECT SUM(cc.fee) FROM collateral_claim cc WHERE cc.address = ANY($1) AND cc.processed = false), 0) + + COALESCE((SELECT SUM(lc.fee) FROM loan_contract lc WHERE lc.lender = ANY($1) AND lc.processed = false), 0) + + COALESCE((SELECT SUM(lp.fee) FROM loan_payment lp WHERE lp.address = ANY($1) AND lp.processed = false), 0) + + COALESCE((SELECT SUM(lc.loan_amount) + FROM loan_contract lc + WHERE lc.lender = ANY($1) AND lc.loan_coin = $2 AND lc.processed = false), 0) + + COALESCE((SELECT SUM(lc.collateral_amount) + FROM loan_contract lc + WHERE lc.borrower = ANY($1) AND lc.collateral = $2 AND lc.processed = false), 0) + + COALESCE(( + SELECT SUM(lp.payback_amount) + FROM loan_payment lp + JOIN loan_contract lc ON lc.txid = lp.contract_hash + WHERE lp.address = ANY($1) AND lc.loan_coin = $2 AND lp.processed = false AND lc.processed = false + ), 0) + + COALESCE(( + SELECT SUM(lp.tip) + FROM loan_payment lp + JOIN loan_contract lc ON lc.txid = lp.contract_hash + WHERE lp.address = ANY($1) AND lc.loan_coin = $2 AND lp.processed = false AND lc.processed = false + ), 0) + + COALESCE((SELECT SUM(s.fee1) FROM swap s WHERE s.sender1 = ANY($1) AND s.processed = false), 0) + + COALESCE((SELECT SUM(s.fee2) FROM swap s WHERE s.sender2 = ANY($1) AND s.processed = false), 0) + + COALESCE((SELECT SUM(s.value1) FROM swap s WHERE s.sender1 = ANY($1) AND s.ticker1 = $2 AND s.processed = false), 0) + + COALESCE((SELECT SUM(s.tip1) FROM swap s WHERE s.sender1 = ANY($1) AND s.ticker1 = $2 AND s.processed = false), 0) + + COALESCE((SELECT SUM(s.value2) FROM swap s WHERE s.sender2 = ANY($1) AND s.ticker2 = $2 AND s.processed = false), 0) + + COALESCE((SELECT SUM(s.tip2) FROM swap s WHERE s.sender2 = ANY($1) AND s.ticker2 = $2 AND s.processed = false), 0) + ) AS BIGINT) AS total + "#, + &[&addresses, &*BASECOIN], + ) + .await?; + + let total: i64 = row.get(0); + let chain_loan_payments = + pending_saved_loan_payment_balance(db, &addresses, &BASECOIN).await?; + Ok((total.max(0) as u64).saturating_add(chain_loan_payments)) +} + +pub async fn get_pending_payments_for_contract( + contract_hash: &str, +) -> Result> { + let client = DB.get().expect("DB not initialized"); + + // Loan verification uses this to prevent pending payments from exceeding + // what the contract still owes. + let row = client + .query_one( + r#" + SELECT CAST(COALESCE(SUM(lp.payback_amount), 0) AS BIGINT) AS total + FROM loan_payment lp + WHERE lp.contract_hash = $1 + AND lp.processed = false + "#, + &[&contract_hash], + ) + .await?; + + let total: i64 = row.get(0); + Ok(total.max(0) as u64) +} + +pub async fn total_transactions() -> RpcResponse { + let client = DB.get().expect("DB not initialized"); + // Count rows across all mempool tables, including processed rows that may + // still be retained briefly for orphan rollback. + let row = match client + .query_one( + r#" + SELECT CAST(SUM(row_count) AS BIGINT) AS total_rows FROM ( + SELECT COUNT(*) AS row_count FROM transfer + UNION ALL + SELECT COUNT(*) AS row_count FROM token + UNION ALL + SELECT COUNT(*) AS row_count FROM issue_token + UNION ALL + SELECT COUNT(*) AS row_count FROM burn + UNION ALL + SELECT COUNT(*) AS row_count FROM nft + UNION ALL + SELECT COUNT(*) AS row_count FROM marketing + UNION ALL + SELECT COUNT(*) AS row_count FROM vanity_address + UNION ALL + SELECT COUNT(*) AS row_count FROM swap + UNION ALL + SELECT COUNT(*) AS row_count FROM loan_contract + UNION ALL + SELECT COUNT(*) AS row_count FROM loan_payment + UNION ALL + SELECT COUNT(*) AS row_count FROM collateral_claim + ) AS combined; + "#, + &[], + ) + .await + { + Ok(r) => r, + Err(_) => return RpcResponse::Binary(vec![0; 8]), + }; + + let total: Option = row.get(0); + let result = (total.unwrap_or(0) as u32).to_le_bytes().to_vec(); + + RpcResponse::Binary(result) +} + diff --git a/src/records/memory/mempool/mod.rs b/src/records/memory/mempool/mod.rs new file mode 100644 index 0000000..62fcccd --- /dev/null +++ b/src/records/memory/mempool/mod.rs @@ -0,0 +1,406 @@ +use crate::blocks::loans::LoanContractTransaction; +use crate::common::binary_conversions::binary_to_string; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::nft_assets::{nft_asset_name, nft_asset_parts}; +use crate::config::SETTINGS; +use crate::decode; +use crate::lazy_static; +use crate::records::memory::structs::BalanceKey; +use crate::records::wallet_registry::{ + resolve_canonical_registered_short_address, +}; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::wallets::structures::Wallet; +use crate::HashMap; +use crate::NoTls; +use crate::{task, AtomicBool}; +use anyhow::{anyhow, Result}; +use once_cell::sync::OnceCell; +use std::fs::File; +use std::io::Write; +use tokio_postgres::Client; + +lazy_static! { + pub static ref BASECOIN: String = { + let ( + _network_name, + base_coin, + _suffix, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + format!("{base_coin:<15}") + }; + static ref CLEANUP_RUNNING: AtomicBool = AtomicBool::new(false); +} + +pub static DB: OnceCell = OnceCell::new(); + +pub const EPOCH_ROW_CAP: i64 = 100_000; +const NFT_UNIT: i64 = 100_000_000; +const CLEANUP_DEPTH: u32 = 10; +const CLEANUP_BATCH_LIMIT: i64 = 1000; + +#[derive(Clone)] +enum SelectedMempoolTransaction { + // These variants hold the minimal fields needed to score, mark, and + // later apply selected mempool transactions into a saved block. + Transfer { + id: i64, + fee: i64, + sender: String, + value: i64, + coin: String, + nft_series: i32, + receiver: String, + hash: String, + }, + Token { + id: i64, + fee: i64, + creator: String, + number: i64, + ticker: String, + hash: String, + }, + IssueToken { + id: i64, + fee: i64, + creator: String, + number: i64, + ticker: String, + hash: String, + }, + Burn { + id: i64, + fee: i64, + address: String, + coin: String, + nft_series: i32, + value: i64, + hash: String, + }, + Nft { + id: i64, + fee: i64, + creator: String, + nft_name: String, + series: i16, + count: i64, + hash: String, + }, + Marketing { + id: i64, + fee: i64, + advertiser: String, + hash: String, + }, + Vanity { + id: i64, + fee: i64, + address: String, + vanity_address: String, + hash: String, + }, + Swap { + id: i64, + fee1: i64, + fee2: i64, + ticker1: String, + nft_series1: i32, + ticker2: String, + nft_series2: i32, + value1: i64, + value2: i64, + sender1: String, + tip1: i64, + tip2: i64, + sender2: String, + hash: String, + }, + Lender { + id: i64, + fee: i64, + loan_coin: String, + loan_amount: i64, + lender: String, + collateral: String, + collateral_amount: i64, + borrower: String, + txid: String, + hash: String, + }, + Borrower { + id: i64, + fee: i64, + payback_amount: i64, + contract_hash: String, + address: String, + tip: i64, + hash: String, + }, + Collateral { + id: i64, + fee: i64, + address: String, + contract_hash: String, + hash: String, + }, +} + +#[derive(Clone, Default)] +pub struct SelectedMempoolBatch { + // The selected transaction view is kept separate from the original + // serialized bytes so save paths can stream the original payloads. + transactions: Vec, + originals: Vec>, +} + +impl SelectedMempoolTransaction { + fn table_name(&self) -> &'static str { + match self { + SelectedMempoolTransaction::Transfer { .. } => "transfer", + SelectedMempoolTransaction::Token { .. } => "token", + SelectedMempoolTransaction::IssueToken { .. } => "issue_token", + SelectedMempoolTransaction::Burn { .. } => "burn", + SelectedMempoolTransaction::Nft { .. } => "nft", + SelectedMempoolTransaction::Marketing { .. } => "marketing", + SelectedMempoolTransaction::Vanity { .. } => "vanity_address", + SelectedMempoolTransaction::Swap { .. } => "swap", + SelectedMempoolTransaction::Lender { .. } => "loan_contract", + SelectedMempoolTransaction::Borrower { .. } => "loan_payment", + SelectedMempoolTransaction::Collateral { .. } => "collateral_claim", + } + } + + fn id(&self) -> i64 { + match self { + SelectedMempoolTransaction::Transfer { id, .. } + | SelectedMempoolTransaction::Token { id, .. } + | SelectedMempoolTransaction::IssueToken { id, .. } + | SelectedMempoolTransaction::Burn { id, .. } + | SelectedMempoolTransaction::Nft { id, .. } + | SelectedMempoolTransaction::Marketing { id, .. } + | SelectedMempoolTransaction::Vanity { id, .. } + | SelectedMempoolTransaction::Swap { id, .. } + | SelectedMempoolTransaction::Lender { id, .. } + | SelectedMempoolTransaction::Borrower { id, .. } + | SelectedMempoolTransaction::Collateral { id, .. } => *id, + } + } +} + +impl SelectedMempoolBatch { + pub fn is_empty(&self) -> bool { + self.transactions.is_empty() + } +} + + +mod lookups; +mod processing; +mod schema; +mod selection; + +pub use lookups::{ + get_basecoin_balance, get_coin_balance, get_pending_payments_for_contract, largest_fee, + signature_exists, total_transactions, transaction_by_signature, transactions_by_address, +}; +pub use processing::{ + mark_processed_by_signatures, mark_selected_transactions_processed, + restore_processed_by_signatures, restore_selected_transactions_processed, + spawn_processed_cleanup, delete_by_signatures, +}; +pub use schema::{clear_mempool, init_db, setup_mempool}; +pub use selection::{ + apply_selected_transaction_math, clear_selected_transaction_sql, delete_selected_transactions, + select_transactions_for_block, stream_selected_transaction_originals, +}; + +fn required_string(row: &tokio_postgres::Row, column: &str) -> Result { + row.try_get::<_, Option>(column)? + .ok_or_else(|| anyhow!("Missing required column {column}")) +} + +fn add_balance_change( + db: &Db, + balance_changes: &mut HashMap, + address: &str, + coin: &str, + delta: i64, +) { + add_balance_change_bytes( + balance_changes, + address_key_bytes(db, address), + coin.as_bytes().to_vec(), + delta, + ); +} + +fn add_balance_change_bytes( + balance_changes: &mut HashMap, + address: Vec, + coin: Vec, + delta: i64, +) { + *balance_changes + .entry(BalanceKey { address, coin }) + .or_insert(0) += delta; +} + +fn address_key_bytes(db: &Db, address: &str) -> Vec { + resolve_canonical_registered_short_address(db, address) + .ok() + .flatten() + .or_else(|| Wallet::normalize_to_short_address(address)) + .map(|normalized| normalized.as_bytes().to_vec()) + .unwrap_or_else(|| address.as_bytes().to_vec()) +} + +fn canonical_mempool_addresses(db: &Db, address: &str) -> Vec { + let canonical = resolve_canonical_registered_short_address(db, address) + .ok() + .flatten() + .or_else(|| Wallet::normalize_to_short_address(address)) + .unwrap_or_else(|| address.to_string()); + + vec![canonical] +} + +async fn resolve_loan_details(db: &Db, contract_hash: &str) -> Result<(Vec, Vec)> { + let RpcResponse::Binary(bytes) = request_transaction_by_txid(db, decode(contract_hash)?).await; + if bytes.is_empty() || bytes[0] != 7 { + return Ok((Vec::new(), Vec::new())); + } + match LoanContractTransaction::from_bytes(7, &bytes[1..]).await { + Ok(loan) => Ok(( + loan.unsigned_loan_contract.loan_coin.as_bytes().to_vec(), + address_key_bytes(db, &loan.unsigned_loan_contract.lender), + )), + Err(_) => Ok((Vec::new(), Vec::new())), + } +} + +async fn resolve_collateral_details(db: &Db, contract_hash: &str) -> Result<(Vec, i64)> { + let RpcResponse::Binary(bytes) = request_transaction_by_txid(db, decode(contract_hash)?).await; + if bytes.is_empty() || bytes[0] != 7 { + return Ok((Vec::new(), 0)); + } + match LoanContractTransaction::from_bytes(7, &bytes[1..]).await { + Ok(loan) => Ok(( + loan.unsigned_loan_contract.collateral.as_bytes().to_vec(), + loan.unsigned_loan_contract.collateral_amount as i64, + )), + Err(_) => Ok((Vec::new(), 0)), + } +} + +fn ids_for_table(batch: &SelectedMempoolBatch, table: &str) -> Vec { + batch + .transactions + .iter() + .filter(|tx| tx.table_name() == table) + .map(SelectedMempoolTransaction::id) + .collect() +} + +async fn mark_rows_by_ids( + client: &Client, + table: &str, + ids: &[i64], + block_number: i32, +) -> Result<()> { + if ids.is_empty() { + return Ok(()); + } + + let statement = format!( + "UPDATE {table} SET processed=true, processed_block_number=$1 WHERE id = ANY($2)" + ); + client.execute(&statement, &[&block_number, &ids]).await?; + Ok(()) +} + +async fn unmark_rows_by_ids(client: &Client, table: &str, ids: &[i64]) -> Result<()> { + if ids.is_empty() { + return Ok(()); + } + + let statement = + format!("UPDATE {table} SET processed=false, processed_block_number=NULL WHERE id = ANY($1)"); + client.execute(&statement, &[&ids]).await?; + Ok(()) +} + +async fn delete_rows(client: &Client, table: &str, ids: &[i64]) -> Result<()> { + if ids.is_empty() { + return Ok(()); + } + + let statement = format!("DELETE FROM {table} WHERE id = ANY($1)"); + client.execute(&statement, &[&ids]).await?; + Ok(()) +} + +async fn unmark_by_signatures( + client: &Client, + table: &str, + signature_column: &str, + signatures: &[String], +) -> Result { + let statement = format!( + "UPDATE {table} SET processed=false, processed_block_number=NULL WHERE {signature_column} = ANY($1) AND processed = true" + ); + Ok(client.execute(&statement, &[&signatures]).await?) +} + +async fn delete_processed_before_or_at(block_number: u32, limit: i64) -> Result<()> { + // Periodic cleanup deletes processed mempool rows in bounded batches + // so long-lived nodes do not accumulate infinite processed history. + let client = DB.get().expect("DB not initialized"); + let bn = block_number as i32; + + delete_processed_rows_limited(client, "transfer", bn, limit).await?; + delete_processed_rows_limited(client, "token", bn, limit).await?; + delete_processed_rows_limited(client, "issue_token", bn, limit).await?; + delete_processed_rows_limited(client, "burn", bn, limit).await?; + delete_processed_rows_limited(client, "nft", bn, limit).await?; + delete_processed_rows_limited(client, "marketing", bn, limit).await?; + delete_processed_rows_limited(client, "vanity_address", bn, limit).await?; + delete_processed_rows_limited(client, "swap", bn, limit).await?; + delete_processed_rows_limited(client, "loan_contract", bn, limit).await?; + delete_processed_rows_limited(client, "loan_payment", bn, limit).await?; + delete_processed_rows_limited(client, "collateral_claim", bn, limit).await?; + + Ok(()) +} + +async fn delete_processed_rows_limited( + client: &Client, + table: &str, + block_number: i32, + limit: i64, +) -> Result { + let statement = format!( + r#" + DELETE FROM {table} + WHERE id IN ( + SELECT id + FROM {table} + WHERE processed = true + AND processed_block_number IS NOT NULL + AND processed_block_number <= $1 + ORDER BY processed_block_number ASC, id ASC + LIMIT $2 + ) + "# + ); + + Ok(client.execute(&statement, &[&block_number, &limit]).await?) +} diff --git a/src/records/memory/mempool/processing.rs b/src/records/memory/mempool/processing.rs new file mode 100644 index 0000000..ca3473f --- /dev/null +++ b/src/records/memory/mempool/processing.rs @@ -0,0 +1,334 @@ +use super::*; + +pub async fn mark_selected_transactions_processed( + batch: &SelectedMempoolBatch, + block_number: u32, +) -> Result<()> { + // Mark each selected mempool row as processed under the saved block + // number so it can be cleaned up or restored later if needed. + let client = DB.get().expect("DB not initialized"); + let bn = block_number as i32; + + // Selected batches are grouped by table, then marked with one UPDATE per + // table instead of touching rows one at a time. + mark_rows_by_ids(client, "transfer", &ids_for_table(batch, "transfer"), bn).await?; + mark_rows_by_ids(client, "token", &ids_for_table(batch, "token"), bn).await?; + mark_rows_by_ids( + client, + "issue_token", + &ids_for_table(batch, "issue_token"), + bn, + ) + .await?; + mark_rows_by_ids(client, "burn", &ids_for_table(batch, "burn"), bn).await?; + mark_rows_by_ids(client, "nft", &ids_for_table(batch, "nft"), bn).await?; + mark_rows_by_ids(client, "marketing", &ids_for_table(batch, "marketing"), bn).await?; + mark_rows_by_ids( + client, + "vanity_address", + &ids_for_table(batch, "vanity_address"), + bn, + ) + .await?; + mark_rows_by_ids(client, "swap", &ids_for_table(batch, "swap"), bn).await?; + mark_rows_by_ids( + client, + "loan_contract", + &ids_for_table(batch, "loan_contract"), + bn, + ) + .await?; + mark_rows_by_ids( + client, + "loan_payment", + &ids_for_table(batch, "loan_payment"), + bn, + ) + .await?; + mark_rows_by_ids( + client, + "collateral_claim", + &ids_for_table(batch, "collateral_claim"), + bn, + ) + .await?; + + Ok(()) +} + +pub async fn restore_selected_transactions_processed(batch: &SelectedMempoolBatch) -> Result<()> { + // If block commit fails after selected rows were marked processed, + // restore them before the chain height can acknowledge the block. + let client = DB.get().expect("DB not initialized"); + + unmark_rows_by_ids(client, "transfer", &ids_for_table(batch, "transfer")).await?; + unmark_rows_by_ids(client, "token", &ids_for_table(batch, "token")).await?; + unmark_rows_by_ids( + client, + "issue_token", + &ids_for_table(batch, "issue_token"), + ) + .await?; + unmark_rows_by_ids(client, "burn", &ids_for_table(batch, "burn")).await?; + unmark_rows_by_ids(client, "nft", &ids_for_table(batch, "nft")).await?; + unmark_rows_by_ids(client, "marketing", &ids_for_table(batch, "marketing")).await?; + unmark_rows_by_ids( + client, + "vanity_address", + &ids_for_table(batch, "vanity_address"), + ) + .await?; + unmark_rows_by_ids(client, "swap", &ids_for_table(batch, "swap")).await?; + unmark_rows_by_ids( + client, + "loan_contract", + &ids_for_table(batch, "loan_contract"), + ) + .await?; + unmark_rows_by_ids( + client, + "loan_payment", + &ids_for_table(batch, "loan_payment"), + ) + .await?; + unmark_rows_by_ids( + client, + "collateral_claim", + &ids_for_table(batch, "collateral_claim"), + ) + .await?; + + Ok(()) +} + +pub async fn restore_processed_by_signatures(signatures: &[String]) -> Result { + // Orphan correction can revive recently processed mempool rows by + // signature when a saved block is rolled back out of the chain. + if signatures.is_empty() { + return Ok(false); + } + + let client = DB.get().expect("DB not initialized"); + let mut restored = 0_u64; + + // Each table keeps its own signature columns, so rollback unmarks every + // column that could contain one of the rolled-back signatures. + restored += unmark_by_signatures(client, "transfer", "signature", signatures).await?; + restored += unmark_by_signatures(client, "token", "signature", signatures).await?; + restored += unmark_by_signatures(client, "issue_token", "signature", signatures).await?; + restored += unmark_by_signatures(client, "burn", "signature", signatures).await?; + restored += unmark_by_signatures(client, "nft", "signature", signatures).await?; + restored += unmark_by_signatures(client, "marketing", "signature", signatures).await?; + restored += unmark_by_signatures(client, "vanity_address", "signature", signatures).await?; + restored += unmark_by_signatures(client, "swap", "signature1", signatures).await?; + restored += unmark_by_signatures(client, "swap", "signature2", signatures).await?; + restored += unmark_by_signatures(client, "loan_contract", "signature1", signatures).await?; + restored += unmark_by_signatures(client, "loan_contract", "signature2", signatures).await?; + restored += unmark_by_signatures(client, "loan_payment", "signature", signatures).await?; + restored += unmark_by_signatures(client, "collateral_claim", "signature", signatures).await?; + + Ok(restored > 0) +} + +pub fn spawn_processed_cleanup(saved_block_number: u32) { + // Cleanup trails the chain tip by a small depth so recent processed + // mempool rows can still be restored during short orphan events. + if saved_block_number <= CLEANUP_DEPTH { + return; + } + + if CLEANUP_RUNNING + .compare_exchange( + false, + true, + crate::AtomicOrdering::SeqCst, + crate::AtomicOrdering::SeqCst, + ) + .is_err() + { + return; + } + + task::spawn(async move { + let safe_block = saved_block_number.saturating_sub(CLEANUP_DEPTH); + // Cleanup is deliberately delayed behind the tip so short reorgs can + // still restore recently processed rows. + if let Err(err) = delete_processed_before_or_at(safe_block, CLEANUP_BATCH_LIMIT).await { + eprintln!( + "[mempool_cleanup] failed: saved_block={saved_block_number} safe_block={safe_block} err={err}" + ); + } + CLEANUP_RUNNING.store(false, crate::AtomicOrdering::SeqCst); + }); +} + + +pub async fn mark_processed_by_signatures(signatures: &[String], block_number: u32) -> Result<()> { + // Synced blocks arrive with signatures instead of selected-row IDs, + // so processed marking on the updating path works by signature. + if signatures.is_empty() { + return Ok(()); + } + + let client = DB.get().expect("DB not initialized"); + let bn = block_number as i32; + + // Remote/synced blocks do not know local row IDs, so they mark by + // transaction signatures instead. + client + .execute( + "UPDATE transfer SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE token SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE issue_token SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE burn SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE nft SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE marketing SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE vanity_address SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE swap SET processed=true, processed_block_number=$1 WHERE signature1 = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE swap SET processed=true, processed_block_number=$1 WHERE signature2 = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE loan_contract SET processed=true, processed_block_number=$1 WHERE signature1 = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE loan_contract SET processed=true, processed_block_number=$1 WHERE signature2 = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE loan_payment SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + &[&bn, &signatures], + ) + .await?; + client + .execute( + "UPDATE collateral_claim SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + &[&bn, &signatures], + ) + .await?; + + Ok(()) +} + +pub async fn delete_by_signatures(signatures: &[String]) -> Result<()> { + // Some validation failures need to remove mempool rows directly by + // signature regardless of which table the transaction lives in. + if signatures.is_empty() { + return Ok(()); + } + + let client = DB.get().expect("DB not initialized"); + + // Failed validation removes every matching pending row no matter which + // transaction table currently owns the signature. + client + .execute( + "DELETE FROM transfer WHERE signature = ANY($1)", + &[&signatures], + ) + .await?; + client + .execute( + "DELETE FROM token WHERE signature = ANY($1)", + &[&signatures], + ) + .await?; + client + .execute( + "DELETE FROM issue_token WHERE signature = ANY($1)", + &[&signatures], + ) + .await?; + client + .execute("DELETE FROM burn WHERE signature = ANY($1)", &[&signatures]) + .await?; + client + .execute("DELETE FROM nft WHERE signature = ANY($1)", &[&signatures]) + .await?; + client + .execute( + "DELETE FROM marketing WHERE signature = ANY($1)", + &[&signatures], + ) + .await?; + client + .execute( + "DELETE FROM vanity_address WHERE signature = ANY($1)", + &[&signatures], + ) + .await?; + client + .execute( + "DELETE FROM swap WHERE signature1 = ANY($1) OR signature2 = ANY($1)", + &[&signatures], + ) + .await?; + client + .execute( + "DELETE FROM loan_contract WHERE signature1 = ANY($1) OR signature2 = ANY($1)", + &[&signatures], + ) + .await?; + client + .execute( + "DELETE FROM loan_payment WHERE signature = ANY($1)", + &[&signatures], + ) + .await?; + client + .execute( + "DELETE FROM collateral_claim WHERE signature = ANY($1)", + &[&signatures], + ) + .await?; + + Ok(()) +} + diff --git a/src/records/memory/mempool/schema.rs b/src/records/memory/mempool/schema.rs new file mode 100644 index 0000000..83876dc --- /dev/null +++ b/src/records/memory/mempool/schema.rs @@ -0,0 +1,398 @@ +use super::*; + +pub async fn init_db() -> Result<()> { + // Initialize the shared Postgres client used by the mempool tables. + if DB.get().is_some() { + return Ok(()); + } + + let password = SETTINGS + .pg_password + .as_deref() + .expect("Postgres password must be set in settings.ini"); + + let conn_str = format!( + "host={} port={} user={} password={} dbname={}", + SETTINGS.pg_host, SETTINGS.pg_port, SETTINGS.pg_user, password, SETTINGS.pg_dbname + ); + + let (client, connection) = tokio_postgres::connect(&conn_str, NoTls) + .await + .map_err(|err| anyhow!("Failed to connect to Postgres: {err}"))?; + + // Keep the Postgres connection driver alive in the background for the + // lifetime of the shared client. + tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("Postgres connection error: {e}"); + } + }); + + DB.set(client) + .map_err(|_| anyhow!("DB already initialized"))?; + + Ok(()) +} + +pub async fn setup_mempool() -> Result<()> { + // Create or migrate the mempool schema, deduplicate any stale rows, + // add the selection indexes, and start from an empty live mempool. + let client = DB.get().expect("DB not initialized"); + + let schema = r#" + CREATE TABLE IF NOT EXISTS transfer ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + sender TEXT, + value BIGINT, + coin VARCHAR(15), + nft_series INTEGER NOT NULL DEFAULT 0, + receiver TEXT NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS token ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + creator TEXT NOT NULL, + number BIGINT NOT NULL, + hard_limit SMALLINT NOT NULL DEFAULT 1, + ticker VARCHAR(15) NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS issue_token ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + creator TEXT NOT NULL, + number BIGINT NOT NULL, + ticker VARCHAR(15) NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS burn ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + address TEXT NOT NULL, + coin VARCHAR(15) NOT NULL, + nft_series INTEGER NOT NULL DEFAULT 0, + value BIGINT NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS nft ( + id BIGSERIAL PRIMARY KEY, + fee BIGINT NOT NULL, + time INTEGER NOT NULL, + creator TEXT NOT NULL, + nft_name VARCHAR(15), + series SMALLINT NOT NULL, + count BIGINT NOT NULL DEFAULT 1, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS marketing ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + advertiser TEXT NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS vanity_address ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + address TEXT NOT NULL, + vanity_address TEXT NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS swap ( + id BIGSERIAL PRIMARY KEY, + fee1 BIGINT NOT NULL, + fee2 BIGINT NOT NULL, + time INTEGER NOT NULL, + ticker1 VARCHAR(15), + nft_series1 INTEGER NOT NULL DEFAULT 0, + ticker2 VARCHAR(15), + nft_series2 INTEGER NOT NULL DEFAULT 0, + value1 BIGINT NOT NULL, + value2 BIGINT NOT NULL, + sender1 TEXT NOT NULL, + tip1 BIGINT NOT NULL, + tip2 BIGINT NOT NULL, + sender2 TEXT NOT NULL, + hash VARCHAR(64) NOT NULL, + signature1 TEXT NOT NULL, + signature2 TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS loan_contract ( + id BIGSERIAL PRIMARY KEY, + fee BIGINT NOT NULL, + time INTEGER NOT NULL, + loan_coin VARCHAR(15), + loan_amount BIGINT NOT NULL, + lender TEXT NOT NULL, + collateral VARCHAR(15), + collateral_amount BIGINT NOT NULL, + borrower TEXT NOT NULL, + txid VARCHAR(64) NOT NULL, + hash VARCHAR(64) NOT NULL, + signature1 TEXT NOT NULL, + signature2 TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS loan_payment ( + id BIGSERIAL PRIMARY KEY, + fee BIGINT NOT NULL, + time INTEGER NOT NULL, + payback_amount BIGINT NOT NULL, + contract_hash VARCHAR(64) NOT NULL, + address TEXT NOT NULL, + tip BIGINT NOT NULL, + txid VARCHAR(64), + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS collateral_claim ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + address TEXT NOT NULL, + contract_hash VARCHAR(64) NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + ALTER TABLE loan_payment ADD COLUMN IF NOT EXISTS txid VARCHAR(64); + ALTER TABLE transfer ADD COLUMN IF NOT EXISTS nft_series INTEGER NOT NULL DEFAULT 0; + ALTER TABLE nft ADD COLUMN IF NOT EXISTS count BIGINT NOT NULL DEFAULT 1; + ALTER TABLE token ADD COLUMN IF NOT EXISTS hard_limit SMALLINT NOT NULL DEFAULT 1; + ALTER TABLE swap ADD COLUMN IF NOT EXISTS nft_series1 INTEGER NOT NULL DEFAULT 0; + ALTER TABLE swap ADD COLUMN IF NOT EXISTS nft_series2 INTEGER NOT NULL DEFAULT 0; + ALTER TABLE transfer ALTER COLUMN sender TYPE TEXT; + ALTER TABLE transfer ALTER COLUMN receiver TYPE TEXT; + ALTER TABLE transfer ALTER COLUMN signature TYPE TEXT; + ALTER TABLE token ALTER COLUMN creator TYPE TEXT; + ALTER TABLE token ALTER COLUMN signature TYPE TEXT; + ALTER TABLE issue_token ALTER COLUMN creator TYPE TEXT; + ALTER TABLE issue_token ALTER COLUMN signature TYPE TEXT; + ALTER TABLE burn ALTER COLUMN address TYPE TEXT; + ALTER TABLE burn ALTER COLUMN signature TYPE TEXT; + ALTER TABLE nft ALTER COLUMN creator TYPE TEXT; + ALTER TABLE nft ALTER COLUMN signature TYPE TEXT; + ALTER TABLE marketing ALTER COLUMN advertiser TYPE TEXT; + ALTER TABLE marketing ALTER COLUMN signature TYPE TEXT; + ALTER TABLE vanity_address ALTER COLUMN address TYPE TEXT; + ALTER TABLE vanity_address ALTER COLUMN vanity_address TYPE TEXT; + ALTER TABLE vanity_address ALTER COLUMN signature TYPE TEXT; + ALTER TABLE swap ALTER COLUMN sender1 TYPE TEXT; + ALTER TABLE swap ALTER COLUMN sender2 TYPE TEXT; + ALTER TABLE swap ALTER COLUMN signature1 TYPE TEXT; + ALTER TABLE swap ALTER COLUMN signature2 TYPE TEXT; + ALTER TABLE loan_contract ALTER COLUMN lender TYPE TEXT; + ALTER TABLE loan_contract ALTER COLUMN borrower TYPE TEXT; + ALTER TABLE loan_contract ALTER COLUMN signature1 TYPE TEXT; + ALTER TABLE loan_contract ALTER COLUMN signature2 TYPE TEXT; + ALTER TABLE loan_payment ALTER COLUMN address TYPE TEXT; + ALTER TABLE loan_payment ALTER COLUMN signature TYPE TEXT; + ALTER TABLE collateral_claim ALTER COLUMN address TYPE TEXT; + ALTER TABLE collateral_claim ALTER COLUMN signature TYPE TEXT; + "#; + + // The schema block creates fresh installs and also carries small migrations + // for recovered or older local databases. + client.batch_execute(schema).await?; + + let dedupe = r#" + DELETE FROM transfer a + USING transfer b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM token a + USING token b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM issue_token a + USING issue_token b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM burn a + USING burn b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM nft a + USING nft b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM marketing a + USING marketing b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM vanity_address a + USING vanity_address b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM swap a + USING swap b + WHERE a.id > b.id AND a.signature1 = b.signature1; + + DELETE FROM swap a + USING swap b + WHERE a.id > b.id AND a.signature2 = b.signature2; + + DELETE FROM loan_contract a + USING loan_contract b + WHERE a.id > b.id AND a.signature1 = b.signature1; + + DELETE FROM loan_contract a + USING loan_contract b + WHERE a.id > b.id AND a.signature2 = b.signature2; + + DELETE FROM loan_payment a + USING loan_payment b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM collateral_claim a + USING collateral_claim b + WHERE a.id > b.id AND a.signature = b.signature; + "#; + + // Remove duplicate rows before unique indexes are created, otherwise stale + // recovered mempools could fail startup. + client.batch_execute(dedupe).await?; + + let indexes = r#" + CREATE INDEX IF NOT EXISTS transfer_pick_idx ON transfer (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS transfer_sig_idx ON transfer (signature); + CREATE UNIQUE INDEX IF NOT EXISTS transfer_sig_unique_idx ON transfer (signature); + + CREATE INDEX IF NOT EXISTS token_pick_idx ON token (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS token_sig_idx ON token (signature); + CREATE UNIQUE INDEX IF NOT EXISTS token_sig_unique_idx ON token (signature); + + CREATE INDEX IF NOT EXISTS issue_token_pick_idx ON issue_token (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS issue_token_sig_idx ON issue_token (signature); + CREATE UNIQUE INDEX IF NOT EXISTS issue_token_sig_unique_idx ON issue_token (signature); + + CREATE INDEX IF NOT EXISTS burn_pick_idx ON burn (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS burn_sig_idx ON burn (signature); + CREATE UNIQUE INDEX IF NOT EXISTS burn_sig_unique_idx ON burn (signature); + + CREATE INDEX IF NOT EXISTS nft_pick_idx ON nft (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS nft_sig_idx ON nft (signature); + CREATE UNIQUE INDEX IF NOT EXISTS nft_sig_unique_idx ON nft (signature); + + CREATE INDEX IF NOT EXISTS marketing_pick_idx ON marketing (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS marketing_sig_idx ON marketing (signature); + CREATE UNIQUE INDEX IF NOT EXISTS marketing_sig_unique_idx ON marketing (signature); + + CREATE INDEX IF NOT EXISTS vanity_address_pick_idx ON vanity_address (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS vanity_address_sig_idx ON vanity_address (signature); + CREATE UNIQUE INDEX IF NOT EXISTS vanity_address_sig_unique_idx ON vanity_address (signature); + + CREATE INDEX IF NOT EXISTS swap_pick_idx ON swap (processed, GREATEST(fee1, fee2) DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS swap_sig1_idx ON swap (signature1); + CREATE INDEX IF NOT EXISTS swap_sig2_idx ON swap (signature2); + CREATE UNIQUE INDEX IF NOT EXISTS swap_sig1_unique_idx ON swap (signature1); + CREATE UNIQUE INDEX IF NOT EXISTS swap_sig2_unique_idx ON swap (signature2); + + CREATE INDEX IF NOT EXISTS loan_contract_pick_idx ON loan_contract (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS loan_contract_sig1_idx ON loan_contract (signature1); + CREATE INDEX IF NOT EXISTS loan_contract_sig2_idx ON loan_contract (signature2); + CREATE UNIQUE INDEX IF NOT EXISTS loan_contract_sig1_unique_idx ON loan_contract (signature1); + CREATE UNIQUE INDEX IF NOT EXISTS loan_contract_sig2_unique_idx ON loan_contract (signature2); + + CREATE INDEX IF NOT EXISTS loan_payment_pick_idx ON loan_payment (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS loan_payment_sig_idx ON loan_payment (signature); + CREATE UNIQUE INDEX IF NOT EXISTS loan_payment_sig_unique_idx ON loan_payment (signature); + + CREATE INDEX IF NOT EXISTS collateral_claim_pick_idx ON collateral_claim (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS collateral_claim_sig_idx ON collateral_claim (signature); + CREATE UNIQUE INDEX IF NOT EXISTS collateral_claim_sig_unique_idx ON collateral_claim (signature); + "#; + // Pick indexes speed up block selection; signature indexes enforce one + // pending copy of each transaction. + client.batch_execute(indexes).await?; + + // Live mempool data is not restored across startup. + clear_mempool().await?; + + Ok(()) +} + +pub async fn clear_mempool() -> Result<()> { + // Startup clears any leftover mempool rows so a node restart begins + // from a clean pending-transaction state. + let client = DB.get().expect("DB not initialized"); + + client + .batch_execute( + r#" + TRUNCATE TABLE + transfer, + token, + issue_token, + burn, + nft, + marketing, + vanity_address, + swap, + loan_contract, + loan_payment, + collateral_claim + RESTART IDENTITY; + "#, + ) + .await?; + + Ok(()) +} + diff --git a/src/records/memory/mempool/selection.rs b/src/records/memory/mempool/selection.rs new file mode 100644 index 0000000..be6e011 --- /dev/null +++ b/src/records/memory/mempool/selection.rs @@ -0,0 +1,930 @@ +use super::*; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; + +pub async fn select_transactions_for_block(limit: i64) -> Result { + // Pull the highest-priority unprocessed rows across all mempool + // tables, keeping the original bytes for block-file streaming later. + let client = DB.get().expect("DB not initialized"); + let rows = client + .query( + r#" + SELECT * FROM ( + SELECT + 'transfer'::TEXT AS kind, id, fee AS priority_fee, time, + sender, receiver, coin, value, nft_series, + NULL::BIGINT AS number, NULL::TEXT AS creator, NULL::TEXT AS ticker, + NULL::TEXT AS nft_name, NULL::SMALLINT AS series, NULL::BIGINT AS count, + NULL::TEXT AS advertiser, NULL::BIGINT AS fee1, NULL::BIGINT AS fee2, + NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, NULL::BIGINT AS value1, + NULL::BIGINT AS value2, NULL::TEXT AS sender1, NULL::BIGINT AS tip1, + NULL::BIGINT AS tip2, NULL::TEXT AS sender2, NULL::TEXT AS loan_coin, + NULL::BIGINT AS loan_amount, NULL::TEXT AS lender, NULL::TEXT AS collateral, + NULL::BIGINT AS collateral_amount, NULL::TEXT AS borrower, + NULL::BIGINT AS payback_amount, NULL::TEXT AS contract_hash, NULL::TEXT AS address, + hash, signature AS signature1, NULL::TEXT AS signature2, NULL::TEXT AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM transfer + WHERE processed = false + + UNION ALL + + SELECT + 'token'::TEXT AS kind, id, fee AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, NULL::TEXT AS coin, NULL::BIGINT AS value, NULL::INTEGER AS nft_series, + number, creator, ticker, NULL::TEXT AS nft_name, NULL::SMALLINT AS series, + NULL::BIGINT AS count, NULL::TEXT AS advertiser, NULL::BIGINT AS fee1, + NULL::BIGINT AS fee2, NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, + NULL::BIGINT AS value1, NULL::BIGINT AS value2, NULL::TEXT AS sender1, + NULL::BIGINT AS tip1, NULL::BIGINT AS tip2, NULL::TEXT AS sender2, + NULL::TEXT AS loan_coin, NULL::BIGINT AS loan_amount, NULL::TEXT AS lender, + NULL::TEXT AS collateral, NULL::BIGINT AS collateral_amount, NULL::TEXT AS borrower, + NULL::BIGINT AS payback_amount, NULL::TEXT AS contract_hash, NULL::TEXT AS address, + hash, signature AS signature1, NULL::TEXT AS signature2, NULL::TEXT AS stored_txid, hard_limit, original + FROM token + WHERE processed = false + + UNION ALL + + SELECT + 'issue_token'::TEXT AS kind, id, fee AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, NULL::TEXT AS coin, NULL::BIGINT AS value, NULL::INTEGER AS nft_series, + number, creator, ticker, NULL::TEXT AS nft_name, NULL::SMALLINT AS series, + NULL::BIGINT AS count, NULL::TEXT AS advertiser, NULL::BIGINT AS fee1, + NULL::BIGINT AS fee2, NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, + NULL::BIGINT AS value1, NULL::BIGINT AS value2, NULL::TEXT AS sender1, + NULL::BIGINT AS tip1, NULL::BIGINT AS tip2, NULL::TEXT AS sender2, + NULL::TEXT AS loan_coin, NULL::BIGINT AS loan_amount, NULL::TEXT AS lender, + NULL::TEXT AS collateral, NULL::BIGINT AS collateral_amount, NULL::TEXT AS borrower, + NULL::BIGINT AS payback_amount, NULL::TEXT AS contract_hash, NULL::TEXT AS address, + hash, signature AS signature1, NULL::TEXT AS signature2, NULL::TEXT AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM issue_token + WHERE processed = false + + UNION ALL + + SELECT + 'burn'::TEXT AS kind, id, fee AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, coin, value, nft_series, + NULL::BIGINT AS number, NULL::TEXT AS creator, NULL::TEXT AS ticker, + NULL::TEXT AS nft_name, NULL::SMALLINT AS series, NULL::BIGINT AS count, + NULL::TEXT AS advertiser, NULL::BIGINT AS fee1, NULL::BIGINT AS fee2, + NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, + NULL::BIGINT AS value1, NULL::BIGINT AS value2, NULL::TEXT AS sender1, + NULL::BIGINT AS tip1, NULL::BIGINT AS tip2, NULL::TEXT AS sender2, + NULL::TEXT AS loan_coin, NULL::BIGINT AS loan_amount, NULL::TEXT AS lender, + NULL::TEXT AS collateral, NULL::BIGINT AS collateral_amount, NULL::TEXT AS borrower, + NULL::BIGINT AS payback_amount, NULL::TEXT AS contract_hash, address, + hash, signature AS signature1, NULL::TEXT AS signature2, NULL::TEXT AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM burn + WHERE processed = false + + UNION ALL + + SELECT + 'nft'::TEXT AS kind, id, fee AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, NULL::TEXT AS coin, NULL::BIGINT AS value, NULL::INTEGER AS nft_series, + NULL::BIGINT AS number, creator, NULL::TEXT AS ticker, nft_name, series, count, + NULL::TEXT AS advertiser, NULL::BIGINT AS fee1, NULL::BIGINT AS fee2, + NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, NULL::BIGINT AS value1, + NULL::BIGINT AS value2, NULL::TEXT AS sender1, NULL::BIGINT AS tip1, + NULL::BIGINT AS tip2, NULL::TEXT AS sender2, NULL::TEXT AS loan_coin, + NULL::BIGINT AS loan_amount, NULL::TEXT AS lender, NULL::TEXT AS collateral, + NULL::BIGINT AS collateral_amount, NULL::TEXT AS borrower, + NULL::BIGINT AS payback_amount, NULL::TEXT AS contract_hash, NULL::TEXT AS address, + hash, signature AS signature1, NULL::TEXT AS signature2, NULL::TEXT AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM nft + WHERE processed = false + + UNION ALL + + SELECT + 'marketing'::TEXT AS kind, id, fee AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, NULL::TEXT AS coin, NULL::BIGINT AS value, NULL::INTEGER AS nft_series, + NULL::BIGINT AS number, NULL::TEXT AS creator, NULL::TEXT AS ticker, + NULL::TEXT AS nft_name, NULL::SMALLINT AS series, NULL::BIGINT AS count, + advertiser, NULL::BIGINT AS fee1, NULL::BIGINT AS fee2, NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, + NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, NULL::BIGINT AS value1, NULL::BIGINT AS value2, + NULL::TEXT AS sender1, NULL::BIGINT AS tip1, NULL::BIGINT AS tip2, + NULL::TEXT AS sender2, NULL::TEXT AS loan_coin, NULL::BIGINT AS loan_amount, + NULL::TEXT AS lender, NULL::TEXT AS collateral, NULL::BIGINT AS collateral_amount, + NULL::TEXT AS borrower, NULL::BIGINT AS payback_amount, NULL::TEXT AS contract_hash, + NULL::TEXT AS address, hash, signature AS signature1, NULL::TEXT AS signature2, + NULL::TEXT AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM marketing + WHERE processed = false + + UNION ALL + + SELECT + 'vanity_address'::TEXT AS kind, id, fee AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, NULL::TEXT AS coin, NULL::BIGINT AS value, NULL::INTEGER AS nft_series, + NULL::BIGINT AS number, NULL::TEXT AS creator, NULL::TEXT AS ticker, + NULL::TEXT AS nft_name, NULL::SMALLINT AS series, NULL::BIGINT AS count, + NULL::TEXT AS advertiser, NULL::BIGINT AS fee1, NULL::BIGINT AS fee2, NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, + NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, NULL::BIGINT AS value1, NULL::BIGINT AS value2, + NULL::TEXT AS sender1, NULL::BIGINT AS tip1, NULL::BIGINT AS tip2, + NULL::TEXT AS sender2, NULL::TEXT AS loan_coin, NULL::BIGINT AS loan_amount, + NULL::TEXT AS lender, NULL::TEXT AS collateral, NULL::BIGINT AS collateral_amount, + NULL::TEXT AS borrower, NULL::BIGINT AS payback_amount, NULL::TEXT AS contract_hash, + address, hash, signature AS signature1, NULL::TEXT AS signature2, + vanity_address AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM vanity_address + WHERE processed = false + + UNION ALL + + SELECT + 'swap'::TEXT AS kind, id, GREATEST(fee1, fee2) AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, NULL::TEXT AS coin, NULL::BIGINT AS value, NULL::INTEGER AS nft_series, + NULL::BIGINT AS number, NULL::TEXT AS creator, NULL::TEXT AS ticker, + NULL::TEXT AS nft_name, NULL::SMALLINT AS series, NULL::BIGINT AS count, + NULL::TEXT AS advertiser, fee1, fee2, ticker1, nft_series1, ticker2, nft_series2, value1, value2, + sender1, tip1, tip2, sender2, NULL::TEXT AS loan_coin, NULL::BIGINT AS loan_amount, + NULL::TEXT AS lender, NULL::TEXT AS collateral, NULL::BIGINT AS collateral_amount, + NULL::TEXT AS borrower, NULL::BIGINT AS payback_amount, NULL::TEXT AS contract_hash, + NULL::TEXT AS address, hash, signature1, signature2, NULL::TEXT AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM swap + WHERE processed = false + + UNION ALL + + SELECT + 'loan_contract'::TEXT AS kind, id, fee AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, NULL::TEXT AS coin, NULL::BIGINT AS value, NULL::INTEGER AS nft_series, + NULL::BIGINT AS number, NULL::TEXT AS creator, NULL::TEXT AS ticker, + NULL::TEXT AS nft_name, NULL::SMALLINT AS series, NULL::BIGINT AS count, + NULL::TEXT AS advertiser, NULL::BIGINT AS fee1, NULL::BIGINT AS fee2, + NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, NULL::BIGINT AS value1, + NULL::BIGINT AS value2, NULL::TEXT AS sender1, NULL::BIGINT AS tip1, + NULL::BIGINT AS tip2, NULL::TEXT AS sender2, loan_coin, loan_amount, lender, + collateral, collateral_amount, borrower, NULL::BIGINT AS payback_amount, + NULL::TEXT AS contract_hash, NULL::TEXT AS address, hash, signature1, signature2, + txid AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM loan_contract + WHERE processed = false + + UNION ALL + + SELECT + 'loan_payment'::TEXT AS kind, id, fee AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, NULL::TEXT AS coin, NULL::BIGINT AS value, NULL::INTEGER AS nft_series, + NULL::BIGINT AS number, NULL::TEXT AS creator, NULL::TEXT AS ticker, + NULL::TEXT AS nft_name, NULL::SMALLINT AS series, NULL::BIGINT AS count, + NULL::TEXT AS advertiser, NULL::BIGINT AS fee1, NULL::BIGINT AS fee2, + NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, NULL::BIGINT AS value1, + NULL::BIGINT AS value2, NULL::TEXT AS sender1, tip AS tip1, NULL::BIGINT AS tip2, + NULL::TEXT AS sender2, NULL::TEXT AS loan_coin, NULL::BIGINT AS loan_amount, + NULL::TEXT AS lender, NULL::TEXT AS collateral, NULL::BIGINT AS collateral_amount, + NULL::TEXT AS borrower, payback_amount, contract_hash, address, hash, + signature AS signature1, NULL::TEXT AS signature2, txid AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM loan_payment + WHERE processed = false + + UNION ALL + + SELECT + 'collateral_claim'::TEXT AS kind, id, fee AS priority_fee, time, + NULL::TEXT AS sender, NULL::TEXT AS receiver, NULL::TEXT AS coin, NULL::BIGINT AS value, NULL::INTEGER AS nft_series, + NULL::BIGINT AS number, NULL::TEXT AS creator, NULL::TEXT AS ticker, + NULL::TEXT AS nft_name, NULL::SMALLINT AS series, NULL::BIGINT AS count, + NULL::TEXT AS advertiser, NULL::BIGINT AS fee1, NULL::BIGINT AS fee2, + NULL::TEXT AS ticker1, NULL::INTEGER AS nft_series1, NULL::TEXT AS ticker2, NULL::INTEGER AS nft_series2, NULL::BIGINT AS value1, + NULL::BIGINT AS value2, NULL::TEXT AS sender1, NULL::BIGINT AS tip1, + NULL::BIGINT AS tip2, NULL::TEXT AS sender2, NULL::TEXT AS loan_coin, + NULL::BIGINT AS loan_amount, NULL::TEXT AS lender, NULL::TEXT AS collateral, + NULL::BIGINT AS collateral_amount, NULL::TEXT AS borrower, + NULL::BIGINT AS payback_amount, contract_hash, address, hash, + signature AS signature1, NULL::TEXT AS signature2, NULL::TEXT AS stored_txid, NULL::SMALLINT AS hard_limit, original + FROM collateral_claim + WHERE processed = false + ) AS combined + ORDER BY priority_fee DESC, time ASC, kind ASC, id ASC + LIMIT $1 + "#, + &[&limit], + ) + .await?; + + let mut transactions = Vec::with_capacity(rows.len()); + let mut originals = Vec::with_capacity(rows.len()); + for row in rows { + // Preserve the original serialized transaction bytes so the miner can + // write exactly what entered the mempool. + originals.push(row.get("original")); + + // The UNION query flattens every mempool table into one row shape; + // this tag restores the correct selected-transaction variant. + let kind: String = row.get("kind"); + let transaction = match kind.as_str() { + "transfer" => SelectedMempoolTransaction::Transfer { + id: row.get("id"), + fee: row.get("priority_fee"), + sender: required_string(&row, "sender")?, + value: row.get("value"), + coin: required_string(&row, "coin")?, + nft_series: row.get("nft_series"), + receiver: required_string(&row, "receiver")?, + hash: row.get("hash"), + }, + "token" => SelectedMempoolTransaction::Token { + id: row.get("id"), + fee: row.get("priority_fee"), + creator: required_string(&row, "creator")?, + number: row.get("number"), + ticker: required_string(&row, "ticker")?, + hash: row.get("hash"), + }, + "issue_token" => SelectedMempoolTransaction::IssueToken { + id: row.get("id"), + fee: row.get("priority_fee"), + creator: required_string(&row, "creator")?, + number: row.get("number"), + ticker: required_string(&row, "ticker")?, + hash: row.get("hash"), + }, + "burn" => SelectedMempoolTransaction::Burn { + id: row.get("id"), + fee: row.get("priority_fee"), + address: required_string(&row, "address")?, + coin: required_string(&row, "coin")?, + nft_series: row.get("nft_series"), + value: row.get("value"), + hash: row.get("hash"), + }, + "nft" => SelectedMempoolTransaction::Nft { + id: row.get("id"), + fee: row.get("priority_fee"), + creator: required_string(&row, "creator")?, + nft_name: required_string(&row, "nft_name")?, + series: row.get("series"), + count: row.get("count"), + hash: row.get("hash"), + }, + "marketing" => SelectedMempoolTransaction::Marketing { + id: row.get("id"), + fee: row.get("priority_fee"), + advertiser: required_string(&row, "advertiser")?, + hash: row.get("hash"), + }, + "vanity_address" => SelectedMempoolTransaction::Vanity { + id: row.get("id"), + fee: row.get("priority_fee"), + address: required_string(&row, "address")?, + vanity_address: required_string(&row, "stored_txid")?, + hash: row.get("hash"), + }, + "swap" => SelectedMempoolTransaction::Swap { + id: row.get("id"), + fee1: row.get("fee1"), + fee2: row.get("fee2"), + ticker1: required_string(&row, "ticker1")?, + nft_series1: row.get("nft_series1"), + ticker2: required_string(&row, "ticker2")?, + nft_series2: row.get("nft_series2"), + value1: row.get("value1"), + value2: row.get("value2"), + sender1: required_string(&row, "sender1")?, + tip1: row.get("tip1"), + tip2: row.get("tip2"), + sender2: required_string(&row, "sender2")?, + hash: row.get("hash"), + }, + "loan_contract" => SelectedMempoolTransaction::Lender { + id: row.get("id"), + fee: row.get("priority_fee"), + loan_coin: required_string(&row, "loan_coin")?, + loan_amount: row.get("loan_amount"), + lender: required_string(&row, "lender")?, + collateral: required_string(&row, "collateral")?, + collateral_amount: row.get("collateral_amount"), + borrower: required_string(&row, "borrower")?, + txid: required_string(&row, "stored_txid")?, + hash: row.get("hash"), + }, + "loan_payment" => SelectedMempoolTransaction::Borrower { + id: row.get("id"), + fee: row.get("priority_fee"), + payback_amount: row.get("payback_amount"), + contract_hash: required_string(&row, "contract_hash")?, + address: required_string(&row, "address")?, + tip: row.get("tip1"), + hash: row.get("hash"), + }, + "collateral_claim" => SelectedMempoolTransaction::Collateral { + id: row.get("id"), + fee: row.get("priority_fee"), + address: required_string(&row, "address")?, + contract_hash: required_string(&row, "contract_hash")?, + hash: row.get("hash"), + }, + _ => return Err(anyhow!("Unsupported mempool kind {kind}")), + }; + transactions.push(transaction); + } + + Ok(SelectedMempoolBatch { + transactions, + originals, + }) +} + +pub async fn apply_selected_transaction_math( + batch: &SelectedMempoolBatch, + db: &Db, + miner: String, + block_number: u32, + start_index: usize, + pending_effects: &mut PendingEffects, +) -> Result<()> { + // Net balance deltas are collected first so multiple changes to the same + // address/asset pair become one balance-sheet write. + let mut balance_changes: HashMap = HashMap::new(); + let miner_address = address_key_bytes(db, &miner); + let base_coin = BASECOIN.as_bytes().to_vec(); + let mut tx_index = start_index; + + for tx in &batch.transactions { + // Transaction index is stored with the block number for later txid + // lookups inside the saved block payload. + tx_index = tx_index.wrapping_add(1); + match tx { + SelectedMempoolTransaction::Transfer { + fee, + sender, + value, + coin, + nft_series, + receiver, + hash, + .. + } => { + // Transfers move the asset, charge the base fee, and credit + // that fee to the miner including this transaction. + let transfer_asset = nft_asset_name(coin, *nft_series as u32); + add_balance_change(db, &mut balance_changes, sender, &transfer_asset, -*value); + add_balance_change(db, &mut balance_changes, receiver, &transfer_asset, *value); + add_balance_change(db, &mut balance_changes, sender, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + let nft_tree = db.open_tree("nfts")?; + if nft_tree.contains_key(transfer_asset.as_bytes())? { + pending_effects.append_tree( + "nft_history", + transfer_asset.as_bytes().to_vec(), + decode(hash)?, + ); + } + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::Token { + fee, + creator, + number, + ticker, + hash, + .. + } => { + // Token creation mints the initial supply and records metadata + // used by later issue and burn validation. + add_balance_change(db, &mut balance_changes, creator, ticker, *number); + add_balance_change(db, &mut balance_changes, creator, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + + let number_bytes = (*number as u64).to_le_bytes(); + pending_effects.set_tree("tokens", ticker.as_bytes().to_vec(), number_bytes.to_vec()); + + // Local mined blocks need to persist the token hard-limit + // metadata just like the downloaded-block save path does. + let client = DB.get().expect("DB not initialized"); + let token_row = client + .query_opt( + "SELECT hard_limit FROM token WHERE hash = $1 LIMIT 1", + &[hash], + ) + .await?; + let hard_limit = token_row + .and_then(|row| row.try_get::<_, Option>("hard_limit").ok().flatten()) + .unwrap_or(1) as u8; + pending_effects.set_tree("token_limits", ticker.as_bytes().to_vec(), vec![hard_limit]); + pending_effects.set_tree( + "token_origins", + ticker.as_bytes().to_vec(), + hash.as_bytes().to_vec(), + ); + pending_effects.append_tree("token_history", ticker.as_bytes().to_vec(), decode(hash)?); + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::IssueToken { + fee, + creator, + number, + ticker, + hash, + .. + } => { + add_balance_change(db, &mut balance_changes, creator, ticker, *number); + add_balance_change(db, &mut balance_changes, creator, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + + // Additional issuance increases the live fungible supply without + // changing the original token metadata entries. + pending_effects.add_token_supply(ticker, *number as u64); + pending_effects.append_tree("token_history", ticker.as_bytes().to_vec(), decode(hash)?); + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::Burn { + fee, + address, + coin, + nft_series, + value, + hash, + .. + } => { + let burned_asset = nft_asset_name(coin, *nft_series as u32); + add_balance_change(db, &mut balance_changes, address, &burned_asset, -*value); + add_balance_change(db, &mut balance_changes, address, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + + // Live NFT state removes burned assets, while fungible token + // state reduces the stored supply by the burned amount. + pending_effects.burn_asset_state(&burned_asset, coin, decode(hash)?, *value as u64); + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::Nft { + fee, + creator, + nft_name, + series, + count, + hash, + .. + } => { + // Series-one NFT creation expands into numbered assets; later + // series use the given NFT name as the asset key. + if *series == 1 { + for item_number in 1..=*count { + let nft_save_name = nft_asset_name(nft_name, item_number as u32); + add_balance_change( + db, + &mut balance_changes, + creator, + &nft_save_name, + NFT_UNIT, + ); + + pending_effects.set_tree( + "nfts", + nft_save_name.as_bytes().to_vec(), + b"1".to_vec(), + ); + pending_effects.set_tree( + "nft_origins", + nft_save_name.as_bytes().to_vec(), + decode(hash)?, + ); + pending_effects.append_tree( + "nft_history", + nft_save_name.into_bytes(), + decode(hash)?, + ); + } + } else { + add_balance_change(db, &mut balance_changes, creator, nft_name, NFT_UNIT); + + pending_effects.set_tree("nfts", nft_name.as_bytes().to_vec(), b"1".to_vec()); + pending_effects.set_tree("nft_origins", nft_name.as_bytes().to_vec(), decode(hash)?); + pending_effects.append_tree("nft_history", nft_name.as_bytes().to_vec(), decode(hash)?); + } + add_balance_change(db, &mut balance_changes, creator, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::Marketing { + fee, + advertiser, + hash, + .. + } => { + add_balance_change(db, &mut balance_changes, advertiser, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::Vanity { + fee, + address, + vanity_address, + hash, + .. + } => { + // Vanity transactions update the alias registry during the + // final block-effect commit so rollback can restore it. + pending_effects.update_vanity(address, vanity_address, hash); + add_balance_change(db, &mut balance_changes, address, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::Swap { + fee1, + fee2, + ticker1, + nft_series1, + ticker2, + nft_series2, + value1, + value2, + sender1, + tip1, + tip2, + sender2, + hash, + .. + } => { + // Swaps debit both sides first, then credit received assets, + // miner fees, and optional asset tips. + let asset1 = nft_asset_name(ticker1, *nft_series1 as u32); + let asset2 = nft_asset_name(ticker2, *nft_series2 as u32); + add_balance_change(db, &mut balance_changes, sender1, &asset1, -*value1); + add_balance_change(db, &mut balance_changes, sender1, &asset1, -*tip1); + add_balance_change(db, &mut balance_changes, sender2, &asset2, -*value2); + add_balance_change(db, &mut balance_changes, sender2, &asset2, -*tip2); + add_balance_change(db, &mut balance_changes, sender2, &asset1, *value1); + add_balance_change(db, &mut balance_changes, sender1, &asset2, *value2); + add_balance_change(db, &mut balance_changes, sender1, &BASECOIN, -*fee1); + add_balance_change(db, &mut balance_changes, sender2, &BASECOIN, -*fee2); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee1, + ); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee2, + ); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + asset1.as_bytes().to_vec(), + *tip1, + ); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + asset2.as_bytes().to_vec(), + *tip2, + ); + let nft_tree = db.open_tree("nfts")?; + if nft_tree.contains_key(asset1.as_bytes())? { + pending_effects.append_tree( + "nft_history", + asset1.as_bytes().to_vec(), + decode(hash)?, + ); + } + if nft_tree.contains_key(asset2.as_bytes())? { + pending_effects.append_tree( + "nft_history", + asset2.as_bytes().to_vec(), + decode(hash)?, + ); + } + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::Lender { + fee, + loan_coin, + loan_amount, + lender, + collateral, + collateral_amount, + borrower, + txid, + hash, + .. + } => { + // Loan creation moves the loan asset to the borrower and locks + // collateral under a contract-specific holding key. + add_balance_change(db, &mut balance_changes, lender, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + add_balance_change(db, &mut balance_changes, lender, loan_coin, -*loan_amount); + add_balance_change(db, &mut balance_changes, borrower, loan_coin, *loan_amount); + add_balance_change( + db, + &mut balance_changes, + borrower, + collateral, + -*collateral_amount, + ); + + let collateral_holding = format!("collateral_{txid}"); + add_balance_change( + db, + &mut balance_changes, + &collateral_holding, + collateral, + *collateral_amount, + ); + + pending_effects.set_tree("loan", decode(txid)?, b"true".to_vec()); + let nft_tree = db.open_tree("nfts")?; + if nft_tree.contains_key(collateral.as_bytes())? { + pending_effects.append_tree( + "nft_history", + collateral.as_bytes().to_vec(), + decode(hash)?, + ); + } + if nft_tree.contains_key(loan_coin.as_bytes())? { + pending_effects.append_tree( + "nft_history", + loan_coin.as_bytes().to_vec(), + decode(hash)?, + ); + } + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::Borrower { + fee, + payback_amount, + contract_hash, + address, + tip, + hash, + .. + } => { + // Loan payments resolve the contract, then move repayment from + // borrower to lender while paying any tip to the miner. + let (loan_coin, lender) = resolve_loan_details(db, contract_hash).await?; + add_balance_change(db, &mut balance_changes, address, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + add_balance_change_bytes( + &mut balance_changes, + address_key_bytes(db, address), + loan_coin.clone(), + -*payback_amount, + ); + add_balance_change_bytes( + &mut balance_changes, + lender.clone(), + loan_coin.clone(), + *payback_amount, + ); + add_balance_change_bytes( + &mut balance_changes, + address_key_bytes(db, address), + loan_coin.clone(), + -*tip, + ); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + loan_coin.clone(), + *tip, + ); + + pending_effects.append_contract_payment(decode(contract_hash)?, *payback_amount as u64); + let loan_coin_name = binary_to_string(loan_coin.clone()); + let nft_tree = db.open_tree("nfts")?; + if nft_tree.contains_key(loan_coin_name.as_bytes())? { + pending_effects.append_tree( + "nft_history", + loan_coin_name.as_bytes().to_vec(), + decode(hash)?, + ); + } + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + SelectedMempoolTransaction::Collateral { + fee, + address, + contract_hash, + hash, + .. + } => { + // Collateral claims release the contract holding to the claimant + // and mark the loan as closed. + let (collateral, collateral_amount) = + resolve_collateral_details(db, contract_hash).await?; + let collateral_holding = format!("collateral_{contract_hash}"); + add_balance_change(db, &mut balance_changes, address, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + add_balance_change_bytes( + &mut balance_changes, + collateral_holding.as_bytes().to_vec(), + collateral.clone(), + -collateral_amount, + ); + add_balance_change_bytes( + &mut balance_changes, + address_key_bytes(db, address), + collateral.clone(), + collateral_amount, + ); + + pending_effects.set_tree("loan", decode(contract_hash)?, b"false".to_vec()); + let collateral_name = binary_to_string(collateral.clone()); + let nft_tree = db.open_tree("nfts")?; + if nft_tree.contains_key(collateral_name.as_bytes())? { + pending_effects.append_tree( + "nft_history", + collateral_name.as_bytes().to_vec(), + decode(hash)?, + ); + } + pending_effects.set_tree( + "txid", + decode(hash)?, + format!("{block_number}:{tx_index}").into_bytes(), + ); + } + } + } + + for (key, change) in balance_changes { + // The balance sheet stores string keys, so convert the internal byte + // keys back into wallet/asset strings before applying the delta. + let address = if key.address.len() == Wallet::SHORT_ADDRESS_BYTES_LENGTH { + Wallet::bytes_to_short_address(&key.address).unwrap_or_default() + } else { + binary_to_string(key.address) + }; + let coin = binary_to_string(key.coin); + let operand = if change >= 0 { + BalanceOperand::Addition + } else { + BalanceOperand::Subtraction + }; + pending_effects.add_balance(&address, change.unsigned_abs(), &coin, operand); + } + + Ok(()) +} + +pub async fn clear_selected_transaction_sql( + db: &Db, + miner: String, + limit: i64, + block_number: u32, + start_index: usize, +) -> Result { + // Selection and ledger math stay paired so the caller can stream this exact + // selected batch into the block file afterward. + let batch = select_transactions_for_block(limit).await?; + let mut pending_effects = PendingEffects::default(); + apply_selected_transaction_math( + &batch, + db, + miner, + block_number, + start_index, + &mut pending_effects, + ) + .await?; + pending_effects.apply(db).map_err(|err| anyhow!(err))?; + Ok(batch) +} + +pub async fn stream_selected_transaction_originals( + file: &mut File, + batch: &SelectedMempoolBatch, +) -> Result<()> { + if batch.originals.is_empty() { + file.flush()?; + return Ok(()); + } + + // Block payloads store the original mempool bytes back-to-back in selected + // order rather than rebuilding each transaction from parsed fields. + let total_len: usize = batch.originals.iter().map(Vec::len).sum(); + let mut payload = Vec::with_capacity(total_len); + for original in &batch.originals { + payload.extend_from_slice(original); + } + file.write_all(&payload)?; + file.flush()?; + Ok(()) +} + +pub async fn delete_selected_transactions(batch: &SelectedMempoolBatch) -> Result<()> { + let client = DB.get().expect("DB not initialized"); + + // Each transaction kind still lives in a separate SQL table, so deletion + // groups the selected IDs by table after the block has been written. + let transfer_ids = ids_for_table(batch, "transfer"); + let token_ids = ids_for_table(batch, "token"); + let issue_token_ids = ids_for_table(batch, "issue_token"); + let burn_ids = ids_for_table(batch, "burn"); + let nft_ids = ids_for_table(batch, "nft"); + let marketing_ids = ids_for_table(batch, "marketing"); + let vanity_ids = ids_for_table(batch, "vanity_address"); + let swap_ids = ids_for_table(batch, "swap"); + let lender_ids = ids_for_table(batch, "loan_contract"); + let borrower_ids = ids_for_table(batch, "loan_payment"); + let collateral_ids = ids_for_table(batch, "collateral_claim"); + + delete_rows(client, "transfer", &transfer_ids).await?; + delete_rows(client, "token", &token_ids).await?; + delete_rows(client, "issue_token", &issue_token_ids).await?; + delete_rows(client, "burn", &burn_ids).await?; + delete_rows(client, "nft", &nft_ids).await?; + delete_rows(client, "marketing", &marketing_ids).await?; + delete_rows(client, "vanity_address", &vanity_ids).await?; + delete_rows(client, "swap", &swap_ids).await?; + delete_rows(client, "loan_contract", &lender_ids).await?; + delete_rows(client, "loan_payment", &borrower_ids).await?; + delete_rows(client, "collateral_claim", &collateral_ids).await?; + + Ok(()) +} + diff --git a/src/records/memory/mod.rs b/src/records/memory/mod.rs new file mode 100644 index 0000000..b3e181d --- /dev/null +++ b/src/records/memory/mod.rs @@ -0,0 +1,11 @@ +// The memory module holds the runtime-only structures used for active connections, +// mempool ordering, torrent state, and other transient node state. +pub mod averages; +pub mod connections; +pub mod enums; +pub mod response_channels; +pub mod mempool; +pub mod network_mapping; +pub mod structs; +pub mod torrent_status; +pub mod torrentmap; diff --git a/src/records/memory/network_mapping/add.rs b/src/records/memory/network_mapping/add.rs new file mode 100644 index 0000000..c62f5ad --- /dev/null +++ b/src/records/memory/network_mapping/add.rs @@ -0,0 +1,263 @@ +use super::*; + +impl NodeInfo { + pub async fn broadcast_node( + map: Arc>, + edit: &SignedNodeEdit, + remote_ip: &str, + edittype: NodeEditType, + connections_key: &str, + ) { + // Re-broadcast signed node-map edits to connected peers while + // skipping the source peer that already sent the update. + let message_type = edittype.message_type(); + let ip_bytes = ip_to_binary(&edit.ip); + let address_bytes = match Wallet::short_address_to_bytes(&edit.address) { + Some(bytes) => bytes, + None => { + warn!( + "[network_map] skipping broadcast for invalid short node address {}", + edit.address + ); + return; + } + }; + let modified_by_bytes = Wallet::long_address_to_bytes(edit.modified_by.clone()); + let modified_timestamp_bytes = edit.modified_timestamp.to_le_bytes(); + let modified_signature_bytes = decode(&edit.modified_signature).unwrap(); + let connections_lock = CONNECTIONS.read().await; + let streams: Option>>> = connections_lock + .as_ref() + .map(|connection| connection.get_all_streams()); + match streams { + Some(streams) => { + for unlocked_stream in streams { + let (hashmap_key, _hashmap_tx, hashmap_rx) = reserve_entry(map.clone()).await; + let mut message: Vec = Vec::new(); + message.push(message_type); + message.extend_from_slice(&hashmap_key); + message.extend_from_slice(&address_bytes); + message.extend_from_slice(&ip_bytes); + message.extend_from_slice(&modified_by_bytes); + message.extend_from_slice(&modified_timestamp_bytes); + message.extend_from_slice(&modified_signature_bytes); + let peer_addr = { + let stream = unlocked_stream.lock().await; + stream.peer_addr() + }; + match peer_addr { + Ok(addr) => { + if (addr.ip().to_string() != *remote_ip) && !remote_ip.is_empty() { + RpcResponse::send_raw( + &unlocked_stream, + Some(connections_key), + &message, + ) + .await; + let mut rx = hashmap_rx.lock().await; + let _ = rx.recv().await; + } + } + Err(e) => { + error!("broadcast node error {e}"); + continue; + } + } + } + } + None => { + warn!("No active connections found."); + } + } + } + + pub async fn add_address(params: AddAddressParams) -> RpcResponse { + let AddAddressParams { + map, + mut edit, + mut blocks_mined, + remote_ip, + db, + wallet_key, + connections_key, + } = params; + let current_timestamp = Utc::now().timestamp_millis() as u64; + let direct_peer_announcement = !remote_ip.is_empty() && edit.ip == remote_ip; + + if !is_public_network_address(&edit.ip) { + return RpcResponse::Binary(b"Error: Invalid network address".to_vec()); + } + + // Locally initiated edits are re-signed with the local wallet and + // current timestamp so they can be propagated as fresh node events. + if edit.ip == remote_ip { + edit.modified_timestamp = current_timestamp; + let wallet = match Wallet::try_obtain_wallet(wallet_key.clone(), None).await { + Ok(wallet) => wallet, + Err(err) => { + error!("Wallet decryption failed while adding node address: {err}"); + return RpcResponse::Binary(b"Error: Wallet decryption failed".to_vec()); + } + }; + edit.modified_by = wallet.saved.long_address; + edit.modified_signature = + Self::added_signature(&edit.address, &edit.ip, current_timestamp, &wallet_key) + .await; + } + + if !remote_ip.is_empty() { + blocks_mined = 0; + } + + let data = format!( + "{}{}{}{}", + edit.address, edit.ip, edit.modified_by, edit.modified_timestamp + ); + let hashed_data = skein_256_hash_data(&data); + + // Every add/delete edit is signed, so the network map accepts + // only node changes backed by a valid wallet signature. + if !Wallet::verify_transaction(&hashed_data, &edit.modified_signature, &edit.modified_by) + .await + { + return RpcResponse::Binary(b"Error: Could not validate signature".to_vec()); + } + + let mut penalize_duplicate_ip = false; + + { + let mut address_map = ADDRESS_MAP.lock().await; + + // Once the chain is mature, adding nodes is restricted to older + // active participants with sufficient mined history. + if get_height(&db) > 10000 { + let signer_key = Wallet::normalize_to_short_address(&edit.modified_by) + .unwrap_or_else(|| edit.modified_by.clone()); + let signer_node = address_map.get(&signer_key); + let valid_added_by = signer_node + .map(|node| { + (current_timestamp - node.added_timestamp) >= 3600 + && node.deleted_by.is_empty() + }) + .unwrap_or(false); + if !valid_added_by { + return RpcResponse::Binary( + b"Error: This address cannot add nodes. It must exist for at least 60 minutes and not be marked for deletion" + .to_vec(), + ); + } + let mined_count = signer_node.map(|node| node.blocks_mined).unwrap_or(0); + if mined_count < 100 { + return RpcResponse::Binary( + b"Error: This address cannot add nodes. It must mined 100 blocks before adding new nodes to the network" + .to_vec(), + ); + } + } + + let added_by_count_in_last_hour = address_map + .values() + .filter(|node| { + node.added_by == edit.modified_by + && (current_timestamp - node.added_timestamp) <= 3600 + }) + .count(); + + if added_by_count_in_last_hour >= 10 { + return RpcResponse::Binary( + b"Error: Cannot add more than 10 nodes in 60 minutes".to_vec(), + ); + } + + // Existing deleted entries can be revived in place when the same + // address/IP pair is re-announced, otherwise the older record is + // discarded and replaced. + if let Some(existing_node) = address_map.get_mut(&edit.address) { + if !existing_node.deleted_by.is_empty() { + if existing_node.ip == edit.ip { + existing_node.deleted_by = "".to_string(); + existing_node.deleted_timestamp = 0_u64; + existing_node.deleted_block = 0_u32; + existing_node.deleted_signature = "".to_string(); + return RpcResponse::Binary(b"Success".to_vec()); + } else { + address_map.remove(&edit.address); + } + } else { + if edit.modified_timestamp < existing_node.added_timestamp { + *existing_node = NodeInfo::new( + edit.ip.clone(), + blocks_mined, + edit.modified_by.clone(), + edit.modified_timestamp, + edit.modified_signature.clone(), + ); + } + return RpcResponse::Binary(b"Success".to_vec()); + } + } + + if let Some(existing_node) = address_map.values_mut().find(|node| node.ip == edit.ip) { + if !existing_node.deleted_by.is_empty() { + address_map.retain(|_, node| node.ip != edit.ip); + } else if edit.ip != GENESIS_IP { + penalize_duplicate_ip = true; + } + } + + if !penalize_duplicate_ip { + // Persist the new node locally. Network-map entries are bare + // IP membership records, separate from live socket keys. + address_map.insert( + edit.address.clone(), + NodeInfo::new( + edit.ip.clone(), + blocks_mined, + edit.modified_by.clone(), + edit.modified_timestamp, + edit.modified_signature.clone(), + ), + ); + } + } + + if penalize_duplicate_ip { + let now = Utc::now().timestamp() as u32; + let _ = update_ip_score( + &remote_ip, + "miner", + InfractionType::BadMinerIpUpdate, + now, + &db, + &wallet_key, + ) + .await; + return RpcResponse::Binary(b"Error: Ip Already exists.".to_vec()); + } + + Self::broadcast_node( + map.clone(), + &edit, + &remote_ip, + NodeEditType::Add, + &connections_key, + ) + .await; + + if direct_peer_announcement { + // Only direct self-announcements get a ping monitor. Imported or + // rebroadcast map records are not guaranteed to have a live stream. + Self::ping(PingMonitorParams { + map, + edit, + remote_ip, + db, + wallet_key, + connections_key, + }); + } + + RpcResponse::Binary(b"Success".to_vec()) + } + +} diff --git a/src/records/memory/network_mapping/delete.rs b/src/records/memory/network_mapping/delete.rs new file mode 100644 index 0000000..09c45bf --- /dev/null +++ b/src/records/memory/network_mapping/delete.rs @@ -0,0 +1,301 @@ +use super::*; + +impl NodeInfo { + pub fn ping(params: PingMonitorParams) { + tokio::spawn(async move { + let PingMonitorParams { + map, + edit, + remote_ip, + db, + wallet_key, + connections_key, + } = params; + let task_addr = edit.address.clone(); + let task_ip = edit.ip.clone(); + let task_wallet = edit.modified_by.clone(); + let task_signature = edit.modified_signature.clone(); + let added_timestamp = edit.modified_timestamp; + + let task_remote_ip = remote_ip; + let task_wallet_key = wallet_key; + let task_connections_key = connections_key; + let task_db = db.clone(); + + { + let mut monitors = PING_MONITORS.lock().await; + // A matching signature means this exact monitor is already active + // for the node, so do not start a duplicate ping task. + if monitors + .get(&task_addr) + .map(|current| current == &task_signature) + .unwrap_or(false) + { + return; + } + monitors.insert(task_addr.clone(), task_signature.clone()); + } + + let mut failures = 0; + + // Periodically ping the node and remove it after repeated + // failures, then recursively reattach any inherited children. + loop { + sleep(Duration::from_secs(120)).await; + + { + let monitors = PING_MONITORS.lock().await; + // Stop this task if another monitor replaced it. + if !monitors + .get(&task_addr) + .map(|current| current == &task_signature) + .unwrap_or(false) + { + break; + } + } + + { + let map_lock = ADDRESS_MAP.lock().await; + if let Some(node) = map_lock.get(&task_addr) { + // Stop monitoring stale node data after the map entry + // has been replaced by a newer edit. + if node.added_by != task_wallet || node.added_signature != task_signature { + break; + } + } else { + break; + } + } + + if let Some(unlocked_stream) = + Connection::get_stream_from_memory(&task_connections_key).await + { + let command = RPC_BLOCK_HEIGHT; + let (ping_key, _ping_tx, ping_rx) = reserve_entry(map.clone()).await; + + // Liveness uses a normal block-height RPC and waits for the + // reserved reply channel to receive any response. + let mut message = vec![command]; + message.extend_from_slice(&ping_key); + RpcResponse::send_raw(&unlocked_stream, Some(&task_connections_key), &message) + .await; + + let response = timeout(Duration::from_secs(10), async { + let mut rx = ping_rx.lock().await; + rx.recv().await + }) + .await; + + match response { + Ok(Some(_buffer)) => { + failures = 0; + } + _ => { + failures += 1; + warn!("[network_map] ping failure: address={task_addr} ip={task_ip} failures={failures}"); + if failures >= 3 { + warn!("[network_map] deleting node after ping failures: address={task_addr} ip={task_ip} responsible_by={task_wallet}"); + let _ = Self::delete_address(DeleteAddressParams { + map: map.clone(), + edit: SignedNodeEdit { + address: task_addr.clone(), + ip: task_ip.clone(), + modified_by: task_wallet.clone(), + modified_timestamp: added_timestamp, + modified_signature: task_signature.clone(), + }, + remote_ip: task_remote_ip.clone(), + db: task_db.clone(), + wallet_key: task_wallet_key.clone(), + connections_key: task_connections_key.clone(), + }) + .await; + + break; + } + } + } + } else { + // The direct socket disappeared before this monitor fired. + // Connection cleanup is owned by the connection manager, so + // stop this map monitor without marking the node deleted. + break; + } + } + + Self::release_ping_monitor(&task_addr, &task_signature).await; + }); + } + + pub async fn delete_address(params: DeleteAddressParams) -> RpcResponse { + let DeleteAddressParams { + map, + mut edit, + remote_ip, + db, + wallet_key, + connections_key, + } = params; + let current_timestamp = Utc::now().timestamp_millis() as u64; + + // Locally initiated deletions are re-signed with fresh metadata + // before they are applied and broadcast. + if remote_ip.is_empty() { + edit.modified_timestamp = current_timestamp; + let wallet = match Wallet::try_obtain_wallet(wallet_key.clone(), None).await { + Ok(wallet) => wallet, + Err(err) => { + error!("Wallet decryption failed while deleting node address: {err}"); + return RpcResponse::Binary(b"Error: Wallet decryption failed".to_vec()); + } + }; + edit.modified_by = wallet.saved.long_address; + edit.modified_signature = + Self::added_signature(&edit.address, &edit.ip, current_timestamp, &wallet_key) + .await; + } + + let data = format!( + "{}{}{}{}", + edit.address, edit.ip, edit.modified_by, edit.modified_timestamp + ); + let hashed_data = skein_256_hash_data(&data); + + if !Wallet::verify_transaction(&hashed_data, &edit.modified_signature, &edit.modified_by) + .await + { + return RpcResponse::Binary(b"Error: Could not validate signature".to_vec()); + } + + { + let mut address_map = ADDRESS_MAP.lock().await; + + if get_height(&db) > 10_000 { + // Mature chains only allow established miners to remove nodes. + let signer_key = Wallet::normalize_to_short_address(&edit.modified_by) + .unwrap_or_else(|| edit.modified_by.clone()); + let signer_node = address_map.get(&signer_key); + let valid_added_by = signer_node + .map(|node| { + (current_timestamp - node.added_timestamp) >= 3600 + && node.deleted_by.is_empty() + }) + .unwrap_or(false); + + if !valid_added_by { + return RpcResponse::Binary( + b"Error: Address must exist for 60m and not be deleted".to_vec(), + ); + } + + let mined_count = signer_node.map(|node| node.blocks_mined).unwrap_or(0); + if mined_count < 100 { + return RpcResponse::Binary( + b"Error: Must mine 100 blocks to remove nodes".to_vec(), + ); + } + } + + let deleted_count_last_hour = address_map + .values() + .filter(|node| { + node.deleted_by == edit.modified_by + && (current_timestamp - node.deleted_timestamp) <= 3600 + }) + .count(); + + // Rate limit delete events per signer to prevent churn in the + // shared network map. + if deleted_count_last_hour >= 10 { + return RpcResponse::Binary(b"Error: Max 10 deletions in 60m".to_vec()); + } + + if let Some(existing_node) = address_map.get_mut(&edit.address) { + if !existing_node.deleted_by.is_empty() { + return RpcResponse::Binary( + b"Error: This address has already been deleted".to_vec(), + ); + } + + // Deletion is recorded as metadata rather than immediately + // removing the node, preserving historical validation context. + existing_node.deleted_by = edit.modified_by.clone(); + existing_node.deleted_timestamp = current_timestamp; + existing_node.deleted_block = get_height(&db) + 1; + existing_node.deleted_signature = edit.modified_signature.clone(); + info!( + "[network_map] node marked deleted: address={} ip={} deleted_by={} timestamp={} deleted_block={}", + edit.address, + edit.ip, + edit.modified_by, + current_timestamp, + existing_node.deleted_block + ); + } else { + return RpcResponse::Binary(b"Error: Address not found".to_vec()); + } + } + + // Stop any ping task owned by the deleted node record. + Self::release_ping_monitor(&edit.address, &edit.modified_signature).await; + + // Deletions propagate to peers and also tear down any live + // outgoing connection so bootstrap can recover a replacement. + Self::broadcast_node( + map.clone(), + &edit, + &remote_ip, + NodeEditType::Delete, + &connections_key, + ) + .await; + + if let Some(port) = CONNECTIONS + .read() + .await + .as_ref() + .and_then(|conn| conn.find_outgoing_port(&edit.ip)) + { + let mut writer = CONNECTIONS.write().await; + if let Some(conn) = writer.as_mut() { + // Drop the live outgoing socket after marking the node deleted. + conn.drop_connection(ConnectionType::Outgoing, edit.ip.clone(), port); + info!( + "[connection_manager] dropped dead outgoing connection: {}:{}", + edit.ip, port + ); + } + drop(writer); + + let live_connection = { + let guard = CONNECTIONS.read().await; + guard.as_ref().and_then(|conn| { + conn.get_all_streams().first().and_then(|stream| { + conn.connection_key_for_stream(stream) + .map(|key| (key, Arc::clone(stream))) + }) + }) + }; + + if let Some((connections_key, new_stream)) = live_connection { + // Use any remaining live stream to refresh peer discovery and + // replace the removed outgoing connection. + let bootstrap_params = BootstrapParams { + stream: new_stream, + connections_key, + wallet_key: wallet_key.clone(), + db: db.clone(), + map: map.clone(), + first: false, + }; + spawn_reconnect_bootstrap(bootstrap_params); + } else { + warn!("[reconnect] No live stream found to bootstrap from"); + } + } + + RpcResponse::Binary(b"Success: Node marked as deleted".to_vec()) + } + +} diff --git a/src/records/memory/network_mapping/enums.rs b/src/records/memory/network_mapping/enums.rs new file mode 100644 index 0000000..9c0a13b --- /dev/null +++ b/src/records/memory/network_mapping/enums.rs @@ -0,0 +1,18 @@ +use crate::rpc::command_maps::{RPC_ADD_NETWORK_NODE, RPC_DELETE_NETWORK_NODE}; + +// NodeEditType keeps the network membership update type explicit at the call sites. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeEditType { + Add, + Delete, +} + +impl NodeEditType { + // The message type mapping keeps the wire format centralized in one place. + pub fn message_type(self) -> u8 { + match self { + NodeEditType::Add => RPC_ADD_NETWORK_NODE, + NodeEditType::Delete => RPC_DELETE_NETWORK_NODE, + } + } +} diff --git a/src/records/memory/network_mapping/mined_counts.rs b/src/records/memory/network_mapping/mined_counts.rs new file mode 100644 index 0000000..c545824 --- /dev/null +++ b/src/records/memory/network_mapping/mined_counts.rs @@ -0,0 +1,77 @@ +use super::*; + +impl NodeInfo { + pub async fn increment_mined(address: &str) { + let mut map = ADDRESS_MAP.lock().await; + if let Some(node_info) = map.get_mut(address) { + // Counts are capped at u8-safe policy maximum used by node rules. + if node_info.blocks_mined < 250 { + node_info.blocks_mined += 1; + } + } + } + + pub async fn decrement_mined(address: &str) { + let mut map = ADDRESS_MAP.lock().await; + if let Some(node_info) = map.get_mut(address) { + // Rollback can undo mined credit, but never below zero. + if node_info.blocks_mined > 0 { + node_info.blocks_mined -= 1; + } + } + } + + pub async fn get_mined_count(address: &str) -> u8 { + let map = ADDRESS_MAP.lock().await; + if let Some(node_info) = map.get(address) { + node_info.blocks_mined + } else { + 0 + } + } + + pub async fn set_deleted_block_from_mapping(address: &str, deleted_block: u32) { + let mut map = ADDRESS_MAP.lock().await; + if let Some(node_info) = map.get_mut(address) { + // The deletion height is filled in once the chain knows the block + // where the delete action becomes active. + node_info.deleted_block = deleted_block; + } + } + + pub async fn rebuild_mined_counts_from_chain(db: &Db) -> Result<(), String> { + // Recompute node mined counts directly from saved block headers + // so startup and recovery can rebuild memory-only state. + let current_height = get_height(db); + let mut mined_counts: HashMap = HashMap::new(); + + let start_height = if current_height > 0 { 1 } else { 0 }; + for block_number in start_height..=current_height { + let header = load_block_header(block_number).await?; + let miner = header.unmined_block.miner; + let entry = mined_counts.entry(miner).or_insert(0); + // Keep the rebuilt value under the same cap as live increments. + if *entry < 250 { + *entry += 1; + } + } + + { + let mut map = ADDRESS_MAP.lock().await; + + for node_info in map.values_mut() { + // Clear memory-only counts before applying the rebuilt chain + // totals so removed or inactive miners do not keep stale data. + node_info.blocks_mined = 0; + } + + for (address, mined_count) in mined_counts { + if let Some(node_info) = map.get_mut(&address) { + node_info.blocks_mined = mined_count; + } + } + } + Ok(()) + } + +} diff --git a/src/records/memory/network_mapping/mod.rs b/src/records/memory/network_mapping/mod.rs new file mode 100644 index 0000000..d905ead --- /dev/null +++ b/src/records/memory/network_mapping/mod.rs @@ -0,0 +1,91 @@ +use crate::common::binary_conversions::ip_to_binary; +use crate::common::network_startup::is_public_network_address; +use crate::common::skein::skein_256_hash_data; +use crate::common::types::GENESIS_IP; +use crate::decode; +use crate::lazy_static; +use crate::log::{error, info, warn}; +use crate::records::block_height::get_block_height::get_height; +use crate::records::ip_score::enums::InfractionType; +use crate::records::ip_score::score::update_ip_score; +use crate::records::memory::connections::{spawn_reconnect_bootstrap, CONNECTIONS}; +use crate::records::memory::enums::ConnectionType; +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::records::memory::network_mapping::enums::NodeEditType; +use crate::records::memory::network_mapping::structs::{ + AddAddressParams, DeleteAddressParams, PingMonitorParams, SignedNodeEdit, NODE_RECORD_BYTES, +}; +use crate::records::memory::structs::Connection; +use crate::records::unpack_block::unpack_header::load_block_header; +use crate::rpc::client::handshake_processing::BootstrapParams; +use crate::rpc::command_maps::RPC_BLOCK_HEIGHT; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::sleep; +use crate::timeout; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::Duration; +use crate::HashMap; +use crate::Mutex; +use crate::TcpStream; +use crate::Utc; + +lazy_static! { + static ref ADDRESS_MAP: Mutex> = Mutex::new(HashMap::new()); + static ref PING_MONITORS: Mutex> = Mutex::new(HashMap::new()); +} + +#[derive(Debug)] +pub struct NodeInfo { + ip: String, + blocks_mined: u8, + added_by: String, + added_timestamp: u64, + added_signature: String, + deleted_by: String, + deleted_timestamp: u64, + deleted_block: u32, + deleted_signature: String, +} + +impl NodeInfo { + async fn release_ping_monitor(address: &str, signature: &str) { + let mut monitors = PING_MONITORS.lock().await; + if monitors + .get(address) + .map(|current| current == signature) + .unwrap_or(false) + { + monitors.remove(address); + } + } + + fn new( + ip: String, + blocks_mined: u8, + added_by: String, + added_timestamp: u64, + added_signature: String, + ) -> Self { + NodeInfo { + ip, + blocks_mined, + added_by, + added_timestamp, + added_signature, + deleted_by: "".to_string(), + deleted_timestamp: 0_u64, + deleted_block: 0_u32, + deleted_signature: "".to_string(), + } + } +} + +mod add; +mod delete; +pub mod enums; +mod mined_counts; +mod queries; +pub mod structs; + diff --git a/src/records/memory/network_mapping/queries.rs b/src/records/memory/network_mapping/queries.rs new file mode 100644 index 0000000..0107987 --- /dev/null +++ b/src/records/memory/network_mapping/queries.rs @@ -0,0 +1,131 @@ +use super::*; + +impl NodeInfo { + pub async fn address_checkup(address: &str, block_number: u32) -> bool { + let map = ADDRESS_MAP.lock().await; + if let Some(node_info) = map.get(address) { + // Deleted nodes remain valid for blocks before their recorded + // deletion height, which keeps historical validation deterministic. + return node_info.deleted_block == 0 || block_number < node_info.deleted_block; + } + false + } + + + pub async fn find_address_by_ip(ip: &str) -> Option { + let map = ADDRESS_MAP.lock().await; + for (address, node_info) in map.iter() { + // Reverse lookup is needed when a peer is identified by socket IP + // but the node map is keyed by wallet short address. + if node_info.ip == ip { + return Some(address.clone()); + } + } + None + } + + pub async fn find_ip_by_address(address: &str) -> Option { + let map = ADDRESS_MAP.lock().await; + map.get(address).map(|node_info| node_info.ip.clone()) + } + + pub async fn active_node_ips() -> Vec { + let map = ADDRESS_MAP.lock().await; + map.values() + // Deleted nodes stay in the map for history, but active node + // lists should only return bare public IPs. + .filter(|node_info| node_info.deleted_by.is_empty()) + .map(|node_info| node_info.ip.clone()) + .collect() + } + + pub async fn get_deleted_addresses() -> Vec { + let map = ADDRESS_MAP.lock().await; + map.iter() + .filter_map(|(address, node_info)| { + // The RPC response is a packed list of deleted short-address + // bytes, so invalid address keys are skipped. + if node_info.deleted_timestamp > 0 { + Wallet::short_address_to_bytes(address) + } else { + None + } + }) + .flatten() + .collect() + } + + pub async fn request_valid_nodes() -> RpcResponse { + // Serialize the in-memory node map into the fixed binary layout + // used by peer bootstrap and node-list synchronization. + let map = ADDRESS_MAP.lock().await; + let mut data: Vec = Vec::with_capacity(map.len() * NODE_RECORD_BYTES); + + for (address, node_info) in map.iter() { + let address_bytes = match Wallet::short_address_to_bytes(address) { + Some(bytes) => bytes, + None => continue, + }; + let ip_bytes = ip_to_binary(&node_info.ip); + let blocks_mined = node_info.blocks_mined; + let added_by_bytes = Wallet::long_address_to_bytes(node_info.added_by.to_string()); + let added_timestamp_bytes = node_info.added_timestamp.to_le_bytes(); + + // Empty deletion fields serialize as zero-filled fixed-width values + // so every node record stays the same size on the wire. + let deleted_by_bytes = if node_info.deleted_by.is_empty() { + vec![0u8; Wallet::ADDRESS_BYTES_LENGTH] + } else { + Wallet::long_address_to_bytes(node_info.deleted_by.to_string()) + }; + + let deleted_timestamp_bytes = node_info.deleted_timestamp.to_le_bytes(); + let deleted_block_bytes = node_info.deleted_block.to_le_bytes(); + + let deleted_signature_bytes = if node_info.deleted_signature.is_empty() { + vec![0u8; Wallet::SIGNATURE_LENGTH] + } else { + decode(node_info.deleted_signature.clone()).unwrap() + }; + + let added_signature_bytes = decode(node_info.added_signature.clone()).unwrap(); + + // Field order here must match the parser used by node-list + // synchronization. + data.extend_from_slice(&address_bytes); + data.extend_from_slice(&ip_bytes); + data.push(blocks_mined); + data.extend_from_slice(&added_by_bytes); + data.extend_from_slice(&added_timestamp_bytes); + data.extend_from_slice(&added_signature_bytes); + data.extend_from_slice(&deleted_by_bytes); + data.extend_from_slice(&deleted_timestamp_bytes); + data.extend_from_slice(&deleted_block_bytes); + data.extend_from_slice(&deleted_signature_bytes); + } + RpcResponse::Binary(data) + } + + pub async fn added_signature( + address: &str, + ip: &str, + current_timestamp: u64, + wallet_key: &str, + ) -> String { + // Node edits are signed over address, IP, signer, and timestamp + // so peers can independently verify the advertised change. + let wallet = match Wallet::try_obtain_wallet(wallet_key.to_string(), None).await { + Ok(wallet) => wallet, + Err(err) => { + error!("Wallet decryption failed while signing node edit: {err}"); + return String::new(); + } + }; + let added_by = wallet.saved.long_address; + let private_key = wallet.saved.private_key; + + let data = format!("{address}{ip}{added_by}{current_timestamp}"); + let hashed_data = skein_256_hash_data(&data); + Wallet::sign_transaction(&hashed_data, &private_key).await + } +} diff --git a/src/records/memory/network_mapping/structs.rs b/src/records/memory/network_mapping/structs.rs new file mode 100644 index 0000000..f617f0a --- /dev/null +++ b/src/records/memory/network_mapping/structs.rs @@ -0,0 +1,68 @@ +use crate::records::memory::response_channels::Command; +use crate::sled::Db; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::Mutex; + +pub const NODE_IP_BYTES: usize = 16; +pub const NODE_BLOCKS_MINED_BYTES: usize = 1; +pub const NODE_TIMESTAMP_BYTES: usize = 8; +pub const NODE_DELETED_BLOCK_BYTES: usize = 4; + +pub const NODE_ADDRESS_OFFSET: usize = 0; +pub const NODE_IP_OFFSET: usize = NODE_ADDRESS_OFFSET + Wallet::SHORT_ADDRESS_BYTES_LENGTH; +pub const NODE_BLOCKS_MINED_OFFSET: usize = NODE_IP_OFFSET + NODE_IP_BYTES; +pub const NODE_ADDED_BY_OFFSET: usize = NODE_BLOCKS_MINED_OFFSET + NODE_BLOCKS_MINED_BYTES; +pub const NODE_ADDED_TIMESTAMP_OFFSET: usize = NODE_ADDED_BY_OFFSET + Wallet::ADDRESS_BYTES_LENGTH; +pub const NODE_ADDED_SIGNATURE_OFFSET: usize = NODE_ADDED_TIMESTAMP_OFFSET + NODE_TIMESTAMP_BYTES; +pub const NODE_DELETED_BY_OFFSET: usize = NODE_ADDED_SIGNATURE_OFFSET + Wallet::SIGNATURE_LENGTH; +pub const NODE_DELETED_TIMESTAMP_OFFSET: usize = + NODE_DELETED_BY_OFFSET + Wallet::ADDRESS_BYTES_LENGTH; +pub const NODE_DELETED_BLOCK_OFFSET: usize = NODE_DELETED_TIMESTAMP_OFFSET + NODE_TIMESTAMP_BYTES; +pub const NODE_DELETED_SIGNATURE_OFFSET: usize = + NODE_DELETED_BLOCK_OFFSET + NODE_DELETED_BLOCK_BYTES; +pub const NODE_RECORD_BYTES: usize = NODE_DELETED_SIGNATURE_OFFSET + Wallet::SIGNATURE_LENGTH; + +// SignedNodeEdit carries the signed node membership payload used by add/delete updates. +#[derive(Debug, Clone)] +pub struct SignedNodeEdit { + pub address: String, + pub ip: String, + pub modified_by: String, + pub modified_timestamp: u64, + pub modified_signature: String, +} + +// AddAddressParams groups the shared context needed to add a node to the network map. +#[derive(Clone)] +pub struct AddAddressParams { + pub map: Arc>, + pub edit: SignedNodeEdit, + pub blocks_mined: u8, + pub remote_ip: String, + pub db: Db, + pub wallet_key: String, + pub connections_key: String, +} + +// DeleteAddressParams groups the shared context needed to remove a node from the network map. +#[derive(Clone)] +pub struct DeleteAddressParams { + pub map: Arc>, + pub edit: SignedNodeEdit, + pub remote_ip: String, + pub db: Db, + pub wallet_key: String, + pub connections_key: String, +} + +// PingMonitorParams keeps the background liveness-monitor arguments bundled together. +#[derive(Clone)] +pub struct PingMonitorParams { + pub map: Arc>, + pub edit: SignedNodeEdit, + pub remote_ip: String, + pub db: Db, + pub wallet_key: String, + pub connections_key: String, +} diff --git a/src/records/memory/response_channels.rs b/src/records/memory/response_channels.rs new file mode 100644 index 0000000..edbb956 --- /dev/null +++ b/src/records/memory/response_channels.rs @@ -0,0 +1,117 @@ +use crate::mpsc; +use crate::Arc; +use crate::Duration; +use crate::HashMap; +use crate::Instant; +use crate::Mutex; +use rand::Rng; + +pub struct ChannelPair { + pub tx: mpsc::Sender>, + pub rx: Arc>>>, + pub expires_at: Option, +} + +pub type Byte3 = [u8; 3]; + +pub type Command = HashMap; + +fn random_3_byte_number() -> [u8; 3] { + let mut rng = rand::thread_rng(); + let num: u32 = rng.gen_range(0..=0xFFFFFF); + // The protocol UID is three bytes on the wire, so the random u32 is sliced + // down to the same fixed-width little-endian layout used by requests. + + num.to_le_bytes()[1..4].try_into().unwrap() +} + +// Generate an untracked three-byte UID for fire-and-forget messages +// that never reserve a reply channel in the shared command map. +pub fn generate_uid() -> [u8; 3] { + random_3_byte_number() +} + +pub fn convert_to_decimal(bytes: [u8; 3]) -> u32 { + // Pad the three-byte UID back into a u32 for logging and display only. + let mut padded_bytes = [0u8; 4]; + padded_bytes[1..].copy_from_slice(&bytes); + u32::from_le_bytes(padded_bytes) +} + +// Reserve a unique reply slot and return both the UID and its paired +// channel in one atomic step so request code cannot collide or race. +pub async fn reserve_entry( + map: Arc>, +) -> ( + Byte3, + mpsc::Sender>, + Arc>>>, +) { + loop { + let key = random_3_byte_number(); + let (tx, rx) = { + let mut map_guard = map.lock().await; + let now = Instant::now(); + // Expired retired entries are removed before reserving a new UID so + // the map does not keep old request channels forever. + map_guard.retain(|_, channel_pair| { + channel_pair + .expires_at + .map(|expires_at| expires_at > now) + .unwrap_or(true) + }); + if map_guard.contains_key(&key) { + continue; + } + + // Store the sender and receiver together so replies can be routed + // by UID while the caller waits on the returned receiver. + let (tx, rx) = mpsc::channel(50000); + let rx = Arc::new(Mutex::new(rx)); + map_guard.insert( + key, + ChannelPair { + tx: tx.clone(), + rx: rx.clone(), + expires_at: None, + }, + ); + (tx, rx) + }; + return (key, tx, rx); + } +} + +pub async fn get_entry(map: Arc>, key: Byte3) -> Option>> { + let map = map.lock().await; + if let Some(channel_pair) = map.get(&key) { + // Retired entries are intentionally still present for a short window, + // but they should not receive any more replies. + if channel_pair.expires_at.is_some() { + return None; + } + let tx = channel_pair.tx.clone(); + Some(tx) + } else { + None + } +} + +pub async fn is_retired_entry(map: Arc>, key: Byte3) -> bool { + let map = map.lock().await; + // A retired entry means the UID recently existed and should be ignored + // until its short grace period expires. + map.get(&key) + .and_then(|channel_pair| channel_pair.expires_at) + .map(|expires_at| expires_at > Instant::now()) + .unwrap_or(false) +} + +pub async fn delete_entry(map: Arc>, key: Byte3) { + let mut map = map.lock().await; + if let Some(channel_pair) = map.get_mut(&key) { + // Keep the UID reserved briefly after completion so a late duplicate + // reply cannot be mistaken for a fresh request. + channel_pair.expires_at = Some(Instant::now() + Duration::from_secs(30)); + } +} diff --git a/src/records/memory/structs.rs b/src/records/memory/structs.rs new file mode 100644 index 0000000..bbdefac --- /dev/null +++ b/src/records/memory/structs.rs @@ -0,0 +1,286 @@ +use crate::records::memory::enums::{ClientType, ConnectionType}; +use crate::records::memory::response_channels::Command; +use crate::Arc; +use crate::BinaryHeap; +use crate::HashMap; +use crate::Mutex; +use crate::Ordering; +use crate::TcpStream; + +// ConnectionInfo stores the live metadata associated with one tracked peer. +#[derive(Debug, Clone)] +pub struct ConnectionInfo { + pub connection_type: Vec, + pub ip: Vec, + pub port: u16, + pub stream: Arc>, + pub client_type: Vec, + pub wallet_address: Vec, +} + +// ConnectionKey is the stable lookup key used inside the in-memory connection map. +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub struct ConnectionKey { + pub connection_type: Vec, + pub ip: Vec, + pub port: u16, +} + +pub struct StoreConnectionParams { + // Store connection inputs as named fields so incoming and outgoing + // handshake paths cannot accidentally swap endpoint or identity data. + pub connection_type: ConnectionType, + pub ip: String, + pub port: u16, + pub stream: Arc>, + pub client_type: ClientType, + pub wallet_address: String, + pub command_map: Arc>, +} + +// The indexed transaction structs keep the original transaction bytes together with +// the fields needed for mempool ordering and later block assembly. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedTransferTransaction { + pub index: usize, + pub fee: u64, + pub time: u32, + pub sender: Vec, + pub value: u64, + pub coin: Vec, + pub nft_series: u32, + pub receiver: Vec, + pub txid: Vec, + pub signature: Vec, + pub original: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedCreateTokenTransaction { + pub index: usize, + pub fee: u64, + pub time: u32, + pub creator: Vec, + pub number: u64, + pub ticker: Vec, + pub txid: Vec, + pub signature: Vec, + pub original: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedIssueTokenTransaction { + pub index: usize, + pub fee: u64, + pub time: u32, + pub creator: Vec, + pub number: u64, + pub ticker: Vec, + pub txid: Vec, + pub signature: Vec, + pub original: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedBurnTransaction { + pub index: usize, + pub fee: u64, + pub time: u32, + pub address: Vec, + pub coin: Vec, + pub nft_series: u32, + pub value: u64, + pub txid: Vec, + pub signature: Vec, + pub original: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedCreateNftTransaction { + pub index: usize, + pub fee: u64, + pub time: u32, + pub creator: Vec, + pub nft_name: Vec, + pub series: u8, + pub count: u32, + pub txid: Vec, + pub signature: Vec, + pub original: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedMarketingTransaction { + pub index: usize, + pub fee: u64, + pub time: u32, + pub advertiser: Vec, + pub txid: Vec, + pub signature: Vec, + pub original: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedSwapTransaction { + pub index: usize, + pub fee1: u64, + pub fee2: u64, + pub time: u32, + pub ticker1: Vec, + pub nft_series1: u32, + pub ticker2: Vec, + pub nft_series2: u32, + pub sender1: Vec, + pub value1: u64, + pub value2: u64, + pub tip1: u64, + pub tip2: u64, + pub sender2: Vec, + pub txid: Vec, + pub signature1: Vec, + pub signature2: Vec, + pub original: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedLoanContractTransaction { + pub index: usize, + pub fee: u64, + pub time: u32, + pub loan_coin: Vec, + pub loan_amount: u64, + pub lender: Vec, + pub collateral: Vec, + pub collateral_amount: u64, + pub borrower: Vec, + pub txid: Vec, + pub signature1: Vec, + pub signature2: Vec, + pub original: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedContractPaymentTransaction { + pub index: usize, + pub fee: u64, + pub time: u32, + pub payback_amount: u64, + pub contract_hash: Vec, + pub address: Vec, + pub tip: u64, + pub txid: Vec, + pub signature: Vec, + pub original: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct IndexedCollateralClaimTransaction { + pub index: usize, + pub fee: u64, + pub time: u32, + pub contract_hash: Vec, + pub address: Vec, + pub txid: Vec, + pub signature: Vec, + pub original: Vec, +} + +#[derive(Clone, Hash, PartialEq, Eq)] +pub struct BalanceKey { + pub address: Vec, + pub coin: Vec, +} + +impl ConnectionInfo { + // The constructor keeps connection creation consistent across the memory layer. + pub fn new( + connection_type: Vec, + ip: Vec, + port: u16, + stream: Arc>, + client_type: Vec, + wallet_address: Vec, + ) -> Self { + ConnectionInfo { + connection_type, + ip, + port, + stream, + client_type, + wallet_address, + } + } +} + +// IndexedTransaction wraps the supported mempool transaction types so they can share +// the same heap ordering and storage path. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum IndexedTransaction { + Transfer(IndexedTransferTransaction), + Token(IndexedCreateTokenTransaction), + IssueToken(IndexedIssueTokenTransaction), + Burn(IndexedBurnTransaction), + Nft(IndexedCreateNftTransaction), + Marketing(IndexedMarketingTransaction), + Swap(IndexedSwapTransaction), + Lender(IndexedLoanContractTransaction), + Borrower(IndexedContractPaymentTransaction), + Collateral(IndexedCollateralClaimTransaction), +} + +impl Ord for IndexedTransaction { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (IndexedTransaction::Transfer(tx1), IndexedTransaction::Transfer(tx2)) => { + tx1.fee.cmp(&tx2.fee).then_with(|| tx1.time.cmp(&tx2.time)) + } + (IndexedTransaction::Token(tx1), IndexedTransaction::Token(tx2)) => { + tx1.fee.cmp(&tx2.fee).then_with(|| tx1.time.cmp(&tx2.time)) + } + (IndexedTransaction::IssueToken(tx1), IndexedTransaction::IssueToken(tx2)) => { + tx1.fee.cmp(&tx2.fee).then_with(|| tx1.time.cmp(&tx2.time)) + } + (IndexedTransaction::Burn(tx1), IndexedTransaction::Burn(tx2)) => { + tx1.fee.cmp(&tx2.fee).then_with(|| tx1.time.cmp(&tx2.time)) + } + (IndexedTransaction::Nft(tx1), IndexedTransaction::Nft(tx2)) => { + tx1.fee.cmp(&tx2.fee).then_with(|| tx1.time.cmp(&tx2.time)) + } + (IndexedTransaction::Marketing(tx1), IndexedTransaction::Marketing(tx2)) => { + tx1.fee.cmp(&tx2.fee).then_with(|| tx1.time.cmp(&tx2.time)) + } + (IndexedTransaction::Swap(tx1), IndexedTransaction::Swap(tx2)) => tx1 + .fee1 + .cmp(&tx2.fee1) + .then_with(|| tx1.fee2.cmp(&tx2.fee2)) + .then_with(|| tx1.time.cmp(&tx2.time)), + (IndexedTransaction::Lender(tx1), IndexedTransaction::Lender(tx2)) => { + tx1.fee.cmp(&tx2.fee).then_with(|| tx1.time.cmp(&tx2.time)) + } + (IndexedTransaction::Borrower(tx1), IndexedTransaction::Borrower(tx2)) => { + tx1.fee.cmp(&tx2.fee).then_with(|| tx1.time.cmp(&tx2.time)) + } + (IndexedTransaction::Collateral(tx1), IndexedTransaction::Collateral(tx2)) => { + tx1.fee.cmp(&tx2.fee).then_with(|| tx1.time.cmp(&tx2.time)) + } + _ => Ordering::Equal, // Add more cases as needed + } + } +} + +impl PartialOrd for IndexedTransaction { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Default, Debug, Clone)] +pub struct Connection { + pub connection_map: HashMap, +} + +// Memory groups the transient in-memory structures that are rebuilt from live runtime state. +#[derive(Default, Debug, Clone)] +pub struct Memory { + pub transaction_heap: BinaryHeap, +} diff --git a/src/records/memory/torrent_status.rs b/src/records/memory/torrent_status.rs new file mode 100644 index 0000000..09fdcd6 --- /dev/null +++ b/src/records/memory/torrent_status.rs @@ -0,0 +1,72 @@ +use crate::{lazy_static, HashMap, Mutex}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TorrentStatus { + Pending, + Valid, + Invalid, +} + +lazy_static! { + static ref TORRENT_STATUS: Mutex> = Mutex::new(HashMap::new()); +} + +fn status_key(height: u32, info_hash: &str) -> String { + // Height and torrent hash together identify one candidate block torrent. + format!("{height}:{info_hash}") +} + +pub async fn set_torrent_status(height: u32, info_hash: &str, status: TorrentStatus) { + let mut map = TORRENT_STATUS.lock().await; + // Status is kept in memory because it only guides current download and + // validation decisions. + map.insert(status_key(height, info_hash), status); +} + +pub async fn get_torrent_status(height: u32, info_hash: &str) -> TorrentStatus { + let map = TORRENT_STATUS.lock().await; + // Unknown torrents are treated as pending so new candidates can still be + // downloaded and validated. + map.get(&status_key(height, info_hash)) + .copied() + .unwrap_or(TorrentStatus::Pending) +} + +pub async fn prune_torrent_statuses_through_height(cutoff_height: u32) { + let mut map = TORRENT_STATUS.lock().await; + map.retain(|key, _| { + // Once a height is finalized, old candidate statuses are no longer + // useful for orphan or torrent decisions. + let Some((height, _)) = key.split_once(':') else { + return false; + }; + match height.parse::() { + Ok(height) => height > cutoff_height, + Err(_) => false, + } + }); +} + +pub async fn reset_all_torrent_statuses() { + let mut map = TORRENT_STATUS.lock().await; + for status in map.values_mut() { + // A rollback changes the parent chain used to judge staged torrents, + // so every remembered candidate outcome must be checked again. + *status = TorrentStatus::Pending; + } +} + +pub async fn mark_other_torrent_statuses_invalid(height: u32, selected_info_hash: &str) { + let mut map = TORRENT_STATUS.lock().await; + let prefix = format!("{height}:"); + for (key, status) in map.iter_mut() { + // Once one candidate wins a height, every other remembered candidate + // at that same height has been checked and lost. + let Some((_, info_hash)) = key.split_once(':') else { + continue; + }; + if key.starts_with(&prefix) && info_hash != selected_info_hash { + *status = TorrentStatus::Invalid; + } + } +} diff --git a/src/records/memory/torrentmap.rs b/src/records/memory/torrentmap.rs new file mode 100644 index 0000000..2c098e8 --- /dev/null +++ b/src/records/memory/torrentmap.rs @@ -0,0 +1,144 @@ +use crate::torrent::structs::PieceStatus; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +pub enum PieceReservation { + // The requested piece was assigned to this peer. + Reserved, + // The piece is already downloading or complete. + PieceUnavailable, + // This peer is not a good candidate for this piece right now. + PeerUnavailable, +} + +#[derive(Debug)] +pub struct Files { + pub status: PieceStatus, + pub ip: Option, + pub failed_ips: Vec, +} + +#[derive(Debug)] +pub struct TorrentMap { + pub pieces: HashMap, +} + +impl TorrentMap { + pub fn add_piece( + &mut self, + piece_number: u8, + status: PieceStatus, + ip: Option, + failed_ips: Vec, + ) { + // Every piece starts with its current status plus the peer history used + // to avoid repeatedly asking the same failing peer. + let file = Files { + status, + ip, + failed_ips, + }; + self.pieces.insert(piece_number, file); + } + + pub fn update_piece( + &mut self, + piece_number: u8, + status: Option, + ip: Option, + failed_ip: Option, + ) -> Result<(), String> { + if let Some(piece) = self.pieces.get_mut(&piece_number) { + // Only fields supplied by the caller are changed, which lets the + // downloader update status, owner IP, and failure history separately. + if let Some(new_status) = status { + piece.status = new_status; + } + if let Some(new_ip) = ip { + piece.ip = Some(new_ip); + } + if let Some(new_failed_ip) = failed_ip { + piece.failed_ips.push(new_failed_ip); + } + Ok(()) + } else { + Err(format!("Piece number {piece_number} not found")) + } + } + + pub fn get_piece_data(&self, piece_number: u8) -> Option<&Files> { + self.pieces.get(&piece_number) + } + + pub fn try_reserve_piece_for_ip( + &mut self, + piece_number: u8, + ip: &str, + ) -> Result { + if let Some(piece) = self.pieces.get(&piece_number) { + // Do not reassign a piece to a peer that already failed it. + if piece.failed_ips.iter().any(|failed_ip| failed_ip == ip) { + return Ok(PieceReservation::PeerUnavailable); + } + // Completed and actively downloading pieces are not available for + // another reservation. + match piece.status { + PieceStatus::Downloading | PieceStatus::Complete => { + return Ok(PieceReservation::PieceUnavailable); + } + PieceStatus::Pending | PieceStatus::Failed => {} + } + } else { + return Err(format!("Piece number {piece_number} not found")); + } + + for (existing_piece_number, piece) in &self.pieces { + if *existing_piece_number == piece_number { + continue; + } + // A peer should only carry one active piece at a time so one slow + // connection cannot stall several pieces at once. + if piece.status == PieceStatus::Downloading && piece.ip.as_deref() == Some(ip) { + return Ok(PieceReservation::PeerUnavailable); + } + } + + let piece = self + .pieces + .get_mut(&piece_number) + .ok_or_else(|| format!("Piece number {piece_number} not found"))?; + // Reservation marks ownership immediately; completion/failure will + // clear the IP when the piece finishes. + piece.status = PieceStatus::Downloading; + piece.ip = Some(ip.to_string()); + Ok(PieceReservation::Reserved) + } + + pub fn mark_piece_complete(&mut self, piece_number: u8) -> Result<(), String> { + let piece = self + .pieces + .get_mut(&piece_number) + .ok_or_else(|| format!("Piece number {piece_number} not found"))?; + // A completed piece no longer belongs to a peer. + piece.status = PieceStatus::Complete; + piece.ip = None; + Ok(()) + } + + pub fn mark_piece_failed(&mut self, piece_number: u8, failed_ip: &str) -> Result<(), String> { + let piece = self + .pieces + .get_mut(&piece_number) + .ok_or_else(|| format!("Piece number {piece_number} not found"))?; + // Failed pieces return to the pool, but remember which peer failed. + piece.status = PieceStatus::Failed; + piece.ip = None; + if !piece.failed_ips.iter().any(|ip| ip == failed_ip) { + piece.failed_ips.push(failed_ip.to_string()); + } + Ok(()) + } +} + +// Type alias for shared TorrentMap +pub type SharedTorrentMap = Arc>; diff --git a/src/records/mod.rs b/src/records/mod.rs new file mode 100644 index 0000000..0c078c6 --- /dev/null +++ b/src/records/mod.rs @@ -0,0 +1,8 @@ +// The records module contains the persistent and in-memory storage layers used by the node. +pub mod balance_sheet; +pub mod block_height; +pub mod ip_score; +pub mod memory; +pub mod record_chain; +pub mod unpack_block; +pub mod wallet_registry; diff --git a/src/records/record_chain/add_payments_db.rs b/src/records/record_chain/add_payments_db.rs new file mode 100644 index 0000000..d16a1e3 --- /dev/null +++ b/src/records/record_chain/add_payments_db.rs @@ -0,0 +1,84 @@ +use sled::Db; + +// Contract payment totals are stored as a simple packed list of u64 values +// under the loan contract key. +fn serialize_value(value: &[u64]) -> Vec { + let mut bytes = vec![]; + for &num in value { + bytes.extend_from_slice(&num.to_le_bytes()); + } + bytes +} + +// Rebuild the payment list from the packed on-disk representation. +fn deserialize_value(bytes: &[u8]) -> Option> { + let mut vec = vec![]; + let mut offset = 0; + while offset + 8 <= bytes.len() { + vec.push(u64::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ])); + offset += 8; + } + if offset == bytes.len() { + Some(vec) + } else { + None + } +} + +pub fn add_payment(db: &Db, contract_id: Vec, payment: u64) -> Result<(), String> { + // Append a new payment amount to the stored payment history for the + // referenced loan contract. + let tree = db + .open_tree("contract_payments") + .map_err(|e| format!("Failed to open contract_payments tree: {e}"))?; + + let mut payments = match tree.get(contract_id.clone()) { + Ok(Some(p)) => deserialize_value(&p).unwrap_or_default(), + _ => vec![], + }; + + payments.push(payment); + + let serialized_payments = serialize_value(&payments); + + tree.insert(contract_id, serialized_payments) + .map_err(|e| format!("Failed to save contract payment history: {e}"))?; + Ok(()) +} + +pub fn remove_payment(db: &Db, contract_id: Vec, payment: u64) -> Result<(), String> { + // Remove the most recent matching payment value during orphan + // rollback or other contract-history corrections. + let tree = db + .open_tree("contract_payments") + .map_err(|e| format!("Failed to open contract_payments tree: {e}"))?; + + let mut payments = match tree.get(contract_id.clone()) { + Ok(Some(p)) => deserialize_value(&p).unwrap_or_default(), + _ => vec![], + }; + + if let Some(index) = payments + .iter() + .rposition(|stored_payment| *stored_payment == payment) + { + payments.remove(index); + + if payments.is_empty() { + let _ = tree.remove(contract_id); + } else { + let serialized_payments = serialize_value(&payments); + let _ = tree.insert(contract_id, serialized_payments); + } + } + Ok(()) +} diff --git a/src/records/record_chain/borrower_tx.rs b/src/records/record_chain/borrower_tx.rs new file mode 100644 index 0000000..56088c9 --- /dev/null +++ b/src/records/record_chain/borrower_tx.rs @@ -0,0 +1,121 @@ +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_borrower( + transaction: &ContractPaymentTransaction, + mut binary_data: Vec, + db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the payment transaction and compute its txid before + // loading the referenced loan contract for settlement details. + let txhash = transaction.unsigned_contract_payment.hash().await; + let txhash_bytes = + decode(&txhash).map_err(|e| format!("Error decoding borrower txhash: {e}"))?; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + binary_data.extend(transaction_bytes); + + // Load the referenced loan contract so the payment can be applied to + // the correct lender and loan-asset balance. + let contract_hash = decode(&transaction.unsigned_contract_payment.contract_hash) + .map_err(|e| format!("Error decoding borrower contract hash: {e}"))?; + let contract = request_transaction_by_txid(db, contract_hash).await; + + let loan_txtype = 7; + let loan_tx = match &contract { + RpcResponse::Binary(contract_bytes) => { + if contract_bytes.is_empty() { + return Err("Invalid loan contract: empty transaction bytes".to_string()); + } + if contract_bytes[0] != loan_txtype { + return Err( + "Invalid loan contract: referenced transaction is not a loan contract" + .to_string(), + ); + } + LoanContractTransaction::from_bytes(loan_txtype, &contract_bytes[1..]) + .await + .map_err(|e| e.to_string())? + } + }; + + // Extract loan_coin and lender from loan contract + let loan_coin = loan_tx.unsigned_loan_contract.loan_coin; + let lender = loan_tx.unsigned_loan_contract.lender; + + // Loan payments move the base-coin fee to the miner, transfer the + // repayment and tip in the loan asset, and then record the payment. + pending_effects.add_balance( + &miner, + transaction.unsigned_contract_payment.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_contract_payment.address, + transaction.unsigned_contract_payment.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &lender, + transaction.unsigned_contract_payment.payback_amount, + &loan_coin, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_contract_payment.address, + transaction.unsigned_contract_payment.payback_amount, + &loan_coin, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &miner, + transaction.unsigned_contract_payment.tip, + &loan_coin, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_contract_payment.address, + transaction.unsigned_contract_payment.tip, + &loan_coin, + BalanceOperand::Subtraction, + ); + + // Record the txid location, append NFT provenance when the loan asset + // is an NFT, and persist the payment against the contract history. + let txkey = decode(txhash).map_err(|e| format!("Error decoding borrower txid key: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + pending_effects.append_tree_if_key_exists( + "nfts", + "nft_history", + loan_coin.as_bytes().to_vec(), + txhash_bytes, + ); + pending_effects.append_contract_payment( + decode(&transaction.unsigned_contract_payment.contract_hash) + .map_err(|e| format!("Error decoding borrower payment contract hash: {e}"))?, + transaction.unsigned_contract_payment.payback_amount, + ); + Ok(binary_data) +} diff --git a/src/records/record_chain/burn_tx.rs b/src/records/record_chain/burn_tx.rs new file mode 100644 index 0000000..82bb8df --- /dev/null +++ b/src/records/record_chain/burn_tx.rs @@ -0,0 +1,75 @@ +use crate::blocks::burn::BurnTransaction; +use crate::common::nft_assets::nft_asset_name; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_burn( + transaction: &BurnTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so txid lookups can point + // back to the exact saved transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the burn transaction and compute its txid before + // applying the permanent balance-sheet and asset-state updates. + let txhash = transaction.unsigned_burn.hash().await; + let txhash_bytes = decode(&txhash).map_err(|e| format!("Error decoding burn txhash: {e}"))?; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(error) => return Err(error.to_string()), + }; + binary_data.extend(transaction_bytes); + + // Burning debits the burner's asset balance, moves the fee to the + // miner, and updates the live token or NFT registry state. + let burned_asset = nft_asset_name( + &transaction.unsigned_burn.coin, + transaction.unsigned_burn.nft_series, + ); + pending_effects.add_balance( + &miner, + transaction.unsigned_burn.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_burn.address, + transaction.unsigned_burn.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_burn.address, + transaction.unsigned_burn.value, + &burned_asset, + BalanceOperand::Subtraction, + ); + + // Live NFT state removes the burned asset entirely, while fungible + // token state reduces the stored supply and removes zero-supply rows. + pending_effects.burn_asset_state( + &burned_asset, + &transaction.unsigned_burn.coin, + txhash_bytes.clone(), + transaction.unsigned_burn.value, + ); + + // Record the txid location so RPC lookups can resolve the saved + // transaction back to its block and index position. + let txkey = decode(txhash).map_err(|e| format!("Error decoding burn txid key: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + + Ok(binary_data) +} diff --git a/src/records/record_chain/collateral_tx.rs b/src/records/record_chain/collateral_tx.rs new file mode 100644 index 0000000..97c4381 --- /dev/null +++ b/src/records/record_chain/collateral_tx.rs @@ -0,0 +1,109 @@ +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_collateral( + transaction: &CollateralClaimTransaction, + mut binary_data: Vec, + db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the collateral-claim transaction and compute its txid + // before loading the referenced loan contract details. + let txhash = transaction.unsigned_collateral_claim.hash().await; + let txhash_bytes = decode(&txhash).map_err(|e| format!("Error decoding hex: {e}"))?; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + binary_data.extend(transaction_bytes); + + // Load the referenced loan contract so the collateral asset and + // escrow holding account can be resolved correctly. + let contract_bytes = decode(&transaction.unsigned_collateral_claim.contract_hash) + .map_err(|e| format!("Error decoding collateral contract hash: {e}"))?; + let contract = request_transaction_by_txid(db, contract_bytes).await; + + let loan_txtype = 7; + let loan_tx = match &contract { + RpcResponse::Binary(bytes) => { + if bytes.is_empty() { + return Err("Invalid loan contract: empty transaction bytes".to_string()); + } + if bytes[0] != loan_txtype { + return Err( + "Invalid loan contract: referenced transaction is not a loan contract" + .to_string(), + ); + } + LoanContractTransaction::from_bytes(loan_txtype, &bytes[1..]) + .await + .map_err(|e| e.to_string())? + } + }; + + let collateral = loan_tx.unsigned_loan_contract.collateral; + let collateral_amount = loan_tx.unsigned_loan_contract.collateral_amount; + let collateral_holding = format!( + "collateral_{}", + transaction.unsigned_collateral_claim.contract_hash + ); + + // Collateral claims credit the miner fee, return the collateral to + // the claimant, and debit the escrow holding balance. + pending_effects.add_balance( + &miner, + transaction.unsigned_collateral_claim.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_collateral_claim.address, + transaction.unsigned_collateral_claim.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_collateral_claim.address, + collateral_amount, + &collateral, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &collateral_holding, + collateral_amount, + &collateral, + BalanceOperand::Subtraction, + ); + + // Record the txid location, append NFT provenance when the collateral + // asset is an NFT, and mark the loan contract as closed in the loan tree. + let txkey = decode(txhash).map_err(|e| format!("Error decoding hex: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + pending_effects.append_tree_if_key_exists( + "nfts", + "nft_history", + collateral.as_bytes().to_vec(), + txhash_bytes, + ); + let loankey = decode(&transaction.unsigned_collateral_claim.contract_hash) + .map_err(|e| format!("Error decoding hex: {e}"))?; + pending_effects.set_tree("loan", loankey, b"false".to_vec()); + Ok(binary_data) +} diff --git a/src/records/record_chain/genesis_tx.rs b/src/records/record_chain/genesis_tx.rs new file mode 100644 index 0000000..84189be --- /dev/null +++ b/src/records/record_chain/genesis_tx.rs @@ -0,0 +1,35 @@ +use crate::blocks::genesis::GenesisTransaction; +use crate::decode; +use crate::records::record_chain::pending_effects::PendingEffects; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +// Genesis processing writes the serialized genesis transaction and +// registers its txid location like any other saved block transaction. +pub async fn process_genesis( + transaction: &GenesisTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + + // Genesis still gets a normal txid mapping so block explorers and + // RPC lookup paths can resolve it like any later transaction. + let txhash = transaction.unsigned.hash().await; + let txkey = decode(txhash).map_err(|e| format!("Error decoding genesis txid key: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + binary_data.extend(transaction_bytes); + Ok(binary_data) +} diff --git a/src/records/record_chain/header_number.rs b/src/records/record_chain/header_number.rs new file mode 100644 index 0000000..f315092 --- /dev/null +++ b/src/records/record_chain/header_number.rs @@ -0,0 +1,15 @@ +use crate::common::check_genesis::genesis_checkup; +use crate::records::block_height::get_block_height::get_height; +use crate::records::block_height::increase_block_height::increase_height; +use crate::sled::Db; + +// Return the next header number context for save paths, treating genesis +// as block 0 and advancing the stored height once a chain already exists. +pub async fn get_header_number(db: &Db) -> u32 { + if genesis_checkup().await { + let _ = increase_height(db); + get_height(db) + } else { + 0 + } +} diff --git a/src/records/record_chain/issue_token_tx.rs b/src/records/record_chain/issue_token_tx.rs new file mode 100644 index 0000000..87703c8 --- /dev/null +++ b/src/records/record_chain/issue_token_tx.rs @@ -0,0 +1,72 @@ +use crate::blocks::issue_token::IssueTokenTransaction; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_issue_token( + transaction: &IssueTokenTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the issue-token transaction and compute its txid before + // applying the balance-sheet and token-supply updates. + let txhash = transaction.unsigned_issue_token.hash().await; + let txhash_bytes = + decode(&txhash).map_err(|e| format!("Error decoding issue-token txhash: {e}"))?; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(error) => return Err(error.to_string()), + }; + binary_data.extend(transaction_bytes); + + // Issuance moves the fee to the miner, debits the creator's base + // coin, credits the newly issued token balance, and updates supply. + pending_effects.add_balance( + &miner, + transaction.unsigned_issue_token.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_issue_token.creator, + transaction.unsigned_issue_token.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_issue_token.creator, + transaction.unsigned_issue_token.number, + &transaction.unsigned_issue_token.ticker, + BalanceOperand::Addition, + ); + + pending_effects.add_token_supply( + &transaction.unsigned_issue_token.ticker, + transaction.unsigned_issue_token.number, + ); + pending_effects.append_tree( + "token_history", + transaction.unsigned_issue_token.ticker.as_bytes().to_vec(), + txhash_bytes.clone(), + ); + + // Record the txid location so RPC lookups can resolve the saved + // transaction back to its block and index position. + let txkey = txhash_bytes; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + + Ok(binary_data) +} diff --git a/src/records/record_chain/lender_tx.rs b/src/records/record_chain/lender_tx.rs new file mode 100644 index 0000000..083a9ae --- /dev/null +++ b/src/records/record_chain/lender_tx.rs @@ -0,0 +1,93 @@ +use crate::blocks::loans::LoanContractTransaction; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_lender( + transaction: &LoanContractTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the loan contract and compute its txid before applying + // the loan disbursement and collateral-holding balance changes. + let txhash = transaction.unsigned_loan_contract.hash().await; + let txhash_bytes = decode(&txhash).map_err(|e| format!("Error decoding hex: {e}"))?; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + binary_data.extend(transaction_bytes); + + // Loan creation credits the miner fee, moves the loan asset from + // lender to borrower, and escrows the collateral under a derived key. + pending_effects.add_balance( + &miner, + transaction.unsigned_loan_contract.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_loan_contract.lender, + transaction.unsigned_loan_contract.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_loan_contract.borrower, + transaction.unsigned_loan_contract.loan_amount, + &transaction.unsigned_loan_contract.loan_coin, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_loan_contract.lender, + transaction.unsigned_loan_contract.loan_amount, + &transaction.unsigned_loan_contract.loan_coin, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_loan_contract.borrower, + transaction.unsigned_loan_contract.collateral_amount, + &transaction.unsigned_loan_contract.collateral, + BalanceOperand::Subtraction, + ); + let collateral_holding = format!("collateral_{txhash}"); + pending_effects.add_balance( + &collateral_holding, + transaction.unsigned_loan_contract.collateral_amount, + &transaction.unsigned_loan_contract.collateral, + BalanceOperand::Addition, + ); + + // Record the txid location, register the contract in the loan tree, + // and append NFT provenance when the loan or collateral asset is an NFT. + let txkey = decode(&txhash).map_err(|e| format!("Error decoding hex: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + let loankey = decode(&txhash).map_err(|e| format!("Error decoding hex: {e}"))?; + pending_effects.set_tree("loan", loankey, b"true".to_vec()); + pending_effects.append_tree_if_key_exists( + "nfts", + "nft_history", + transaction.unsigned_loan_contract.collateral.as_bytes().to_vec(), + txhash_bytes.clone(), + ); + pending_effects.append_tree_if_key_exists( + "nfts", + "nft_history", + transaction.unsigned_loan_contract.loan_coin.as_bytes().to_vec(), + txhash_bytes, + ); + Ok(binary_data) +} diff --git a/src/records/record_chain/marketing_tx.rs b/src/records/record_chain/marketing_tx.rs new file mode 100644 index 0000000..a743502 --- /dev/null +++ b/src/records/record_chain/marketing_tx.rs @@ -0,0 +1,53 @@ +use crate::blocks::marketing::MarketingTransaction; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_marketing( + transaction: &MarketingTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the marketing transaction and compute its txid before + // applying the fee transfer side effects. + let txhash = transaction.unsigned_marketing.hash().await; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + binary_data.extend(transaction_bytes); + + // Marketing transactions only move fees: they reward the miner and + // debit the advertiser's base-coin balance. + pending_effects.add_balance( + &miner, + transaction.unsigned_marketing.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_marketing.advertiser, + transaction.unsigned_marketing.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + + // Record the txid location so RPC lookups can resolve the saved + // transaction back to its block and index position. + let txkey = decode(txhash).map_err(|e| format!("Error decoding marketing txid key: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + Ok(binary_data) +} diff --git a/src/records/record_chain/mod.rs b/src/records/record_chain/mod.rs new file mode 100644 index 0000000..553bd2e --- /dev/null +++ b/src/records/record_chain/mod.rs @@ -0,0 +1,24 @@ +// The record_chain module contains the block-save processors for each transaction family. +pub mod add_payments_db; +pub mod borrower_tx; +pub mod burn_tx; +pub mod collateral_tx; +pub mod genesis_tx; +pub mod header_number; +pub mod issue_token_tx; +pub mod lender_tx; +pub mod marketing_tx; +pub mod nft_provenance; +pub mod nft_tx; +pub mod parse_transactions; +pub mod pending_effects; +pub mod previous_difficulty; +pub mod rewards_tx; +pub mod save; +pub mod save_flags; +pub mod structs; +pub mod swap_tx; +pub mod token_provenance; +pub mod token_tx; +pub mod transfer_tx; +pub mod vanity_tx; diff --git a/src/records/record_chain/nft_provenance.rs b/src/records/record_chain/nft_provenance.rs new file mode 100644 index 0000000..b37b7b0 --- /dev/null +++ b/src/records/record_chain/nft_provenance.rs @@ -0,0 +1,65 @@ +use crate::sled::Db; + +pub fn set_nft_origin(db: &Db, asset_name: &str, tx_hash_bytes: &[u8]) -> sled::Result<()> { + // Each NFT asset records the txid that originally minted it. + let tree = db.open_tree("nft_origins")?; + let _ = tree.insert(asset_name.as_bytes(), tx_hash_bytes)?; + Ok(()) +} + +pub fn append_nft_history(db: &Db, asset_name: &str, tx_hash_bytes: &[u8]) -> sled::Result<()> { + // Ownership and contract events append txids to the NFT history tree + // as a flat sequence of 32-byte hashes. + let tree = db.open_tree("nft_history")?; + let mut history = tree + .get(asset_name.as_bytes())? + .map(|bytes| bytes.to_vec()) + .unwrap_or_default(); + history.extend_from_slice(tx_hash_bytes); + let _ = tree.insert(asset_name.as_bytes(), history)?; + Ok(()) +} + +pub fn remove_nft_origin(db: &Db, asset_name: &str) -> sled::Result<()> { + // Orphan rollback can remove the origin record for NFTs whose minting + // transaction is no longer part of the active chain. + let tree = db.open_tree("nft_origins")?; + let _ = tree.remove(asset_name.as_bytes())?; + Ok(()) +} + +pub fn remove_nft_history_entry( + db: &Db, + asset_name: &str, + tx_hash_bytes: &[u8], +) -> sled::Result<()> { + // Remove the most recent matching history entry when rolling back a + // transaction that previously touched the NFT. + let tree = db.open_tree("nft_history")?; + let Some(bytes) = tree.get(asset_name.as_bytes())? else { + return Ok(()); + }; + + let mut history = bytes.to_vec(); + let chunk_len = 32; + if history.len() < chunk_len { + let _ = tree.remove(asset_name.as_bytes())?; + return Ok(()); + } + + if let Some(index) = history + .chunks(chunk_len) + .rposition(|chunk| chunk == tx_hash_bytes) + { + let start = index * chunk_len; + history.drain(start..start + chunk_len); + } + + if history.is_empty() { + let _ = tree.remove(asset_name.as_bytes())?; + } else { + let _ = tree.insert(asset_name.as_bytes(), history)?; + } + + Ok(()) +} diff --git a/src/records/record_chain/nft_tx.rs b/src/records/record_chain/nft_tx.rs new file mode 100644 index 0000000..994e0e0 --- /dev/null +++ b/src/records/record_chain/nft_tx.rs @@ -0,0 +1,86 @@ +use crate::blocks::nft::CreateNftTransaction; +use crate::common::nft_assets::nft_asset_name; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +const NFT_UNIT: u64 = 100_000_000; + +pub async fn process_nft( + transaction: &CreateNftTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the NFT creation transaction and compute its txid before + // applying the fee transfer and NFT registry side effects. + let txhash = transaction.unsigned_create_nft.hash().await; + let txhash_bytes = decode(&txhash).map_err(|e| format!("Error decoding nft txhash: {e}"))?; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + binary_data.extend(transaction_bytes); + + // NFT creation credits the miner fee and debits the creator's base coin. + pending_effects.add_balance( + &miner, + transaction.unsigned_create_nft.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_create_nft.creator, + transaction.unsigned_create_nft.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + + // Series-1 mints expand into numbered NFT assets, while later-series + // mints use the provided asset name directly. + if transaction.unsigned_create_nft.series == 1 { + for item_number in 1..=transaction.unsigned_create_nft.count { + let nft_save_name = + nft_asset_name(&transaction.unsigned_create_nft.nft_name, item_number); + pending_effects.add_balance( + &transaction.unsigned_create_nft.creator, + NFT_UNIT, + &nft_save_name, + BalanceOperand::Addition, + ); + pending_effects.set_tree("nfts", nft_save_name.as_bytes().to_vec(), b"1".to_vec()); + pending_effects.set_tree("nft_origins", nft_save_name.as_bytes().to_vec(), txhash_bytes.clone()); + pending_effects.append_tree("nft_history", nft_save_name.into_bytes(), txhash_bytes.clone()); + } + } else { + let nft_save_name = transaction.unsigned_create_nft.nft_name.clone(); + pending_effects.add_balance( + &transaction.unsigned_create_nft.creator, + NFT_UNIT, + &nft_save_name, + BalanceOperand::Addition, + ); + pending_effects.set_tree("nfts", nft_save_name.as_bytes().to_vec(), b"1".to_vec()); + pending_effects.set_tree("nft_origins", nft_save_name.as_bytes().to_vec(), txhash_bytes.clone()); + pending_effects.append_tree("nft_history", nft_save_name.into_bytes(), txhash_bytes.clone()); + } + + // Record the txid location so RPC lookups can resolve the saved + // transaction back to its block and index position. + let txkey = decode(txhash).map_err(|e| format!("Error decoding nft txid key: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + + Ok(binary_data) +} diff --git a/src/records/record_chain/parse_transactions.rs b/src/records/record_chain/parse_transactions.rs new file mode 100644 index 0000000..2b26572 --- /dev/null +++ b/src/records/record_chain/parse_transactions.rs @@ -0,0 +1,204 @@ +use crate::blocks::block::Block; +use crate::common::types::Transaction; +use crate::records::record_chain::borrower_tx::process_borrower; +use crate::records::record_chain::burn_tx::process_burn; +use crate::records::record_chain::collateral_tx::process_collateral; +use crate::records::record_chain::genesis_tx::process_genesis; +use crate::records::record_chain::issue_token_tx::process_issue_token; +use crate::records::record_chain::lender_tx::process_lender; +use crate::records::record_chain::marketing_tx::process_marketing; +use crate::records::record_chain::nft_tx::process_nft; +use crate::records::record_chain::pending_effects::PendingEffects; +use crate::records::record_chain::rewards_tx::process_rewards; +use crate::records::record_chain::swap_tx::process_swap; +use crate::records::record_chain::token_tx::process_token; +use crate::records::record_chain::transfer_tx::process_transfer; +use crate::records::record_chain::vanity_tx::process_vanity; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +// Walk the block's transactions in order and append each transaction's +// record data onto the saved block binary representation. +pub async fn handle_transactions( + block: &Block, + mut binary_data: Vec, + db: &Db, + index_mutex: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + for transaction in &block.transactions { + // Each transaction type contributes its own serialized record + // data and may also update the backing record stores. + let result = match transaction { + Transaction::Genesis(genesis_transaction) => { + process_genesis( + genesis_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Rewards(rewards_transaction) => { + process_rewards( + rewards_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Transfer(transfer_transaction) => { + process_transfer( + transfer_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Token(token_transaction) => { + process_token( + token_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::IssueToken(issue_token_transaction) => { + process_issue_token( + issue_token_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Burn(burn_transaction) => { + process_burn( + burn_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Nft(nft_transaction) => { + process_nft( + nft_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Marketing(marketing_transaction) => { + process_marketing( + marketing_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Swap(swap_transaction) => { + process_swap( + swap_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Lender(lender_transaction) => { + process_lender( + lender_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Borrower(borrower_transaction) => { + process_borrower( + borrower_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Collateral(collateral_transaction) => { + process_collateral( + collateral_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::Vanity(vanity_transaction) => { + process_vanity( + vanity_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + }; + match result { + Ok(new_binary_data) => { + // Carry the growing block bytes into the next transaction + // processor. + binary_data = new_binary_data; + } + Err(error) => { + return Err(error); + } + } + } + Ok(binary_data) +} diff --git a/src/records/record_chain/pending_effects.rs b/src/records/record_chain/pending_effects.rs new file mode 100644 index 0000000..ecb677a --- /dev/null +++ b/src/records/record_chain/pending_effects.rs @@ -0,0 +1,656 @@ +use crate::log::error; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::wallet_registry::{ + get_registered_vanity_for_owner, register_or_update_vanity_address, + remove_registered_vanity_for_owner, store_previous_vanity_for_txid, VanityRegistrationResult, + WALLET_VANITY_ROLLBACK_TREE, +}; +use crate::sled::Db; + +#[derive(Clone, Copy)] +pub enum BalanceOperand { + Addition, + Subtraction, +} + +impl BalanceOperand { + fn as_str(self) -> &'static str { + match self { + Self::Addition => "addition", + Self::Subtraction => "subtraction", + } + } + + fn inverse(self) -> Self { + match self { + Self::Addition => Self::Subtraction, + Self::Subtraction => Self::Addition, + } + } +} + +pub enum PendingEffect { + Balance { + address: String, + amount: u64, + coin: String, + operand: BalanceOperand, + }, + TreeSet { + tree: &'static str, + key: Vec, + value: Vec, + }, + TreeRemove { + tree: &'static str, + key: Vec, + }, + TreeAppend { + tree: &'static str, + key: Vec, + bytes: Vec, + }, + TreeAppendIfKeyExists { + exists_tree: &'static str, + append_tree: &'static str, + key: Vec, + bytes: Vec, + }, + TokenSupplyAdd { + ticker: String, + amount: u64, + }, + TokenSupplySubtract { + ticker: String, + amount: u64, + }, + BurnAssetState { + burned_asset: String, + token_ticker: String, + txhash_bytes: Vec, + amount: u64, + }, + ContractPaymentAppend { + contract_id: Vec, + payment: u64, + }, + VanityUpdate { + owner_address: String, + vanity_address: String, + txhash: String, + }, +} + +#[derive(Default)] +pub struct PendingEffects { + effects: Vec, +} + +impl PendingEffects { + pub fn push(&mut self, effect: PendingEffect) { + self.effects.push(effect); + } + + pub fn add_balance(&mut self, address: &str, amount: u64, coin: &str, operand: BalanceOperand) { + if amount == 0 { + return; + } + + self.push(PendingEffect::Balance { + address: address.to_string(), + amount, + coin: coin.to_string(), + operand, + }); + } + + pub fn set_tree(&mut self, tree: &'static str, key: Vec, value: Vec) { + self.push(PendingEffect::TreeSet { tree, key, value }); + } + + pub fn remove_tree(&mut self, tree: &'static str, key: Vec) { + self.push(PendingEffect::TreeRemove { tree, key }); + } + + pub fn append_tree(&mut self, tree: &'static str, key: Vec, bytes: Vec) { + self.push(PendingEffect::TreeAppend { tree, key, bytes }); + } + + pub fn append_tree_if_key_exists( + &mut self, + exists_tree: &'static str, + append_tree: &'static str, + key: Vec, + bytes: Vec, + ) { + self.push(PendingEffect::TreeAppendIfKeyExists { + exists_tree, + append_tree, + key, + bytes, + }); + } + + pub fn add_token_supply(&mut self, ticker: &str, amount: u64) { + self.push(PendingEffect::TokenSupplyAdd { + ticker: ticker.to_string(), + amount, + }); + } + + pub fn subtract_token_supply(&mut self, ticker: &str, amount: u64) { + self.push(PendingEffect::TokenSupplySubtract { + ticker: ticker.to_string(), + amount, + }); + } + + pub fn burn_asset_state( + &mut self, + burned_asset: &str, + token_ticker: &str, + txhash_bytes: Vec, + amount: u64, + ) { + self.push(PendingEffect::BurnAssetState { + burned_asset: burned_asset.to_string(), + token_ticker: token_ticker.to_string(), + txhash_bytes, + amount, + }); + } + + pub fn append_contract_payment(&mut self, contract_id: Vec, payment: u64) { + self.push(PendingEffect::ContractPaymentAppend { + contract_id, + payment, + }); + } + + pub fn update_vanity(&mut self, owner_address: &str, vanity_address: &str, txhash: &str) { + self.push(PendingEffect::VanityUpdate { + owner_address: owner_address.to_string(), + vanity_address: vanity_address.to_string(), + txhash: txhash.to_string(), + }); + } + + pub fn apply(&self, db: &Db) -> Result { + let mut applied = AppliedEffects::default(); + + for effect in &self.effects { + match apply_effect(db, effect) { + Ok(applied_effect) => applied.effects.push(applied_effect), + Err(err) => { + if let Err(rollback_err) = applied.rollback(db) { + error!("Failed to roll back partial block effects: {rollback_err}"); + } + return Err(err); + } + } + } + + Ok(applied) + } +} + +#[derive(Default)] +pub struct AppliedEffects { + effects: Vec, +} + +impl AppliedEffects { + pub fn rollback(&mut self, db: &Db) -> Result<(), String> { + let mut first_error = None; + + while let Some(effect) = self.effects.pop() { + if let Err(err) = rollback_effect(db, effect) { + if first_error.is_none() { + first_error = Some(err); + } + } + } + + match first_error { + Some(err) => Err(err), + None => Ok(()), + } + } +} + +enum AppliedEffect { + Balance { + address: String, + amount: u64, + coin: String, + operand: BalanceOperand, + }, + TreeMutation { + tree: &'static str, + key: Vec, + previous: Option>, + }, + Composite(Vec), + VanityUpdate { + owner_address: String, + previous_vanity: Option, + rollback_key: Vec, + previous_rollback: Option>, + }, + Noop, +} + +fn apply_effect(db: &Db, effect: &PendingEffect) -> Result { + match effect { + PendingEffect::Balance { + address, + amount, + coin, + operand, + } => { + balance_sheet_operation_with_db(db, address, *amount, coin, operand.as_str()) + .map_err(|err| format!("Failed to apply balance effect: {err}"))?; + Ok(AppliedEffect::Balance { + address: address.clone(), + amount: *amount, + coin: coin.clone(), + operand: *operand, + }) + } + PendingEffect::TreeSet { tree, key, value } => { + let previous = set_tree_value(db, tree, key, value)?; + Ok(AppliedEffect::TreeMutation { + tree, + key: key.clone(), + previous, + }) + } + PendingEffect::TreeRemove { tree, key } => { + let tree_handle = db + .open_tree(tree) + .map_err(|err| format!("Failed to open {tree} tree: {err}"))?; + let previous = tree_handle + .remove(key) + .map_err(|err| format!("Failed to remove {tree} value: {err}"))? + .map(|value| value.to_vec()); + Ok(AppliedEffect::TreeMutation { + tree, + key: key.clone(), + previous, + }) + } + PendingEffect::TreeAppend { tree, key, bytes } => { + let previous = append_tree_value(db, tree, key, bytes)?; + Ok(AppliedEffect::TreeMutation { + tree, + key: key.clone(), + previous, + }) + } + PendingEffect::TreeAppendIfKeyExists { + exists_tree, + append_tree, + key, + bytes, + } => { + let exists = db + .open_tree(exists_tree) + .map_err(|err| format!("Failed to open {exists_tree} tree: {err}"))? + .contains_key(key) + .map_err(|err| format!("Failed to check {exists_tree} tree: {err}"))?; + if !exists { + return Ok(AppliedEffect::Noop); + } + + let previous = append_tree_value(db, append_tree, key, bytes)?; + Ok(AppliedEffect::TreeMutation { + tree: append_tree, + key: key.clone(), + previous, + }) + } + PendingEffect::TokenSupplyAdd { ticker, amount } => { + let tree = db + .open_tree("tokens") + .map_err(|err| format!("Failed to open tokens tree: {err}"))?; + let key = ticker.as_bytes(); + let previous = tree + .get(key) + .map_err(|err| format!("Failed to read token supply: {err}"))? + .map(|value| value.to_vec()); + let Some(previous_bytes) = previous.clone() else { + return Err(format!("Token supply does not exist for {ticker}")); + }; + let current_supply = read_supply(&previous_bytes)?; + let updated_supply = current_supply + .checked_add(*amount) + .ok_or_else(|| format!("Token supply overflow for {ticker}"))?; + tree.insert(key, &updated_supply.to_le_bytes()) + .map_err(|err| format!("Failed to update token supply: {err}"))?; + Ok(AppliedEffect::TreeMutation { + tree: "tokens", + key: key.to_vec(), + previous, + }) + } + PendingEffect::TokenSupplySubtract { ticker, amount } => { + let tree = db + .open_tree("tokens") + .map_err(|err| format!("Failed to open tokens tree: {err}"))?; + let key = ticker.as_bytes(); + let previous = tree + .get(key) + .map_err(|err| format!("Failed to read token supply: {err}"))? + .map(|value| value.to_vec()); + let Some(previous_bytes) = previous.clone() else { + return Err(format!("Token supply does not exist for {ticker}")); + }; + let current_supply = read_supply(&previous_bytes)?; + if *amount > current_supply { + return Err(format!("Token burn exceeds supply for {ticker}")); + } + let remaining_supply = current_supply - *amount; + if remaining_supply == 0 { + tree.remove(key) + .map_err(|err| format!("Failed to remove token supply: {err}"))?; + } else { + tree.insert(key, &remaining_supply.to_le_bytes()) + .map_err(|err| format!("Failed to update token supply: {err}"))?; + } + Ok(AppliedEffect::TreeMutation { + tree: "tokens", + key: key.to_vec(), + previous, + }) + } + PendingEffect::BurnAssetState { + burned_asset, + token_ticker, + txhash_bytes, + amount, + } => apply_burn_asset_state(db, burned_asset, token_ticker, txhash_bytes, *amount), + PendingEffect::ContractPaymentAppend { + contract_id, + payment, + } => { + let previous = append_tree_value( + db, + "contract_payments", + contract_id, + &payment.to_le_bytes(), + )?; + Ok(AppliedEffect::TreeMutation { + tree: "contract_payments", + key: contract_id.clone(), + previous, + }) + } + PendingEffect::VanityUpdate { + owner_address, + vanity_address, + txhash, + } => apply_vanity_effect(db, owner_address, vanity_address, txhash), + } +} + +fn rollback_effect(db: &Db, effect: AppliedEffect) -> Result<(), String> { + match effect { + AppliedEffect::Balance { + address, + amount, + coin, + operand, + } => balance_sheet_operation_with_db(db, &address, amount, &coin, operand.inverse().as_str()) + .map_err(|err| format!("Failed to roll back balance effect: {err}")), + AppliedEffect::TreeMutation { + tree, + key, + previous, + } => restore_tree_value(db, tree, &key, previous), + AppliedEffect::Composite(mut effects) => { + let mut first_error = None; + while let Some(effect) = effects.pop() { + if let Err(err) = rollback_effect(db, effect) { + if first_error.is_none() { + first_error = Some(err); + } + } + } + match first_error { + Some(err) => Err(err), + None => Ok(()), + } + } + AppliedEffect::VanityUpdate { + owner_address, + previous_vanity, + rollback_key, + previous_rollback, + } => { + restore_vanity_mapping(db, &owner_address, previous_vanity)?; + restore_tree_value(db, WALLET_VANITY_ROLLBACK_TREE, &rollback_key, previous_rollback) + } + AppliedEffect::Noop => Ok(()), + } +} + +fn set_tree_value( + db: &Db, + tree: &'static str, + key: &[u8], + value: &[u8], +) -> Result>, String> { + let tree_handle = db + .open_tree(tree) + .map_err(|err| format!("Failed to open {tree} tree: {err}"))?; + let previous = tree_handle + .get(key) + .map_err(|err| format!("Failed to read previous {tree} value: {err}"))? + .map(|value| value.to_vec()); + tree_handle + .insert(key, value) + .map_err(|err| format!("Failed to write {tree} value: {err}"))?; + Ok(previous) +} + +fn append_tree_value( + db: &Db, + tree: &'static str, + key: &[u8], + bytes: &[u8], +) -> Result>, String> { + let tree_handle = db + .open_tree(tree) + .map_err(|err| format!("Failed to open {tree} tree: {err}"))?; + let previous = tree_handle + .get(key) + .map_err(|err| format!("Failed to read previous {tree} value: {err}"))? + .map(|value| value.to_vec()); + let mut updated = previous.clone().unwrap_or_default(); + updated.extend_from_slice(bytes); + tree_handle + .insert(key, updated) + .map_err(|err| format!("Failed to append {tree} value: {err}"))?; + Ok(previous) +} + +fn restore_tree_value( + db: &Db, + tree: &'static str, + key: &[u8], + previous: Option>, +) -> Result<(), String> { + let tree_handle = db + .open_tree(tree) + .map_err(|err| format!("Failed to open {tree} tree during rollback: {err}"))?; + match previous { + Some(value) => { + tree_handle + .insert(key, value) + .map_err(|err| format!("Failed to restore {tree} value: {err}"))?; + } + None => { + tree_handle + .remove(key) + .map_err(|err| format!("Failed to remove {tree} value during rollback: {err}"))?; + } + } + Ok(()) +} + +fn read_supply(bytes: &[u8]) -> Result { + if bytes.len() != 8 { + return Err("Invalid token supply bytes".to_string()); + } + let mut supply_bytes = [0u8; 8]; + supply_bytes.copy_from_slice(bytes); + Ok(u64::from_le_bytes(supply_bytes)) +} + +fn apply_burn_asset_state( + db: &Db, + burned_asset: &str, + token_ticker: &str, + txhash_bytes: &[u8], + amount: u64, +) -> Result { + let nft_tree = db + .open_tree("nfts") + .map_err(|err| format!("Failed to open nfts tree: {err}"))?; + let mut effects = Vec::new(); + + if nft_tree + .contains_key(burned_asset.as_bytes()) + .map_err(|err| format!("Failed to check nfts tree: {err}"))? + { + let history_previous = + append_tree_value(db, "nft_history", burned_asset.as_bytes(), txhash_bytes)?; + effects.push(AppliedEffect::TreeMutation { + tree: "nft_history", + key: burned_asset.as_bytes().to_vec(), + previous: history_previous, + }); + + let nft_previous = match nft_tree.remove(burned_asset.as_bytes()) { + Ok(previous) => previous.map(|value| value.to_vec()), + Err(err) => { + let _ = rollback_effect(db, AppliedEffect::Composite(effects)); + return Err(format!("Failed to remove burned NFT: {err}")); + } + }; + effects.push(AppliedEffect::TreeMutation { + tree: "nfts", + key: burned_asset.as_bytes().to_vec(), + previous: nft_previous, + }); + + return Ok(AppliedEffect::Composite(effects)); + } + + let history_previous = + append_tree_value(db, "token_history", token_ticker.as_bytes(), txhash_bytes)?; + effects.push(AppliedEffect::TreeMutation { + tree: "token_history", + key: token_ticker.as_bytes().to_vec(), + previous: history_previous, + }); + + let supply_effect = match apply_effect( + db, + &PendingEffect::TokenSupplySubtract { + ticker: token_ticker.to_string(), + amount, + }, + ) { + Ok(effect) => effect, + Err(err) => { + let _ = rollback_effect(db, AppliedEffect::Composite(effects)); + return Err(err); + } + }; + effects.push(supply_effect); + + Ok(AppliedEffect::Composite(effects)) +} + +fn apply_vanity_effect( + db: &Db, + owner_address: &str, + vanity_address: &str, + txhash: &str, +) -> Result { + let previous_vanity = get_registered_vanity_for_owner(db, owner_address) + .map_err(|err| format!("Could not read existing vanity mapping: {err}"))?; + let rollback_key = + crate::decode(txhash).map_err(|_| "Could not decode vanity transaction hash".to_string())?; + let rollback_tree = db + .open_tree(WALLET_VANITY_ROLLBACK_TREE) + .map_err(|err| format!("Could not open vanity rollback tree: {err}"))?; + let previous_rollback = rollback_tree + .get(&rollback_key) + .map_err(|err| format!("Could not read vanity rollback state: {err}"))? + .map(|value| value.to_vec()); + + match register_or_update_vanity_address(db, owner_address, vanity_address) + .map_err(|err| format!("Could not register vanity address: {err}"))? + { + VanityRegistrationResult::Inserted + | VanityRegistrationResult::Updated + | VanityRegistrationResult::AlreadyRegistered => {} + VanityRegistrationResult::Conflict => { + return Err("Vanity Address is already owned by another wallet.".to_string()); + } + VanityRegistrationResult::OwnerNotRegistered => { + return Err("Wallet Address is not registered.".to_string()); + } + VanityRegistrationResult::InvalidOwner => { + return Err("Wallet Address is invalid.".to_string()); + } + VanityRegistrationResult::InvalidVanity => { + return Err("Vanity Address is invalid.".to_string()); + } + } + + if let Err(err) = store_previous_vanity_for_txid(db, txhash, previous_vanity.as_deref()) { + let _ = restore_vanity_mapping(db, owner_address, previous_vanity.clone()); + return Err(format!("Could not store vanity rollback state: {err}")); + } + + Ok(AppliedEffect::VanityUpdate { + owner_address: owner_address.to_string(), + previous_vanity, + rollback_key, + previous_rollback, + }) +} + +fn restore_vanity_mapping( + db: &Db, + owner_address: &str, + previous_vanity: Option, +) -> Result<(), String> { + match previous_vanity { + Some(vanity) => match register_or_update_vanity_address(db, owner_address, &vanity) + .map_err(|err| format!("Failed to restore vanity mapping: {err}"))? + { + VanityRegistrationResult::Inserted + | VanityRegistrationResult::Updated + | VanityRegistrationResult::AlreadyRegistered => Ok(()), + VanityRegistrationResult::Conflict => { + Err("Failed to restore vanity mapping: conflict".to_string()) + } + VanityRegistrationResult::OwnerNotRegistered => { + Err("Failed to restore vanity mapping: owner missing".to_string()) + } + VanityRegistrationResult::InvalidOwner => { + Err("Failed to restore vanity mapping: invalid owner".to_string()) + } + VanityRegistrationResult::InvalidVanity => { + Err("Failed to restore vanity mapping: invalid vanity".to_string()) + } + }, + None => remove_registered_vanity_for_owner(db, owner_address) + .map(|_| ()) + .map_err(|err| format!("Failed to remove vanity mapping: {err}")), + } +} diff --git a/src/records/record_chain/previous_difficulty.rs b/src/records/record_chain/previous_difficulty.rs new file mode 100644 index 0000000..3849e4e --- /dev/null +++ b/src/records/record_chain/previous_difficulty.rs @@ -0,0 +1,9 @@ +use crate::records::unpack_block::unpack_header::load_block_header; + +// Torrent metadata stores the difficulty the current block was mined +// under, which comes from the previous header's next-block difficulty. +pub async fn previous_block_difficulty(block_header_number: u32) -> Result { + let previous_difficulty_height = block_header_number - 1; + let previous_block = load_block_header(previous_difficulty_height).await?; + Ok(previous_block.unmined_block.next_block_difficulty) +} diff --git a/src/records/record_chain/rewards_tx.rs b/src/records/record_chain/rewards_tx.rs new file mode 100644 index 0000000..794e272 --- /dev/null +++ b/src/records/record_chain/rewards_tx.rs @@ -0,0 +1,99 @@ +use crate::blocks::rewards::RewardsTransaction; +use crate::common::types::Transaction; +use crate::decode; +use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::PendingEffects; +use crate::records::unpack_block::load_by_block_number::load_block; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +const FINALIZED_REWARD_TREE: &str = "finalized_rewards"; + +fn reward_key(block_height: u32) -> [u8; 4] { + block_height.to_le_bytes() +} + +pub fn reward_credit_applied(db: &Db, block_height: u32) -> bool { + db.open_tree(FINALIZED_REWARD_TREE) + .ok() + .and_then(|tree| tree.contains_key(reward_key(block_height)).ok()) + .unwrap_or(false) +} + +pub fn remove_reward_credit_marker(db: &Db, block_height: u32) { + if let Ok(tree) = db.open_tree(FINALIZED_REWARD_TREE) { + let _ = tree.remove(reward_key(block_height)); + } +} + +pub async fn finalize_rewards_through_height(db: &Db, cutoff_height: u32) -> Result<(), String> { + let tree = db + .open_tree(FINALIZED_REWARD_TREE) + .map_err(|e| format!("Failed to open finalized reward tree: {e}"))?; + + for block_height in 0..=cutoff_height { + if tree + .contains_key(reward_key(block_height)) + .map_err(|e| format!("Failed to read finalized reward marker: {e}"))? + { + continue; + } + + let block = match load_block(block_height).await { + Ok(block) => block, + Err(_) => continue, + }; + let miner = block.vrf_block.unmined_block.miner; + + for transaction in block.transactions { + if let Transaction::Rewards(rewards) = transaction { + balance_sheet_operation_with_db( + db, + &miner, + rewards.unsigned.value, + &BASECOIN, + "addition", + ) + .map_err(|e| format!("Failed to finalize reward balance: {e}"))?; + tree.insert(reward_key(block_height), b"true") + .map_err(|e| format!("Failed to store finalized reward marker: {e}"))?; + break; + } + } + } + + Ok(()) +} + +pub async fn process_rewards( + transaction: &RewardsTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + _miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the reward transaction and compute its txid. The miner + // balance is credited later when the block leaves the orphan window. + let txhash = transaction.unsigned.hash().await; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + binary_data.extend(transaction_bytes); + + // Reward processing records the txid location for later lookup by + // block/index, but does not make the reward spendable yet. + let txkey = decode(txhash).map_err(|e| format!("Error decoding rewards txid key: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + Ok(binary_data) +} diff --git a/src/records/record_chain/save.rs b/src/records/record_chain/save.rs new file mode 100644 index 0000000..b94c9dd --- /dev/null +++ b/src/records/record_chain/save.rs @@ -0,0 +1,587 @@ +use crate::common::check_genesis::genesis_checkup; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::miner::flag::{is_mining_running, is_normal_mode, is_reorganizing_mode, is_syncing_mode}; +use crate::orphans::snapshot_check::{snapshot_height, update_snapshot}; +use crate::records::block_height::get_block_height::get_height; +use crate::records::block_height::increase_block_height::increase_height; +use crate::records::memory::averages::{calculate_averages, update_block_data}; +use crate::records::memory::mempool::{ + apply_selected_transaction_math, mark_processed_by_signatures, + mark_selected_transactions_processed, restore_processed_by_signatures, + restore_selected_transactions_processed, select_transactions_for_block, spawn_processed_cleanup, + stream_selected_transaction_originals, +}; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::memory::torrent_status::prune_torrent_statuses_through_height; +use crate::records::record_chain::parse_transactions::handle_transactions; +use crate::records::record_chain::pending_effects::PendingEffects; +use crate::records::record_chain::previous_difficulty::previous_block_difficulty; +use crate::records::record_chain::rewards_tx::finalize_rewards_through_height; +use crate::records::record_chain::save_flags::SAVE_FLAG; +use crate::records::record_chain::structs::{ + SaveBinaryDataParams, SaveBinaryDataWithMempoolStreamParams, SaveBlockParams, SaveType, +}; +use crate::records::unpack_block::unpack_header::load_block_header; +use crate::torrent::create_metadata::{broadcast_new_torrent_to_peers, metadata_from_file}; +use crate::torrent::torrenting_system::save_torrent::prune_staged_torrents; +use crate::torrent::torrenting_system::torrent_cache::prune_recent_torrents; +use crate::log::{error, info}; +use crate::Arc; +use crate::decode; +use crate::fs; +use crate::Mutex; +use crate::PathBuf; +use crate::Utc; +use crate::{sled::Db, TimeZone}; + +pub async fn save_block(params: SaveBlockParams) -> Result<(), String> { + // Serialize all block saves through a single async lock so mining, + // sync, and orphan-driven writes cannot interleave. + let _lock = SAVE_FLAG.lock().await; + let SaveBlockParams { + block, + db, + header_hash, + timestamp, + signatures, + save_type, + allow_during_reorg, + map, + } = params; + + let genesis_missing = !genesis_checkup().await; + if save_type.is_updating() { + if is_reorganizing_mode() && !allow_during_reorg { + return Err("Cannot save discovered block while reorganizing.".to_string()); + } + } else { + if is_reorganizing_mode() { + return Err("Cannot save mined block while reorganizing.".to_string()); + } + if is_syncing_mode() { + return Err("Cannot save mined block while syncing.".to_string()); + } + if !is_mining_running() { + return Err("Cannot save mined block when mining is not running.".to_string()); + } + if !genesis_missing && !is_normal_mode() { + return Err("Cannot save mined block outside normal node mode.".to_string()); + } + } + + let header_bytes = block + .vrf_block + .to_bytes() + .await + .map_err(|e| e.to_string())?; + let mut binary_data = header_bytes.clone(); + + let previous_hash = &block.vrf_block.unmined_block.previous_hash; + let miner = &block.vrf_block.unmined_block.miner; + + // Ensure the block being saved really extends the current chain tip + // before any headers, files, or mempool effects are written. + let current_height = get_height(&db); + if current_height > 0 { + let current_block = load_block_header(current_height).await?; + let current_hash = current_block.hash().await; + if current_hash != *previous_hash { + if save_type == SaveType::Mining { + return Err(format!( + "Stale mining candidate: current chain tip changed before save. current_height={current_height}" + )); + } + + error!("Discovered block rejected: previous hash mismatch. current_height={current_height} current_tip_hash={current_hash} candidate_previous_hash={previous_hash}"); + return Err("Incorrect previous_block_hash.".to_string()); + } + } + + let block_header_number = if !genesis_missing { + get_height(&db) + 1 + } else { + 0 + }; + + // Capture the current difficulty context before the block is written + // so the saved-block diagnostic log reflects the live adjustment input. + let mut previous_difficulty = 0_u64; + if block_header_number > 0 { + previous_difficulty = previous_block_difficulty(block_header_number).await?; + } + + log_saved_block_difficulty( + block_header_number, + timestamp, + previous_difficulty, + block.vrf_block.unmined_block.next_block_difficulty, + ) + .await; + + let mut index_counter = 0; + let index_mutex = Arc::new(Mutex::new(&mut index_counter)); + let mut pending_effects = PendingEffects::default(); + + // Append transaction-derived record data to the block binary while + // tracking the index offset where mempool-derived records begin. + binary_data = handle_transactions( + &block, + binary_data, + &db, + index_mutex.clone(), + miner.clone(), + block_header_number, + &mut pending_effects, + ) + .await?; + + let start_index = { + let index = index_mutex.lock().await; + **index + }; + + // Locally mined blocks stream the original selected mempool records + // into the saved block file, while synced/discovered blocks already + // carry their transaction data in the downloaded payload. + if !save_type.is_updating() { + let selected = select_transactions_for_block(10_000_000) + .await + .map_err(|e| e.to_string())?; + + apply_selected_transaction_math( + &selected, + &db, + miner.clone(), + block_header_number, + start_index, + &mut pending_effects, + ) + .await + .map_err(|e| e.to_string())?; + + save_binary_data_with_mempool_stream(SaveBinaryDataWithMempoolStreamParams { + data: &binary_data, + header_bytes: &header_bytes, + selected: &selected, + pending_effects: &pending_effects, + db: &db, + previous_height: current_height, + block_header_number, + difficulty: previous_difficulty, + timestamp, + header_hash: &header_hash, + save_type: save_type.clone(), + miner: miner.clone(), + map, + }) + .await?; + + spawn_processed_cleanup(block_header_number); + } else { + save_binary_data(SaveBinaryDataParams { + data: &binary_data, + header_bytes: &header_bytes, + pending_effects: &pending_effects, + signatures: &signatures, + db: &db, + previous_height: current_height, + block_header_number, + difficulty: previous_difficulty, + timestamp, + header_hash: &header_hash, + save_type, + miner: miner.clone(), + map, + }) + .await?; + + spawn_processed_cleanup(block_header_number); + } + + Ok(()) +} + +async fn log_saved_block_difficulty( + block_number: u32, + timestamp: u32, + current_difficulty: u64, + new_difficulty: u64, +) { + // Skip genesis because there is no prior rolling-average context to + // compare against for a difficulty adjustment log line. + if block_number == 0 { + return; + } + + update_block_data(block_number - 1).await; + let (difficulty_average, average_duration) = calculate_averages(timestamp).await; + + info!( + "[difficulty] saved_block={} timestamp={} average_time_seconds={:.2} average_difficulty={} current_difficulty={} new_difficulty={}", + block_number, + timestamp, + average_duration.as_secs_f64(), + difficulty_average, + current_difficulty, + new_difficulty + ); +} + +async fn save_binary_data_with_mempool_stream( + params: SaveBinaryDataWithMempoolStreamParams<'_>, +) -> Result<(), String> { + let SaveBinaryDataWithMempoolStreamParams { + data, + header_bytes, + selected, + pending_effects, + db, + previous_height, + block_header_number, + difficulty, + timestamp, + header_hash, + save_type, + miner, + map, + } = params; + + // Build the on-disk block context and announce whether the save came + // from local mining or an updating path. + let (file_name, next_number, difficulty) = block_file_context(db, difficulty).await; + let current_time = format_block_time(timestamp); + + if next_number != 0 { + info!("New block mined {next_number} at {current_time}"); + } else if !save_type.is_updating() { + info!("Genesis block mined {next_number} at {current_time}"); + } else { + info!("Genesis block discovered {next_number} at {current_time}"); + } + + let temp_file_name = format!("{file_name}.tmp"); + let _ = fs::remove_file(&temp_file_name); + let mut file = fs::File::create(temp_file_name.clone()).map_err(|e| e.to_string())?; + std::io::Write::write_all(&mut file, data).map_err(|e| e.to_string())?; + + // Preserve the original selected mempool records after the block + // payload so later unpacking can reconstruct the full transaction set. + stream_selected_transaction_originals(&mut file, selected) + .await + .map_err(|e| e.to_string())?; + drop(file); + + fs::rename(&temp_file_name, &file_name).map_err(|e| e.to_string())?; + + let mut applied_effects = match pending_effects.apply(db) { + Ok(applied_effects) => applied_effects, + Err(err) => { + cleanup_block_file(&file_name); + return Err(err); + } + }; + + if let Err(err) = mark_selected_transactions_processed(selected, block_header_number).await { + let _ = restore_selected_transactions_processed(selected).await; + if let Err(rollback_err) = applied_effects.rollback(db) { + error!("Failed to roll back block effects: {rollback_err}"); + } + cleanup_block_file(&file_name); + return Err(err.to_string()); + } + + if let Err(err) = commit_block_indexes(db, block_header_number, header_bytes, header_hash) { + let _ = restore_selected_transactions_processed(selected).await; + if let Err(rollback_err) = applied_effects.rollback(db) { + error!("Failed to roll back block effects: {rollback_err}"); + } + cleanup_block_file(&file_name); + return Err(err); + } + + let torrent_bytes = match metadata_from_file( + &file_name, + next_number, + difficulty, + timestamp, + header_hash, + miner.clone(), + map.clone(), + ) + .await + { + Ok(torrent_bytes) => torrent_bytes, + Err(err) => { + cleanup_block_indexes(db, block_header_number, header_hash); + cleanup_torrent_file(next_number); + let _ = restore_selected_transactions_processed(selected).await; + if let Err(rollback_err) = applied_effects.rollback(db) { + error!("Failed to roll back block effects: {rollback_err}"); + } + cleanup_block_file(&file_name); + return Err(err); + } + }; + + if next_number != 0 { + if let Err(err) = increase_height(db) { + cleanup_block_indexes(db, block_header_number, header_hash); + cleanup_torrent_file(next_number); + let _ = restore_selected_transactions_processed(selected).await; + if let Err(rollback_err) = applied_effects.rollback(db) { + error!("Failed to roll back block effects: {rollback_err}"); + } + cleanup_block_file(&file_name); + return Err(err); + } + let _ = update_snapshot(db, next_number).await; + if let Some(snapshot_height) = snapshot_height(db).await { + if let Err(err) = finalize_rewards_through_height(db, snapshot_height).await { + error!("Failed to finalize rewards through snapshot height {snapshot_height}: {err}"); + } + prune_recent_torrents(snapshot_height).await; + prune_torrent_statuses_through_height(snapshot_height).await; + let _ = prune_staged_torrents(snapshot_height).await; + } + } else { + let _ = update_snapshot(db, next_number).await; + } + + if !is_syncing_mode() { + broadcast_new_torrent_to_peers(next_number, &torrent_bytes, map).await; + } + + // Only advance mined-count tracking when this save actually moved + // the persisted chain height forward. + if get_height(db) > previous_height { + NodeInfo::increment_mined(&miner).await; + } + + Ok(()) +} + +async fn save_binary_data(params: SaveBinaryDataParams<'_>) -> Result<(), String> { + let SaveBinaryDataParams { + data, + header_bytes, + pending_effects, + signatures, + db, + previous_height, + block_header_number, + difficulty, + timestamp, + header_hash, + save_type, + miner, + map, + } = params; + + // Build the on-disk block context and announce whether the save came + // from local mining or an updating path. + let (file_name, next_number, difficulty) = block_file_context(db, difficulty).await; + let current_time = format_block_time(timestamp); + + if next_number != 0 { + if !save_type.is_updating() { + info!("New block mined {next_number} at {current_time}"); + } else { + info!("New block discovered {next_number} at {current_time}"); + } + } else if !save_type.is_updating() { + info!("Genesis block mined {next_number} at {current_time}"); + } else { + info!("Genesis block discovered {next_number} at {current_time}"); + } + + let temp_file_name = format!("{file_name}.tmp"); + let _ = fs::remove_file(&temp_file_name); + fs::write(&temp_file_name, data).map_err(|e| e.to_string())?; + fs::rename(&temp_file_name, &file_name).map_err(|e| e.to_string())?; + + let mut applied_effects = match pending_effects.apply(db) { + Ok(applied_effects) => applied_effects, + Err(err) => { + cleanup_block_file(&file_name); + return Err(err); + } + }; + let chunk_size = 1000; + for chunk in signatures.chunks(chunk_size) { + if let Err(err) = mark_processed_by_signatures(chunk, block_header_number).await { + let _ = restore_processed_by_signatures(signatures).await; + if let Err(rollback_err) = applied_effects.rollback(db) { + error!("Failed to roll back block effects: {rollback_err}"); + } + cleanup_block_file(&file_name); + return Err(err.to_string()); + } + } + + if let Err(err) = commit_block_indexes(db, block_header_number, header_bytes, header_hash) { + let _ = restore_processed_by_signatures(signatures).await; + if let Err(rollback_err) = applied_effects.rollback(db) { + error!("Failed to roll back block effects: {rollback_err}"); + } + cleanup_block_file(&file_name); + return Err(err); + } + + let torrent_bytes = match metadata_from_file( + &file_name, + next_number, + difficulty, + timestamp, + header_hash, + miner.clone(), + map.clone(), + ) + .await + { + Ok(torrent_bytes) => torrent_bytes, + Err(err) => { + cleanup_block_indexes(db, block_header_number, header_hash); + cleanup_torrent_file(next_number); + let _ = restore_processed_by_signatures(signatures).await; + if let Err(rollback_err) = applied_effects.rollback(db) { + error!("Failed to roll back block effects: {rollback_err}"); + } + cleanup_block_file(&file_name); + return Err(err); + } + }; + + if next_number != 0 { + if let Err(err) = increase_height(db) { + cleanup_block_indexes(db, block_header_number, header_hash); + cleanup_torrent_file(next_number); + let _ = restore_processed_by_signatures(signatures).await; + if let Err(rollback_err) = applied_effects.rollback(db) { + error!("Failed to roll back block effects: {rollback_err}"); + } + cleanup_block_file(&file_name); + return Err(err); + } + let _ = update_snapshot(db, next_number).await; + if let Some(snapshot_height) = snapshot_height(db).await { + if let Err(err) = finalize_rewards_through_height(db, snapshot_height).await { + error!("Failed to finalize rewards through snapshot height {snapshot_height}: {err}"); + } + prune_recent_torrents(snapshot_height).await; + prune_torrent_statuses_through_height(snapshot_height).await; + let _ = prune_staged_torrents(snapshot_height).await; + } + } else { + let _ = update_snapshot(db, next_number).await; + } + + if !is_syncing_mode() { + broadcast_new_torrent_to_peers(next_number, &torrent_bytes, map).await; + } + + // Only advance mined-count tracking when this save actually moved + // the persisted chain height forward. + if get_height(db) > previous_height { + NodeInfo::increment_mined(&miner).await; + } + + Ok(()) +} + +async fn block_file_context(db: &Db, mut difficulty: u64) -> (String, u32, u64) { + // Genesis uses block number 0 and a fixed starting difficulty, + // while all later saves append to the current chain height. + let current_height = get_height(db); + let next_number = if genesis_checkup().await { + current_height + 1 + } else { + difficulty = 3000000000000000_u64; + 0 + }; + + let ( + _network_name, + _padded_base_coin, + block_ext, + _torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let file_name = PathBuf::from(block_path) + .join(format!("{next_number}.{block_ext}")) + .to_string_lossy() + .into_owned(); + (file_name, next_number, difficulty) +} + +fn commit_block_indexes( + db: &Db, + block_header_number: u32, + header_bytes: &[u8], + header_hash: &str, +) -> Result<(), String> { + // Commit the height->header and hash->height indexes together after + // the block file exists but before height marks the block current. + let tree = db + .open_tree("block_headers") + .map_err(|e| format!("Failed to open block_headers tree: {e}"))?; + tree.insert(block_header_number.to_le_bytes(), header_bytes) + .map_err(|e| format!("Failed to write block header index: {e}"))?; + + let hkey = decode(header_hash).map_err(|e| format!("Failed to decode header hash: {e}"))?; + let htree = db + .open_tree("block_hashes") + .map_err(|e| format!("Failed to open block_hashes tree: {e}"))?; + let hvalue = block_header_number.to_le_bytes(); + htree + .insert(hkey, &hvalue) + .map_err(|e| format!("Failed to write block hash index: {e}"))?; + + Ok(()) +} + +fn cleanup_block_indexes(db: &Db, block_header_number: u32, header_hash: &str) { + // If the final height commit fails, remove the indexes that would + // otherwise point at a block the chain height does not acknowledge. + if let Ok(tree) = db.open_tree("block_headers") { + let _ = tree.remove(block_header_number.to_le_bytes()); + } + + if let Ok(hkey) = decode(header_hash) { + if let Ok(htree) = db.open_tree("block_hashes") { + let _ = htree.remove(hkey); + } + } +} + +fn cleanup_block_file(file_name: &str) { + let _ = fs::remove_file(file_name); +} + +fn cleanup_torrent_file(block_number: u32) { + let ( + _network_name, + _padded_base_coin, + _block_ext, + torrent_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let torrent_file = PathBuf::from(&torrent_path).join(format!("{block_number}.torrent")); + let temp_torrent_file = PathBuf::from(torrent_path).join(format!("{block_number}.torrent.tmp")); + let _ = fs::remove_file(torrent_file); + let _ = fs::remove_file(temp_torrent_file); +} + +fn format_block_time(timestamp: u32) -> String { + match Utc.timestamp_opt(timestamp as i64, 0).single() { + Some(datetime) => datetime.format("%H:%M:%S").to_string(), + None => "invalid-time".to_string(), + } +} diff --git a/src/records/record_chain/save_flags.rs b/src/records/record_chain/save_flags.rs new file mode 100644 index 0000000..225ee62 --- /dev/null +++ b/src/records/record_chain/save_flags.rs @@ -0,0 +1,9 @@ +use lazy_static::lazy_static; +use std::sync::Arc; +use tokio::sync::Mutex; + +lazy_static! { + // All block saves share one async mutex so header writes, block-file + // writes, and mempool state changes cannot interleave. + pub static ref SAVE_FLAG: Arc> = Arc::new(Mutex::new(())); +} diff --git a/src/records/record_chain/structs.rs b/src/records/record_chain/structs.rs new file mode 100644 index 0000000..8c8615f --- /dev/null +++ b/src/records/record_chain/structs.rs @@ -0,0 +1,69 @@ +use crate::blocks::block::Block; +use crate::records::memory::response_channels::Command; +use crate::records::record_chain::pending_effects::PendingEffects; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub struct SaveBlockParams { + // Bundle the block save inputs so mined and discovered save paths can + // share one strongly typed entry point. + pub block: Block, + pub db: Db, + pub header_hash: String, + pub timestamp: u32, + pub signatures: Vec, + pub save_type: SaveType, + pub allow_during_reorg: bool, + pub map: Arc>, +} + +pub struct SaveBinaryDataWithMempoolStreamParams<'a> { + // Keep the mined-block file write inputs named because this path also + // appends the original selected mempool records after the block payload. + pub data: &'a [u8], + pub header_bytes: &'a [u8], + pub selected: &'a crate::records::memory::mempool::SelectedMempoolBatch, + pub pending_effects: &'a PendingEffects, + pub db: &'a Db, + pub previous_height: u32, + pub block_header_number: u32, + pub difficulty: u64, + pub timestamp: u32, + pub header_hash: &'a str, + pub save_type: SaveType, + pub miner: String, + pub map: Arc>, +} + +pub struct SaveBinaryDataParams<'a> { + // Discovered and replayed blocks already include their transaction + // payloads, so this save path writes the prepared block bytes directly. + pub data: &'a [u8], + pub header_bytes: &'a [u8], + pub pending_effects: &'a PendingEffects, + pub signatures: &'a [String], + pub db: &'a Db, + pub previous_height: u32, + pub block_header_number: u32, + pub difficulty: u64, + pub timestamp: u32, + pub header_hash: &'a str, + pub save_type: SaveType, + pub miner: String, + pub map: Arc>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SaveType { + // Locally mined blocks and externally discovered blocks share the + // same save pipeline but diverge in mempool and logging behavior. + Mining, + Updating, +} + +impl SaveType { + pub fn is_updating(&self) -> bool { + matches!(self, Self::Updating) + } +} diff --git a/src/records/record_chain/swap_tx.rs b/src/records/record_chain/swap_tx.rs new file mode 100644 index 0000000..71cf9f9 --- /dev/null +++ b/src/records/record_chain/swap_tx.rs @@ -0,0 +1,136 @@ +use crate::blocks::swap::SwapTransaction; +use crate::common::nft_assets::nft_asset_name; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_swap( + transaction: &SwapTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the swap and compute its txid before applying the two + // fee transfers, asset exchange, and NFT provenance updates. + let txhash = transaction.unsigned_swap.hash().await; + let txhash_bytes = decode(&txhash).map_err(|e| format!("Error decoding swap txhash: {e}"))?; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + binary_data.extend(transaction_bytes); + + let asset1 = nft_asset_name( + &transaction.unsigned_swap.ticker1, + transaction.unsigned_swap.nft_series1, + ); + let asset2 = nft_asset_name( + &transaction.unsigned_swap.ticker2, + transaction.unsigned_swap.nft_series2, + ); + + // Swap processing moves both base-coin fees and both asset-denominated + // tips to the miner before exchanging the offered assets. + pending_effects.add_balance( + &miner, + transaction.unsigned_swap.txfee1, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &miner, + transaction.unsigned_swap.txfee2, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &miner, + transaction.unsigned_swap.tip1, + &asset1, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &miner, + transaction.unsigned_swap.tip2, + &asset2, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_swap.sender1, + transaction.unsigned_swap.txfee1, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_swap.sender2, + transaction.unsigned_swap.txfee2, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_swap.sender1, + transaction.unsigned_swap.tip1, + &asset1, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_swap.sender2, + transaction.unsigned_swap.tip2, + &asset2, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_swap.sender1, + transaction.unsigned_swap.value1, + &asset1, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_swap.sender2, + transaction.unsigned_swap.value2, + &asset2, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_swap.sender1, + transaction.unsigned_swap.value2, + &asset2, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_swap.sender2, + transaction.unsigned_swap.value1, + &asset1, + BalanceOperand::Addition, + ); + + // Record the txid location and append NFT provenance when either + // swapped asset corresponds to a tracked NFT series. + let txkey = decode(txhash).map_err(|e| format!("Error decoding swap txid key: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + pending_effects.append_tree_if_key_exists( + "nfts", + "nft_history", + asset1.into_bytes(), + txhash_bytes.clone(), + ); + pending_effects.append_tree_if_key_exists( + "nfts", + "nft_history", + asset2.into_bytes(), + txhash_bytes, + ); + Ok(binary_data) +} diff --git a/src/records/record_chain/token_provenance.rs b/src/records/record_chain/token_provenance.rs new file mode 100644 index 0000000..c4d28c9 --- /dev/null +++ b/src/records/record_chain/token_provenance.rs @@ -0,0 +1,54 @@ +use crate::sled::Db; + +pub fn append_token_history(db: &Db, ticker: &str, tx_hash_bytes: &[u8]) -> sled::Result<()> { + // Token provenance stores related txids as a flat sequence of + // 32-byte hashes so lookup can expose issuance and burn history. + let tree = db.open_tree("token_history")?; + let mut history = tree + .get(ticker.as_bytes())? + .map(|bytes| bytes.to_vec()) + .unwrap_or_default(); + history.extend_from_slice(tx_hash_bytes); + let _ = tree.insert(ticker.as_bytes(), history)?; + Ok(()) +} + +pub fn remove_token_history_entry(db: &Db, ticker: &str, tx_hash_bytes: &[u8]) -> sled::Result<()> { + // Reorg rollback removes the latest matching token-history entry so + // the stored provenance follows the active chain. + let tree = db.open_tree("token_history")?; + let Some(bytes) = tree.get(ticker.as_bytes())? else { + return Ok(()); + }; + + let mut history = bytes.to_vec(); + let chunk_len = 32; + if history.len() < chunk_len { + let _ = tree.remove(ticker.as_bytes())?; + return Ok(()); + } + + if let Some(index) = history + .chunks(chunk_len) + .rposition(|chunk| chunk == tx_hash_bytes) + { + let start = index * chunk_len; + history.drain(start..start + chunk_len); + } + + if history.is_empty() { + let _ = tree.remove(ticker.as_bytes())?; + } else { + let _ = tree.insert(ticker.as_bytes(), history)?; + } + + Ok(()) +} + +pub fn clear_token_history(db: &Db, ticker: &str) -> sled::Result<()> { + // Removing the genesis token transaction clears the whole token + // provenance chain because the asset no longer exists on-chain. + let tree = db.open_tree("token_history")?; + let _ = tree.remove(ticker.as_bytes())?; + Ok(()) +} diff --git a/src/records/record_chain/token_tx.rs b/src/records/record_chain/token_tx.rs new file mode 100644 index 0000000..c165ef2 --- /dev/null +++ b/src/records/record_chain/token_tx.rs @@ -0,0 +1,79 @@ +use crate::blocks::token::CreateTokenTransaction; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_token( + transaction: &CreateTokenTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the token-creation transaction and compute its txid + // before applying the balance-sheet and token-registry updates. + let txhash = transaction.unsigned_create_token.hash().await; + let txhash_bytes = + decode(&txhash).map_err(|e| format!("Error decoding token txhash: {e}"))?; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + binary_data.extend(transaction_bytes); + + // Token creation moves the fee to the miner, debits the creator, + // credits the new token balance, and stores token origin metadata. + pending_effects.add_balance( + &miner, + transaction.unsigned_create_token.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_create_token.creator, + transaction.unsigned_create_token.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.add_balance( + &transaction.unsigned_create_token.creator, + transaction.unsigned_create_token.number, + &transaction.unsigned_create_token.ticker, + BalanceOperand::Addition, + ); + let key = transaction.unsigned_create_token.ticker.clone(); + let value = transaction.unsigned_create_token.number.to_le_bytes(); + pending_effects.set_tree("tokens", key.into_bytes(), value.to_vec()); + + // Token issuance policy is stored separately so later issuance + // verification can check whether the token is capped or uncapped. + let limit_key = transaction.unsigned_create_token.ticker.clone(); + let limit_value = [transaction.unsigned_create_token.hard_limit]; + pending_effects.set_tree("token_limits", limit_key.into_bytes(), limit_value.to_vec()); + + let origin_key = transaction.unsigned_create_token.ticker.clone(); + let origin_value = txhash.as_bytes(); + pending_effects.set_tree("token_origins", origin_key.into_bytes(), origin_value.to_vec()); + pending_effects.append_tree( + "token_history", + transaction.unsigned_create_token.ticker.as_bytes().to_vec(), + txhash_bytes.clone(), + ); + + // Record the txid location so RPC lookups can resolve the saved + // transaction back to its block and index position. + let txkey = txhash_bytes; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + Ok(binary_data) +} diff --git a/src/records/record_chain/transfer_tx.rs b/src/records/record_chain/transfer_tx.rs new file mode 100644 index 0000000..aac8152 --- /dev/null +++ b/src/records/record_chain/transfer_tx.rs @@ -0,0 +1,80 @@ +use crate::blocks::transfer::TransferTransaction; +use crate::common::nft_assets::nft_asset_name; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_transfer( + transaction: &TransferTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + // Advance the per-block transaction index so the txid mapping can + // point back to the exact transaction position inside the block. + let mut index = index_counter.lock().await; + **index += 1; + + // Serialize the transfer and compute its txid before applying the + // balance-sheet and provenance side effects. + let txhash = transaction.unsigned_transfer.hash().await; + let txhash_bytes = + decode(&txhash).map_err(|e| format!("Error decoding transfer txhash: {e}"))?; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(e) => return Err(e.to_string()), + }; + binary_data.extend(transaction_bytes); + + // Transfer processing moves the fee to the miner and then applies + // the asset movement between sender and receiver. + pending_effects.add_balance( + &miner, + transaction.unsigned_transfer.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_transfer.sender, + transaction.unsigned_transfer.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + + let transfer_asset = nft_asset_name( + &transaction.unsigned_transfer.coin, + transaction.unsigned_transfer.nft_series, + ); + pending_effects.add_balance( + &transaction.unsigned_transfer.receiver, + transaction.unsigned_transfer.value, + &transfer_asset, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + &transaction.unsigned_transfer.sender, + transaction.unsigned_transfer.value, + &transfer_asset, + BalanceOperand::Subtraction, + ); + + // Record the txid location and append NFT ownership history when the + // transferred asset is an NFT series tracked in the NFT tree. + let txkey = decode(txhash).map_err(|e| format!("Error decoding transfer txid key: {e}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + pending_effects.append_tree_if_key_exists( + "nfts", + "nft_history", + transfer_asset.into_bytes(), + txhash_bytes, + ); + + Ok(binary_data) +} diff --git a/src/records/record_chain/vanity_tx.rs b/src/records/record_chain/vanity_tx.rs new file mode 100644 index 0000000..7fb4c4b --- /dev/null +++ b/src/records/record_chain/vanity_tx.rs @@ -0,0 +1,61 @@ +use crate::blocks::vanity::VanityAddressTransaction; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_vanity( + transaction: &VanityAddressTransaction, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + let mut index = index_counter.lock().await; + **index += 1; + + // The transaction hash is needed both for txid lookup and for storing + // rollback state tied to this exact vanity update. + let txhash = transaction.unsigned_vanity_address.hash().await; + let transaction_bytes = match transaction.to_bytes().await { + Ok(bytes) => bytes, + Err(err) => return Err(err.to_string()), + }; + // Saved block bytes keep the original vanity transaction in-line with the + // rest of the block payload. + binary_data.extend(transaction_bytes); + + let owner_address = &transaction.unsigned_vanity_address.address; + pending_effects.update_vanity( + owner_address, + &transaction.unsigned_vanity_address.vanity_address, + &txhash, + ); + + // Vanity registration fees are paid in base coin to the block miner. + pending_effects.add_balance( + &miner, + transaction.unsigned_vanity_address.txfee, + &BASECOIN, + BalanceOperand::Addition, + ); + pending_effects.add_balance( + owner_address, + transaction.unsigned_vanity_address.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + + // Map the transaction hash to its block and transaction index for lookup + // commands. + let txkey = decode(&txhash) + .map_err(|err| format!("Could not decode vanity transaction hash: {err}"))?; + let txvalue = format!("{block_header_number}:{}", **index); + pending_effects.set_tree("txid", txkey, txvalue.into_bytes()); + + Ok(binary_data) +} diff --git a/src/records/unpack_block/load_by_binary_data.rs b/src/records/unpack_block/load_by_binary_data.rs new file mode 100644 index 0000000..ffe6ff3 --- /dev/null +++ b/src/records/unpack_block/load_by_binary_data.rs @@ -0,0 +1,208 @@ +use crate::blocks::block::{Block, VrfBlock, VRF_BLOCK_BYTES}; +use crate::blocks::burn::BurnTransaction; +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::genesis::GenesisTransaction; +use crate::blocks::issue_token::IssueTokenTransaction; +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::blocks::marketing::MarketingTransaction; +use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::rewards::RewardsTransaction; +use crate::blocks::swap::SwapTransaction; +use crate::blocks::token::CreateTokenTransaction; +use crate::blocks::transfer::TransferTransaction; +use crate::blocks::vanity::VanityAddressTransaction; +use crate::common::types::{ + Transaction, BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, + GENESIS_TYPE, ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, REWARDS_TYPE, SWAP_TYPE, + TRANSFER_TYPE, VANITY_ADDRESS_TYPE, +}; +use crate::rpc::command_maps::get_bytes; + +// The transaction body helpers keep binary parsing aligned with the command map sizes. +fn transaction_body_len(txtype: u8) -> Result { + let total_len = get_bytes(txtype); + if total_len <= 1 { + return Err(format!("Unknown transaction type: {txtype}")); + } + + // get_bytes includes the leading type byte, while each from_bytes call + // below receives only the body. + Ok(total_len - 1) +} + +fn transaction_body_slice( + binary_data: &[u8], + start: usize, + body_len: usize, +) -> Result<&[u8], String> { + // Bounds checking happens before parsing so malformed torrent data reports + // the exact offset that failed. + binary_data + .get(start..start + body_len) + .ok_or_else(|| format!("Truncated transaction body at offset {start}")) +} + +pub async fn load_block_from_binary(binary_data: &[u8]) -> Result { + // Binary block parsing mirrors the on-disk format so torrent-downloaded + // blocks can be verified before they are written into the local chain. + if binary_data.len() < VRF_BLOCK_BYTES { + return Err("Unable to load block: binary data shorter than VrfBlock header".to_string()); + } + + let vrf_block = VrfBlock::from_bytes(&binary_data[0..VRF_BLOCK_BYTES]) + .await + .map_err(|e| e.to_string())?; + let mut i = VRF_BLOCK_BYTES; + let mut transactions: Vec = Vec::new(); + + while i < binary_data.len() { + // Each transaction starts with its type byte, followed by the fixed-length body + // defined for that transaction family. + let txtype = binary_data[i]; + i += 1; + let body_len = transaction_body_len(txtype)?; + let body = transaction_body_slice(binary_data, i, body_len)?; + let transaction = match txtype { + GENESIS_TYPE => { + let genesis = Transaction::Genesis( + GenesisTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + genesis + } + + REWARDS_TYPE => { + let rewards = Transaction::Rewards( + RewardsTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + rewards + } + + TRANSFER_TYPE => { + let transfer = Transaction::Transfer( + TransferTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + transfer + } + + BURN_TYPE => { + let burn = Transaction::Burn( + BurnTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + burn + } + + CREATE_TOKEN_TYPE => { + let create_token = Transaction::Token( + CreateTokenTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + create_token + } + + ISSUE_TOKEN_TYPE => { + let issue_token = Transaction::IssueToken( + IssueTokenTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + issue_token + } + + CREATE_NFT_TYPE => { + let create_nft = Transaction::Nft( + CreateNftTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + create_nft + } + + MARKETING_TYPE => { + let marketing = Transaction::Marketing( + MarketingTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + marketing + } + + SWAP_TYPE => { + let swap = Transaction::Swap( + SwapTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + swap + } + + LENDER_TYPE => { + let loan = Transaction::Lender( + LoanContractTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + loan + } + + BORROWER_TYPE => { + let payment = Transaction::Borrower( + ContractPaymentTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + payment + } + + COLLATERAL_TYPE => { + let collateral = Transaction::Collateral( + CollateralClaimTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + collateral + } + VANITY_ADDRESS_TYPE => { + let vanity = Transaction::Vanity( + VanityAddressTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + vanity + } + _ => { + return Err(format!("Unsupported transaction type: {txtype}")); + } + }; + transactions.push(transaction); + } + + let block = Block { + vrf_block, + transactions, + }; + + Ok(block) +} diff --git a/src/records/unpack_block/load_by_block_number.rs b/src/records/unpack_block/load_by_block_number.rs new file mode 100644 index 0000000..b8d393a --- /dev/null +++ b/src/records/unpack_block/load_by_block_number.rs @@ -0,0 +1,227 @@ +use crate::blocks::block::{Block, VrfBlock, VRF_BLOCK_BYTES}; +use crate::blocks::burn::BurnTransaction; +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::genesis::GenesisTransaction; +use crate::blocks::issue_token::IssueTokenTransaction; +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::blocks::marketing::MarketingTransaction; +use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::rewards::RewardsTransaction; +use crate::blocks::swap::SwapTransaction; +use crate::blocks::token::CreateTokenTransaction; +use crate::blocks::transfer::TransferTransaction; +use crate::blocks::vanity::VanityAddressTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::types::{ + Transaction, BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, + GENESIS_TYPE, ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, REWARDS_TYPE, SWAP_TYPE, + TRANSFER_TYPE, VANITY_ADDRESS_TYPE, +}; +use crate::fs; +use crate::rpc::command_maps::get_bytes; +use crate::PathBuf; + +// The transaction body helpers keep the block parser aligned with the command map sizes. +fn transaction_body_len(txtype: u8) -> Result { + let total_len = get_bytes(txtype); + if total_len <= 1 { + return Err(format!("Unknown transaction type: {txtype}")); + } + + // get_bytes includes the transaction type byte; parser bodies start after + // that byte has already been consumed. + Ok(total_len - 1) +} + +fn transaction_body_slice( + binary_data: &[u8], + start: usize, + body_len: usize, +) -> Result<&[u8], String> { + // Slice with bounds checking so truncated block files fail cleanly instead + // of panicking during transaction parsing. + binary_data + .get(start..start + body_len) + .ok_or_else(|| format!("Truncated transaction body at offset {start}")) +} + +pub async fn load_block(block_number: u32) -> Result { + // Blocks are loaded from disk by height, then split back into the header and + // variable-length transaction payloads. + let ( + _network_name, + _padded_base_coin, + block_ext, + _torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let file_name = PathBuf::from(block_path) + .join(format!("{block_number}.{block_ext}")) + .to_string_lossy() + .into_owned(); + + // Load the full block because this path reconstructs both the header and + // every transaction for validation or inspection. + let binary_data = match fs::read(&file_name) { + Ok(data) => data, + Err(err) => { + println!("{}", &file_name); + eprintln!("Error reading file load_block: {err:?}"); + return Err(format!("Unable to read binary file: {err:?}")); + } + }; + + if binary_data.len() < VRF_BLOCK_BYTES { + return Err("Unable to load block: binary data shorter than VrfBlock header".to_string()); + } + + let vrf_block = VrfBlock::from_bytes(&binary_data[0..VRF_BLOCK_BYTES]) + .await + .map_err(|e| e.to_string())?; + let mut i = VRF_BLOCK_BYTES; + let mut transactions: Vec = Vec::new(); + + while i < binary_data.len() { + // Each stored transaction begins with its type byte, followed by the fixed-size + // body for that transaction family. + let txtype = binary_data[i]; + i += 1; + let body_len = transaction_body_len(txtype)?; + let body = transaction_body_slice(&binary_data, i, body_len)?; + let transaction = match txtype { + GENESIS_TYPE => { + let genesis = Transaction::Genesis( + GenesisTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + genesis + } + REWARDS_TYPE => { + let rewards = Transaction::Rewards( + RewardsTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + rewards + } + TRANSFER_TYPE => { + let transfer = Transaction::Transfer( + TransferTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + transfer + } + BURN_TYPE => { + let burn = Transaction::Burn( + BurnTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + burn + } + CREATE_TOKEN_TYPE => { + let create_token = Transaction::Token( + CreateTokenTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + create_token + } + ISSUE_TOKEN_TYPE => { + let issue_token = Transaction::IssueToken( + IssueTokenTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + issue_token + } + CREATE_NFT_TYPE => { + let create_nft = Transaction::Nft( + CreateNftTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + create_nft + } + MARKETING_TYPE => { + let marketing = Transaction::Marketing( + MarketingTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + marketing + } + SWAP_TYPE => { + let swap = Transaction::Swap( + SwapTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + swap + } + LENDER_TYPE => { + let loan = Transaction::Lender( + LoanContractTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + loan + } + BORROWER_TYPE => { + let payment = Transaction::Borrower( + ContractPaymentTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + payment + } + COLLATERAL_TYPE => { + let collateral = Transaction::Collateral( + CollateralClaimTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + collateral + } + VANITY_ADDRESS_TYPE => { + let vanity = Transaction::Vanity( + VanityAddressTransaction::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + vanity + } + _ => { + return Err(format!("Unsupported transaction type: {txtype}")); + } + }; + transactions.push(transaction); + } + + let block = Block { + vrf_block, + transactions, + }; + + Ok(block) +} diff --git a/src/records/unpack_block/mod.rs b/src/records/unpack_block/mod.rs new file mode 100644 index 0000000..e751d4f --- /dev/null +++ b/src/records/unpack_block/mod.rs @@ -0,0 +1,4 @@ +// The unpack_block module rebuilds block and header structures from stored binary data. +pub mod load_by_binary_data; +pub mod load_by_block_number; +pub mod unpack_header; diff --git a/src/records/unpack_block/unpack_header.rs b/src/records/unpack_block/unpack_header.rs new file mode 100644 index 0000000..8572aed --- /dev/null +++ b/src/records/unpack_block/unpack_header.rs @@ -0,0 +1,51 @@ +use crate::blocks::block::{VrfBlock, VRF_BLOCK_BYTES}; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::AsyncReadExt; +use crate::File; +use crate::PathBuf; + +pub async fn load_block_header(block_number: u32) -> Result { + // Header-only loads avoid reading the full block when only chain metadata + // is needed. + let ( + _network_name, + _padded_base_coin, + block_ext, + _torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let file_name = PathBuf::from(block_path) + .join(format!("{block_number}.{block_ext}")) + .to_string_lossy() + .into_owned(); + let file = match File::open(&file_name).await { + Ok(file) => file, + Err(err) => { + return Err(format!("Error opening file {}: {:?}", &file_name, err)); + } + }; + + let mut binary_data = Vec::with_capacity(VRF_BLOCK_BYTES); + // Only read the fixed VrfBlock prefix from the block file. + if let Err(err) = file + .take(VRF_BLOCK_BYTES as u64) + .read_to_end(&mut binary_data) + .await + { + return Err(format!( + "Error reading file load_block_head {}: {:?}", + &file_name, err + )); + } + + // The stored header format is the same VrfBlock prefix used at the + // beginning of every block file. + match VrfBlock::from_bytes(&binary_data).await { + Ok(block) => Ok(block), + Err(err) => Err(format!("Error parsing block: {err:?}")), + } +} diff --git a/src/records/wallet_registry/helpers.rs b/src/records/wallet_registry/helpers.rs new file mode 100644 index 0000000..657137f --- /dev/null +++ b/src/records/wallet_registry/helpers.rs @@ -0,0 +1,17 @@ +use super::*; + +pub fn get_registered_pubkey(db: &Db, short_address: &[u8]) -> sled::Result>> { + // The primary registry maps canonical short-address bytes to long public keys. + let tree = db.open_tree(WALLET_REGISTRY_TREE)?; + Ok(tree.get(short_address)?.map(|value| value.to_vec())) +} + +pub fn is_registered_short_address(db: &Db, short_address: &str) -> sled::Result { + // Vanity aliases are resolved first, so callers can ask about either form. + Ok(resolve_canonical_registered_short_address(db, short_address)?.is_some()) +} + +pub fn short_address_exists(db: &Db, short_address: &[u8]) -> sled::Result { + // Raw-byte checks only look at the canonical registry tree. + Ok(get_registered_pubkey(db, short_address)?.is_some()) +} diff --git a/src/records/wallet_registry/mappings.rs b/src/records/wallet_registry/mappings.rs new file mode 100644 index 0000000..5db1995 --- /dev/null +++ b/src/records/wallet_registry/mappings.rs @@ -0,0 +1,195 @@ +use super::*; + +pub fn resolve_canonical_registered_short_address( + db: &Db, + address: &str, +) -> sled::Result> { + // Normalize long addresses, normal short addresses, and vanity-shaped + // addresses into the current-network short address format first. + let normalized = match Wallet::normalize_to_short_address(address) { + Some(address) => address, + None => return Ok(None), + }; + + // A direct registry hit means the input was already the canonical short + // address for a registered wallet. + let normalized_bytes = match Wallet::short_address_to_bytes(&normalized) { + Some(bytes) => bytes, + None => return Ok(None), + }; + + if short_address_exists(db, &normalized_bytes)? { + return Ok(Some(normalized)); + } + + // If the normalized text is a vanity address, resolve it through the + // vanity-to-owner tree and return the owner's canonical short address. + if let Some(owner_short_address) = resolve_owner_from_vanity_address(db, &normalized)? { + return Ok(Some(owner_short_address)); + } + + Ok(None) +} + +pub fn resolve_pubkey_from_short_address( + db: &Db, + short_address: &str, +) -> sled::Result>> { + // Resolve vanity aliases before loading the public key so signature checks + // always use the registered owner address. + let canonical_short_address = + match resolve_canonical_registered_short_address(db, short_address)? { + Some(address) => address, + None => return Ok(None), + }; + + let short_address_bytes = match Wallet::short_address_to_bytes(&canonical_short_address) { + Some(bytes) => bytes, + None => return Ok(None), + }; + + get_registered_pubkey(db, &short_address_bytes) +} + +pub fn require_canonical_registered_short_address( + db: &Db, + address: &str, + label: &str, +) -> Result { + // This path is used where vanity aliases are not allowed. The input may be + // normalized, but it must already equal the canonical registered address. + let normalized = Wallet::normalize_to_short_address(address) + .ok_or_else(|| format!("{label} is invalid."))?; + + let canonical = resolve_canonical_registered_short_address(db, &normalized) + .map_err(|err| format!("{label} lookup failed: {err}"))? + .ok_or_else(|| format!("{label} is not registered."))?; + + if canonical != normalized { + return Err(format!( + "{label} must use the canonical short address instead of a vanity alias." + )); + } + + Ok(canonical) +} + +pub fn resolve_local_input_short_address(address: &str) -> Result { + // CLI tools may receive long, short, or vanity addresses, so normalize the + // user input before opening the local registry. + let normalized = Wallet::normalize_to_short_address(address) + .ok_or_else(|| "Invalid wallet address.".to_string())?; + + let ( + _network_name, + _padded_base_coin, + _suffix, + _torrent_path, + _wallet_path, + _block_path, + db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let db = sled::open(&db_path) + .map_err(|e| format!("Failed to open wallet registry database: {e}"))?; + + if let Some(canonical) = resolve_canonical_registered_short_address(&db, &normalized) + .map_err(|e| format!("Wallet registry lookup failed: {e}"))? + { + // Registered vanity aliases are converted to the true owner short address. + return Ok(canonical); + } + + let (payload, _) = normalized + .rsplit_once('.') + .ok_or_else(|| "Invalid wallet address.".to_string())?; + + if payload.len() == Wallet::SHORT_ADDRESS_HASH_BYTES_LENGTH * 2 + && payload.chars().all(|ch| ch.is_ascii_hexdigit()) + { + // A normal hex short address can be used even if the local wallet + // registry has not learned it yet. + return Ok(normalized); + } + + Err("Vanity address could not be resolved to a canonical short address.".to_string()) +} + +pub fn list_registered_wallets(db: &Db) -> sled::Result, Vec)>> { + let tree = db.open_tree(WALLET_REGISTRY_TREE)?; + let mut wallets = Vec::new(); + + // Registry sync ships raw short-address/public-key pairs across peers. + for entry in tree.iter() { + let (short_address, public_key) = entry?; + wallets.push((short_address.to_vec(), public_key.to_vec())); + } + + Ok(wallets) +} + +pub fn get_registered_vanity_for_owner( + db: &Db, + owner_short_address: &str, +) -> sled::Result> { + // Owner lookups are canonicalized first so callers can pass either long or + // short owner addresses. + let canonical_owner = match resolve_canonical_registered_short_address(db, owner_short_address)? + { + Some(address) => address, + None => return Ok(None), + }; + + let owner_bytes = match Wallet::short_address_to_bytes(&canonical_owner) { + Some(bytes) => bytes, + None => return Ok(None), + }; + + let tree = db.open_tree(WALLET_VANITY_OWNER_TREE)?; + // Owner tree stores owner short-address bytes -> vanity-address bytes. + let Some(vanity_bytes) = tree.get(owner_bytes)?.map(|value| value.to_vec()) else { + return Ok(None); + }; + + Ok(Wallet::bytes_to_vanity_address(&vanity_bytes)) +} + +pub fn resolve_owner_from_vanity_address( + db: &Db, + vanity_address: &str, +) -> sled::Result> { + // Vanity text is normalized to the fixed byte payload before sled lookup. + let vanity_bytes = match Wallet::vanity_address_to_bytes(vanity_address) { + Some(bytes) => bytes, + None => return Ok(None), + }; + + let tree = db.open_tree(WALLET_VANITY_ADDRESS_TREE)?; + // Vanity tree stores vanity-address bytes -> owner short-address bytes. + let Some(owner_bytes) = tree.get(vanity_bytes)?.map(|value| value.to_vec()) else { + return Ok(None); + }; + + Ok(Wallet::bytes_to_short_address(&owner_bytes)) +} + +pub fn take_previous_vanity_for_txid( + db: &Db, + txid_hex: &str, +) -> sled::Result>> { + let tree = db.open_tree(WALLET_VANITY_ROLLBACK_TREE)?; + let key = decode(txid_hex).unwrap_or_default(); + // Taking removes the rollback marker so the same vanity undo cannot be + // replayed twice. + let Some(value) = tree.remove(key)?.map(|value| value.to_vec()) else { + return Ok(None); + }; + + if value.is_empty() { + // Empty bytes mean the owner had no prior vanity mapping. + return Ok(Some(None)); + } + + Ok(Some(Wallet::bytes_to_vanity_address(&value))) +} diff --git a/src/records/wallet_registry/mod.rs b/src/records/wallet_registry/mod.rs new file mode 100644 index 0000000..f2b61ba --- /dev/null +++ b/src/records/wallet_registry/mod.rs @@ -0,0 +1,26 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode; +use crate::sled::Db; +use crate::wallets::structures::Wallet; + +pub(super) const WALLET_REGISTRY_TREE: &str = "wallet_registry"; +pub(super) const WALLET_VANITY_ADDRESS_TREE: &str = "wallet_vanity_address"; +pub(super) const WALLET_VANITY_OWNER_TREE: &str = "wallet_vanity_owner"; +pub(crate) const WALLET_VANITY_ROLLBACK_TREE: &str = "wallet_vanity_rollback"; + +mod helpers; +mod mappings; +mod storage; +pub mod structs; + +pub use helpers::{get_registered_pubkey, is_registered_short_address, short_address_exists}; +pub use mappings::{ + get_registered_vanity_for_owner, list_registered_wallets, require_canonical_registered_short_address, + resolve_canonical_registered_short_address, resolve_local_input_short_address, + resolve_owner_from_vanity_address, resolve_pubkey_from_short_address, take_previous_vanity_for_txid, +}; +pub use storage::{ + register_or_update_vanity_address, register_short_address, remove_registered_vanity_for_owner, + store_previous_vanity_for_txid, +}; +pub use structs::{VanityRegistrationResult, WalletRegistrationResult}; diff --git a/src/records/wallet_registry/storage.rs b/src/records/wallet_registry/storage.rs new file mode 100644 index 0000000..ab29f98 --- /dev/null +++ b/src/records/wallet_registry/storage.rs @@ -0,0 +1,132 @@ +use super::*; + +pub fn register_short_address( + db: &Db, + short_address: &[u8], + public_key: &[u8], +) -> sled::Result { + let tree = db.open_tree(WALLET_REGISTRY_TREE)?; + + // Re-registering the same public key is harmless, but a different public + // key for the same short address is a real conflict. + if let Some(existing) = tree.get(short_address)? { + if existing.as_ref() == public_key { + return Ok(WalletRegistrationResult::AlreadyRegistered); + } + return Ok(WalletRegistrationResult::Conflict); + } + + tree.insert(short_address, public_key)?; + Ok(WalletRegistrationResult::Inserted) +} + +pub fn register_or_update_vanity_address( + db: &Db, + owner_short_address: &str, + vanity_address: &str, +) -> sled::Result { + // Vanity ownership is only valid for an already registered canonical owner. + let normalized_owner = + match resolve_canonical_registered_short_address(db, owner_short_address)? { + Some(address) => address, + None => return Ok(VanityRegistrationResult::OwnerNotRegistered), + }; + let normalized_vanity = match Wallet::vanity_address_to_bytes(vanity_address) + .and_then(|bytes| Wallet::bytes_to_vanity_address(&bytes)) + { + Some(address) => address, + None => return Ok(VanityRegistrationResult::InvalidVanity), + }; + + let owner_bytes = match Wallet::short_address_to_bytes(&normalized_owner) { + Some(bytes) => bytes, + None => return Ok(VanityRegistrationResult::InvalidOwner), + }; + let vanity_bytes = match Wallet::vanity_address_to_bytes(&normalized_vanity) { + Some(bytes) => bytes, + None => return Ok(VanityRegistrationResult::InvalidVanity), + }; + + let owner_tree = db.open_tree(WALLET_VANITY_OWNER_TREE)?; + let vanity_tree = db.open_tree(WALLET_VANITY_ADDRESS_TREE)?; + + // Check the owner tree first to see whether this is an insert, update, or + // no-op re-registration of the same vanity. + let existing_vanity_bytes = owner_tree.get(&owner_bytes)?.map(|value| value.to_vec()); + if let Some(existing_vanity_bytes) = &existing_vanity_bytes { + if *existing_vanity_bytes == vanity_bytes { + if let Some(existing_owner_bytes) = + vanity_tree.get(&vanity_bytes)?.map(|value| value.to_vec()) + { + if existing_owner_bytes == owner_bytes { + return Ok(VanityRegistrationResult::AlreadyRegistered); + } + } + } + } + + let result = if existing_vanity_bytes.is_some() { + VanityRegistrationResult::Updated + } else { + VanityRegistrationResult::Inserted + }; + + if let Some(old_vanity_bytes) = existing_vanity_bytes { + // Updating an owner removes the old reverse vanity -> owner mapping + // before writing the new pair. + vanity_tree.remove(&old_vanity_bytes)?; + } + + // Keep both directions in sync so vanity resolution and wallet restoration + // can each use the efficient lookup direction they need. + vanity_tree.insert(&vanity_bytes, owner_bytes.clone())?; + owner_tree.insert(&owner_bytes, vanity_bytes.clone())?; + + Ok(result) +} + +pub fn remove_registered_vanity_for_owner( + db: &Db, + owner_short_address: &str, +) -> sled::Result { + // Removing by owner canonicalizes first so long/short owner inputs remove + // the same vanity mapping. + let normalized_owner = + match resolve_canonical_registered_short_address(db, owner_short_address)? { + Some(address) => address, + None => return Ok(false), + }; + let owner_bytes = match Wallet::short_address_to_bytes(&normalized_owner) { + Some(bytes) => bytes, + None => return Ok(false), + }; + + let owner_tree = db.open_tree(WALLET_VANITY_OWNER_TREE)?; + let vanity_tree = db.open_tree(WALLET_VANITY_ADDRESS_TREE)?; + + let Some(vanity_bytes) = owner_tree.remove(&owner_bytes)?.map(|value| value.to_vec()) else { + return Ok(false); + }; + + // The reverse vanity lookup must be removed with the owner mapping. + vanity_tree.remove(&vanity_bytes)?; + + Ok(true) +} + +pub fn store_previous_vanity_for_txid( + db: &Db, + txid_hex: &str, + previous_vanity: Option<&str>, +) -> sled::Result<()> { + let tree = db.open_tree(WALLET_VANITY_ROLLBACK_TREE)?; + let key = decode(txid_hex).unwrap_or_default(); + // A missing previous vanity is stored as an empty value so undo can + // distinguish "known none" from "no rollback record exists". + let value = match previous_vanity { + Some(vanity) => Wallet::vanity_address_to_bytes(vanity).unwrap_or_default(), + None => Vec::new(), + }; + let _ = tree.insert(key, value)?; + Ok(()) +} diff --git a/src/records/wallet_registry/structs.rs b/src/records/wallet_registry/structs.rs new file mode 100644 index 0000000..2ab1902 --- /dev/null +++ b/src/records/wallet_registry/structs.rs @@ -0,0 +1,17 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalletRegistrationResult { + Inserted, + AlreadyRegistered, + Conflict, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VanityRegistrationResult { + Inserted, + Updated, + AlreadyRegistered, + Conflict, + OwnerNotRegistered, + InvalidOwner, + InvalidVanity, +} diff --git a/src/rpc/client/handshake.rs b/src/rpc/client/handshake.rs new file mode 100644 index 0000000..6ea82e2 --- /dev/null +++ b/src/rpc/client/handshake.rs @@ -0,0 +1,173 @@ +use crate::common::network_startup::get_listen_ip; +use crate::rpc::client::handshake_message::prepare_handshake_message; +use crate::rpc::client::handshake_processing::process_handshake_response; +use crate::rpc::client::structs::{Connect, Handshake}; +use crate::rpc::command_maps::{MAX_RPC_REPLY_BYTES, RPC_REPLY}; +use crate::rpc::handshake_constants::HANDSHAKE_RESPONSE_BYTES; +use crate::wallets::structures::Wallet; +use crate::{AsyncReadExt, AsyncWriteExt}; +use crate::io; +use crate::IpAddr; +use crate::SocketAddr; +use crate::TcpStream; +use tokio::net::TcpSocket; + +pub async fn connect_and_handshake(params: Connect) -> Result<(), Box> { + // Outbound node sockets bind to the configured local IP before + // connecting so the socket source matches the advertised node IP. + let stream = match connect_from_configured_ip(params.addr).await { + Ok(s) => s, + Err(e) => return Err(Box::new(e)), + }; + + // if let Err(e) = stream.set_nodelay(true) { + // eprintln!("Failed to set TCP_NODELAY: {e:?}"); + // } + + let handshake_params = Handshake { + stream, + db: params.db, + addr: params.node_ip, + wallet_key: params.wallet_key.clone(), + map: params.map, + first: params.first, + }; + perform_handshake(handshake_params) + .await + .map_err(|err| Box::new(err) as Box) +} + +async fn connect_from_configured_ip(remote_addr: SocketAddr) -> io::Result { + let listen_ip = get_listen_ip().await; + let local_ip: IpAddr = listen_ip.parse().map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid configured listen IP: {err}"), + ) + })?; + + if local_ip.is_unspecified() { + // Wildcard bind addresses such as 0.0.0.0 are for listening only. + // Outbound NAT/source selection must be left to the OS. + return TcpStream::connect(remote_addr).await; + } + + if local_ip.is_ipv4() != remote_addr.is_ipv4() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Configured listen IP and remote peer IP use different address families", + )); + } + + let socket = if remote_addr.is_ipv4() { + TcpSocket::new_v4()? + } else { + TcpSocket::new_v6()? + }; + + socket.bind(SocketAddr::new(local_ip, 0))?; + socket.connect(remote_addr).await +} + +async fn perform_handshake(mut params: Handshake) -> io::Result<()> { + let wallet = Wallet::try_obtain_wallet(params.wallet_key.clone(), None) + .await + .map_err(io::Error::other)?; + + let data = prepare_handshake_message(&wallet, "aced").await?; + let response = send_and_receive_handshake(&mut params, &data).await?; + process_handshake_response(response, &wallet, params).await +} + +async fn send_and_receive_handshake(params: &mut Handshake, data: &[u8]) -> io::Result> { + params.stream.write_all(data).await?; + params.stream.flush().await?; + + let mut buffer = vec![0u8; HANDSHAKE_RESPONSE_BYTES]; + let mut total_read = 0usize; + + while total_read < buffer.len() { + match params.stream.read(&mut buffer[total_read..]).await { + Ok(0) => { + if total_read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Peer closed handshake without sending a response", + )); + } + break; + } + Ok(n) => { + total_read += n; + } + Err(err) => return Err(err), + } + } + + let received = buffer[..total_read].to_vec(); + if let Some(message) = handshake_error_response(&received) { + return Err(io::Error::other(message)); + } + + if let Ok(text) = String::from_utf8(received.clone()) { + let trimmed = text.trim_matches(char::from(0)).trim().to_string(); + if !trimmed.is_empty() + && (trimmed == "The connection is already in the connection manager Please wait 10 minutes and try again" + || trimmed.starts_with("error:")) + { + return Err(io::Error::other(trimmed)); + } + } + + if total_read != buffer.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!( + "Incomplete handshake response: received {} of {} bytes", + total_read, + buffer.len() + ), + )); + } + + Ok(received) +} + +fn handshake_error_response(received: &[u8]) -> Option { + if received.first().copied()? != RPC_REPLY { + return None; + } + + // Current RPC replies use a 4-byte payload length after command+uid. + if received.len() >= 8 { + let message_length = + u32::from_le_bytes([received[4], received[5], received[6], received[7]]) as usize; + if message_length <= MAX_RPC_REPLY_BYTES && received.len() >= 8 + message_length { + let message = String::from_utf8_lossy(&received[8..8 + message_length]) + .trim_matches(char::from(0)) + .trim() + .to_string(); + if !message.is_empty() { + return Some(message); + } + } + } + + // Older peers used a 2-byte payload length. This only exists so a + // rejected handshake reports the real remote error instead of being + // misread as a malformed signed response. + if received.len() >= 6 { + let message_length = u16::from_le_bytes([received[4], received[5]]) as usize; + if message_length <= MAX_RPC_REPLY_BYTES && received.len() >= 6 + message_length { + let message = String::from_utf8_lossy(&received[6..6 + message_length]) + .trim_matches(char::from(0)) + .trim() + .to_string(); + if !message.is_empty() { + return Some(message); + } + } + } + + None +} diff --git a/src/rpc/client/handshake_message.rs b/src/rpc/client/handshake_message.rs new file mode 100644 index 0000000..f48ca69 --- /dev/null +++ b/src/rpc/client/handshake_message.rs @@ -0,0 +1,42 @@ +use crate::common::binary_conversions::ip_port_to_binary; +use crate::common::network_startup::get_ip_and_port; +use crate::common::skein::skein_256_hash_data; +use crate::rpc::commands::time::request_time; +use crate::rpc::handshake_constants::HANDSHAKE_REQUEST_BYTES; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::decode; +use crate::io; + +pub async fn prepare_handshake_message(wallet: &Wallet, message: &str) -> io::Result> { + // Client handshakes are assembled from the signed message, wallet identity, timestamp, + // and advertised ip:port so standalone tools can follow the same handshake rules as the node. + let hash = skein_256_hash_data(message); + let signed_message = Wallet::sign_transaction(&hash, &wallet.saved.private_key).await; + + let (_ip, _port, real_ip) = get_ip_and_port().await; + let timestamp = request_time().await; + let time = match timestamp { + RpcResponse::Binary(ts) => { + u32::from_le_bytes(ts.try_into().expect("Slice should be 4 bytes long")) + } + }; + + let address_bin = Wallet::long_address_to_bytes(wallet.saved.long_address.clone()); + let message_bin = + decode(message).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let signed_bin = + decode(&signed_message).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let ip = ip_port_to_binary(&real_ip) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let time_bin = time.to_le_bytes(); + + let mut data = Vec::with_capacity(HANDSHAKE_REQUEST_BYTES); + data.extend_from_slice(&message_bin); + data.extend_from_slice(&signed_bin); + data.extend_from_slice(&address_bin); + data.extend_from_slice(&time_bin); + data.extend_from_slice(&ip); + + Ok(data) +} diff --git a/src/rpc/client/handshake_processing.rs b/src/rpc/client/handshake_processing.rs new file mode 100644 index 0000000..363c3ce --- /dev/null +++ b/src/rpc/client/handshake_processing.rs @@ -0,0 +1,364 @@ +use crate::common::binary_conversions::binary_to_ip_port; +use crate::common::check_genesis::genesis_checkup; +use crate::common::skein::skein_256_hash_data; +use crate::config::SETTINGS; +use crate::miner::flag::{clear_mining_stop_request, request_mining_stop, set_mining_state, set_node_mode, MiningState, NodeMode}; +use crate::orphans::structs::OrphanCheckup2; +use crate::orphans::sync_check::sync_checkup; +use crate::orphans::torrent_candidates::hydrate_torrent_candidates; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::connections::{set_reconnect_context, CONNECTIONS}; +use crate::records::memory::enums::{ClientType, ConnectionType}; +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::memory::structs::{Connection, StoreConnectionParams}; +use crate::rpc::client::handshake::connect_and_handshake; +use crate::rpc::client::structs::{Connect, Handshake}; +use crate::rpc::client::syncing::node_syncing; +use crate::rpc::client::register_wallet::register_connected_wallet; +use crate::rpc::client::wallet_registry_sync::sync_wallet_registry; +use crate::rpc::command_maps::RPC_RANDOM_NODE; +use crate::rpc::handshake_constants::{HANDSHAKE_ADDRESS_OFFSET, HANDSHAKE_MESSAGE_BYTES, HANDSHAKE_RESPONSE_BYTES, HANDSHAKE_SIGNATURE_OFFSET,}; +use crate::rpc::responses::RpcResponse; +use crate::rpc::server::rpc_command_loop::start_loop; +use crate::common::network_startup::get_ip_and_port; +use crate::startup::network_broadcast::announce_self_to_network; +use crate::startup::remote_height::request_remote_height; +use crate::wallets::structures::Wallet; +use crate::log::{error, info, warn}; +use crate::sled::Db; +use crate::Arc; +use crate::Duration; +use crate::encode; +use crate::io; +use crate::Mutex; +use crate::SocketAddr; +use crate::TcpStream; +use crate::timeout; + +#[derive(Clone)] +pub struct BootstrapParams { + pub stream: Arc>, + pub connections_key: String, + pub wallet_key: String, + pub db: Db, + pub map: Arc>, + pub first: bool, +} + +pub fn spawn_bootstrap_peer_discovery(params: BootstrapParams) { + tokio::spawn(async move { + if let Err(e) = bootstrap_peer_discovery(params).await { + set_node_mode(NodeMode::Normal); + clear_mining_stop_request(); + set_mining_state(MiningState::Idle); + eprintln!("[bootstrap] error: {e}"); + } + }); +} + +pub async fn bootstrap_peer_discovery(mut params: BootstrapParams) -> Result<(), String> { + set_node_mode(NodeMode::Syncing); + request_mining_stop(); + let (_, _, local_endpoint) = get_ip_and_port().await; + let max = SETTINGS.outgoing_connections; + let mut no_progress_count = 0; + let max_no_progress = 3; + let mut current_key = params.connections_key.clone(); + let mut stream = params.stream; + params.first = false; + while no_progress_count < max_no_progress { + let outgoing_connections = { + let connections = CONNECTIONS.read().await; + connections + .as_ref() + .map(|connection| connection.count_outgoing_connections()) + .unwrap_or(0) + }; + if outgoing_connections >= max as usize { + break; + } + + let (hashmap_key, _tx, rx) = reserve_entry(params.map.clone()).await; + + let mut payload = vec![RPC_RANDOM_NODE]; + payload.extend_from_slice(&hashmap_key); + + RpcResponse::send_raw(&stream, Some(¤t_key), &payload).await; + + let mut rx = rx.lock().await; + let buffer = match timeout(Duration::from_secs(15), rx.recv()).await { + Ok(Some(buf)) => buf, + Ok(None) => return Err("Peer discovery channel closed".into()), + Err(_) => return Err("Timeout waiting for peer discovery response".into()), + }; + + if buffer.len() != 18 { + no_progress_count += 1; + continue; + } + + let addr_string = binary_to_ip_port(&buffer[0..18]); + if addr_string == local_endpoint { + no_progress_count += 1; + continue; + } + + if Connection::get_stream_from_memory(&addr_string).await.is_some() { + no_progress_count += 1; + continue; + } + + let socket_addr: SocketAddr = match addr_string.parse() { + Ok(addr) => addr, + Err(_) => { + warn!("Invalid discovered peer from {current_key}: {addr_string}"); + no_progress_count += 1; + continue; + } + }; + + let connect = Connect { + addr: socket_addr, + node_ip: addr_string.clone(), + wallet_key: params.wallet_key.clone(), + db: params.db.clone(), + map: params.map.clone(), + first: params.first, + }; + + if let Err(err) = connect_and_handshake(connect).await { + warn!("Failed to connect to discovered peer {addr_string}: {err}"); + no_progress_count += 1; + continue; + } + + if let Some(new_stream) = Connection::get_stream_from_memory(&addr_string).await { + stream = new_stream; + current_key = addr_string; + no_progress_count = 0; + } else { + warn!("Failed to retrieve new stream for: {addr_string}"); + no_progress_count += 1; + } + } + + loop { + let local_height = get_height(¶ms.db); + let remote_height = + request_remote_height(stream.clone(), params.map.clone(), current_key.clone()).await?; + let local_genesis_exists = genesis_checkup().await; + + if !local_genesis_exists || remote_height > local_height + 10 { + info!("[sync] Starting sync from {local_height} to {remote_height}"); + node_syncing( + stream.clone(), + ¶ms.db, + remote_height, + params.map.clone(), + true, + ¶ms.wallet_key, + current_key.clone(), + ) + .await + .map_err(|e| format!("Sync error: {e}"))?; + if !local_genesis_exists && !genesis_checkup().await { + return Err("Sync completed without obtaining remote genesis".to_string()); + } + continue; + } + break; + } + + let post_sync_local_height = get_height(¶ms.db); + let post_sync_remote_height = + request_remote_height(stream.clone(), params.map.clone(), current_key.clone()).await?; + + if post_sync_remote_height != post_sync_local_height { + match hydrate_torrent_candidates(stream.clone(), params.map.clone(), current_key.clone()) + .await + { + Ok(imported) => { + if imported > 0 { + warn!("[sync] hydrated {imported} torrent candidates before post-sync orphan check"); + } + } + Err(err) => warn!("[sync] failed to hydrate torrent candidates: {err}"), + } + + let orphan_checkup_params = OrphanCheckup2 { + stream: stream.clone(), + db: params.db.clone(), + local_height: post_sync_local_height, + remote_height: post_sync_remote_height, + map: params.map.clone(), + node_syncing: true, + connections_key: current_key.clone(), + }; + match sync_checkup(orphan_checkup_params, ¶ms.wallet_key).await { + Ok(()) => {} + Err(err) => warn!("[sync] Post-sync orphan check error: {err}"), + } + } + + info!("[sync] post-sync checks complete, mining resuming"); + set_node_mode(NodeMode::Normal); + clear_mining_stop_request(); + set_mining_state(MiningState::Idle); + Ok(()) +} + +pub async fn process_handshake_response( + response: Vec, + wallet: &Wallet, + params: Handshake, +) -> io::Result<()> { + if response.len() != HANDSHAKE_RESPONSE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Invalid handshake response length: expected {}, got {}", + HANDSHAKE_RESPONSE_BYTES, + response.len() + ), + )); + } + + let returned_message_bin = &response[..HANDSHAKE_MESSAGE_BYTES]; + let returned_signed_bin = &response[HANDSHAKE_SIGNATURE_OFFSET..HANDSHAKE_ADDRESS_OFFSET]; + let returned_address_bin = &response[HANDSHAKE_ADDRESS_OFFSET..HANDSHAKE_RESPONSE_BYTES]; + + let returned_message = encode(returned_message_bin); + let returned_signed_message = encode(returned_signed_bin); + if Wallet::map_byte_to_wallet(returned_address_bin[0]).is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid handshake wallet network byte", + )); + } + let returned_address = Wallet::bytes_to_long_address(returned_address_bin.to_vec()); + + if !Wallet::wallet_validation(&returned_address).await { + return Ok(()); + } + let hash = skein_256_hash_data(&returned_message); + + let valid_response_signature = + Wallet::verify_transaction(&hash, &returned_signed_message, &returned_address).await; + + if returned_message == "ecaf" && valid_response_signature { + return Err(io::Error::other( + "Handshake accepted as client, not miner. The remote node could not verify your advertised public endpoint is reachable.", + )); + } + + if returned_message != "face" { + return Err(io::Error::other(format!( + "Handshake failed: unexpected response message {returned_message}" + ))); + } + + if !valid_response_signature { + return Err(io::Error::other( + "Handshake failed: response signature verification failed", + )); + } + + // create and arc mutex of the stream + let stream = Arc::new(Mutex::new(params.stream)); + let connections_key = params.addr.clone(); + let socket_parts: Vec<&str> = params.addr.split(':').collect(); + if socket_parts.len() == 2 { + let ip = socket_parts[0]; + let port: u16 = socket_parts[1].parse().unwrap_or(0); + set_reconnect_context( + params.db.clone(), + params.wallet_key.clone(), + params.map.clone(), + ) + .await; + let mut conn = CONNECTIONS.write().await; + if let Some(manager) = conn.as_mut() { + if !manager.store_connection(StoreConnectionParams { + connection_type: ConnectionType::Outgoing, + ip: ip.to_string(), + port, + stream: Arc::clone(&stream), + client_type: ClientType::Miner, + wallet_address: returned_address.clone(), + command_map: params.map.clone(), + }) { + return Err(io::Error::other( + "The connection is already in the connection manager Please wait 10 minutes and try again", + )); + } + } + } + let listener_stream = Arc::clone(&stream); + tokio::spawn(start_loop( + listener_stream, + params.db.clone(), + connections_key.clone(), + params.wallet_key.clone(), + params.map.clone(), + )); + let broadcast_stream = Arc::clone(&stream); + + if let Err(err) = register_connected_wallet( + Arc::clone(&stream), + params.map.clone(), + connections_key.clone(), + wallet, + ) + .await + { + warn!("[wallet_registry] peer registration failed during handshake: {err}"); + } + + if let Err(err) = sync_wallet_registry( + Arc::clone(&stream), + ¶ms.db, + params.map.clone(), + connections_key.clone(), + ) + .await + { + warn!("[wallet_registry] peer sync failed during handshake: {err}"); + } + + if params.first { + announce_self_to_network( + broadcast_stream.clone(), + &wallet.saved.short_address.clone(), + params.map.clone(), + ¶ms.db.clone(), + ¶ms.wallet_key.clone(), + &connections_key, + ) + .await; + if let Err(err) = NodeInfo::rebuild_mined_counts_from_chain(¶ms.db).await { + error!("[startup] failed to rebuild mined counts from local chain: {err}"); + } + let bsparams = BootstrapParams { + stream: Arc::clone(&stream), + connections_key: connections_key.clone(), + wallet_key: params.wallet_key.clone(), + db: params.db.clone(), + map: params.map.clone(), + first: params.first, + }; + + spawn_bootstrap_peer_discovery(bsparams); + } else { + announce_self_to_network( + broadcast_stream.clone(), + &wallet.saved.short_address.clone(), + params.map.clone(), + ¶ms.db.clone(), + ¶ms.wallet_key.clone(), + &connections_key, + ) + .await; + } + Ok(()) +} diff --git a/src/rpc/client/mod.rs b/src/rpc/client/mod.rs new file mode 100644 index 0000000..82f2d47 --- /dev/null +++ b/src/rpc/client/mod.rs @@ -0,0 +1,8 @@ +// The rpc client module contains the standalone client-side handshake and sync helpers. +pub mod handshake; +pub mod handshake_message; +pub mod handshake_processing; +pub mod register_wallet; +pub mod structs; +pub mod syncing; +pub mod wallet_registry_sync; diff --git a/src/rpc/client/register_wallet.rs b/src/rpc/client/register_wallet.rs new file mode 100644 index 0000000..6479f93 --- /dev/null +++ b/src/rpc/client/register_wallet.rs @@ -0,0 +1,73 @@ +use crate::common::skein::skein_256_hash_bytes; +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::rpc::command_maps::RPC_REGISTER_WALLET; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::log::warn; +use crate::Arc; +use crate::decode; +use crate::Duration; +use crate::Mutex; +use crate::TcpStream; +use crate::timeout; + +pub async fn register_connected_wallet( + stream: Arc>, + map: Arc>, + connections_key: String, + wallet: &Wallet, +) -> Result<(), String> { + let short_address_bytes = Wallet::short_address_to_bytes(&wallet.saved.short_address) + .ok_or_else(|| "Failed to derive short address bytes from wallet".to_string())?; + let long_address_bytes = Wallet::long_address_to_bytes(wallet.saved.long_address.clone()); + if long_address_bytes.len() != Wallet::ADDRESS_BYTES_LENGTH { + return Err("Startup wallet long address was invalid".to_string()); + } + + let mut signed_payload = + Vec::with_capacity(1 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + Wallet::ADDRESS_BYTES_LENGTH); + signed_payload.push(RPC_REGISTER_WALLET); + signed_payload.extend_from_slice(&short_address_bytes); + signed_payload.extend_from_slice(&long_address_bytes); + + let payload_hash = skein_256_hash_bytes(&signed_payload); + let signature = Wallet::sign_transaction(&payload_hash, &wallet.saved.private_key).await; + let signature_bytes = decode(&signature) + .map_err(|_| "Failed to decode wallet registration signature".to_string())?; + if signature_bytes.len() != Wallet::SIGNATURE_LENGTH { + return Err("Wallet registration signature length was invalid".to_string()); + } + + let (hashmap_key, _tx, rx) = reserve_entry(map.clone()).await; + let mut message = Vec::with_capacity( + 1 + 3 + + Wallet::SHORT_ADDRESS_BYTES_LENGTH + + Wallet::ADDRESS_BYTES_LENGTH + + Wallet::SIGNATURE_LENGTH, + ); + message.push(RPC_REGISTER_WALLET); + message.extend_from_slice(&hashmap_key); + message.extend_from_slice(&short_address_bytes); + message.extend_from_slice(&long_address_bytes); + message.extend_from_slice(&signature_bytes); + + RpcResponse::send_raw(&stream, Some(&connections_key), &message).await; + + let response = { + let mut rx = rx.lock().await; + timeout(Duration::from_secs(30), rx.recv()) + .await + .map_err(|_| "Timed out waiting for wallet registration response".to_string())? + .ok_or_else(|| "Wallet registration channel closed".to_string())? + }; + + let response_text = String::from_utf8_lossy(&response); + match response_text.trim() { + "1" => Ok(()), + "0" => Err("Peer rejected wallet registration".to_string()), + other => { + warn!("[wallet_registry] unexpected wallet registration response: {other}"); + Err("Unexpected wallet registration response".to_string()) + } + } +} diff --git a/src/rpc/client/structs.rs b/src/rpc/client/structs.rs new file mode 100644 index 0000000..9d1f232 --- /dev/null +++ b/src/rpc/client/structs.rs @@ -0,0 +1,26 @@ +use crate::records::memory::response_channels::Command; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; +use crate::SocketAddr; +use crate::TcpStream; + +// Connect carries the data needed to open a client connection and begin the handshake flow. +pub struct Connect { + pub addr: SocketAddr, + pub db: Db, + pub node_ip: String, + pub wallet_key: String, + pub map: Arc>, + pub first: bool, +} + +// Handshake carries the open stream and startup context into the client handshake stage. +pub struct Handshake { + pub stream: TcpStream, + pub db: Db, + pub addr: String, + pub wallet_key: String, + pub map: Arc>, + pub first: bool, +} diff --git a/src/rpc/client/syncing.rs b/src/rpc/client/syncing.rs new file mode 100644 index 0000000..187cd49 --- /dev/null +++ b/src/rpc/client/syncing.rs @@ -0,0 +1,121 @@ +use crate::common::check_genesis::genesis_checkup; +use crate::orphans::structs::OrphanCheckup2; +use crate::orphans::sync_check::sync_checkup; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::response_channels::reserve_entry; +use crate::records::memory::response_channels::Command; +use crate::torrent::structs::Torrent; +use crate::torrent::torrenting_system::torrent_requests::{handle_response_and_save_torrent, send_request_torrent_message}; +use crate::log::{error, info, warn}; +use crate::sled::Db; +use crate::Arc; +use crate::Duration; +use crate::io; +use crate::Mutex; +use crate::TcpStream; +use crate::timeout; + +pub async fn node_syncing( + stream: Arc>, + db: &Db, + remote_height: u32, + map: Arc>, + node_syncing: bool, + wallet_key: &str, + connections_key: String, +) -> io::Result<()> { + let mut local_height = get_height(db); + // Normalize the local height into the next expected block height + // so sync requests start at the first missing block. + if local_height > 0 || genesis_checkup().await { + local_height += 1; + } + + // Walk forward block-by-block until the local chain catches up to + // the advertised remote height. + while remote_height >= local_height { + let (hashmap_key, _torrent_tx, torrent_rx) = reserve_entry(map.clone()).await; + send_request_torrent_message( + stream.clone(), + local_height, + hashmap_key, + connections_key.clone(), + ) + .await?; + + // Wait for the requested torrent bytes to come back through the + // shared response map entry created for this request. + let mut rx = torrent_rx.lock().await; + if let Some(torrent_bytes) = + timeout(Duration::from_secs(30), rx.recv()) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "Timed out waiting for torrent response", + ) + })? + { + if let Ok(text) = String::from_utf8(torrent_bytes.clone()) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + warn!("[sync] received textual torrent response at height={local_height} err={trimmed}"); + if local_height == 0 && !genesis_checkup().await { + warn!( + "[sync] remote peer has no genesis torrent; local node will attempt genesis mining" + ); + } + break; + } + } + + let torrent = Torrent::from_bytes(&torrent_bytes).await?; + match handle_response_and_save_torrent( + local_height, + db, + torrent, + wallet_key, + map.clone(), + false, + ) + .await + { + Ok(()) => {} + Err(err) => { + warn!("[sync] error saving block: height={local_height} err={err}"); + + // Refresh local chain state before triggering orphan + // handling so the reconciliation logic starts from the + // real saved height. + local_height = get_height(db); + let orphan_checkup_params = OrphanCheckup2 { + stream: stream.clone(), + db: db.clone(), + local_height, + remote_height, + map: map.clone(), + node_syncing, + connections_key: connections_key.clone(), + }; + + match sync_checkup(orphan_checkup_params, wallet_key).await { + Ok(()) => {} + Err(err) => error!("[sync] orphan check returned error: {err}"), + } + + // Sync correction may roll back several blocks, so the + // next loop iteration needs a fresh local height. + local_height = get_height(db); + } + } + } else { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("No torrent response received for height {local_height}"), + )); + } + local_height += 1; + } + info!("[sync] node syncing complete, awaiting post-sync checks before mining resumes"); + Ok(()) +} diff --git a/src/rpc/client/wallet_registry_sync.rs b/src/rpc/client/wallet_registry_sync.rs new file mode 100644 index 0000000..3d62316 --- /dev/null +++ b/src/rpc/client/wallet_registry_sync.rs @@ -0,0 +1,56 @@ +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::records::wallet_registry::{register_short_address, WalletRegistrationResult}; +use crate::rpc::commands::wallet_registry_sync::WALLET_REGISTRY_RECORD_BYTES; +use crate::rpc::command_maps::RPC_WALLET_REGISTRY_SYNC; +use crate::rpc::responses::RpcResponse; +use crate::log::warn; +use crate::sled::Db; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::Duration; +use crate::Mutex; +use crate::TcpStream; +use crate::timeout; + +pub async fn sync_wallet_registry( + stream: Arc>, + db: &Db, + map: Arc>, + connections_key: String, +) -> Result<(), String> { + let (hashmap_key, _tx, rx) = reserve_entry(map.clone()).await; + let mut payload = Vec::with_capacity(4); + payload.push(RPC_WALLET_REGISTRY_SYNC); + payload.extend_from_slice(&hashmap_key); + + RpcResponse::send_raw(&stream, Some(&connections_key), &payload).await; + + let buffer = { + let mut rx = rx.lock().await; + timeout(Duration::from_secs(30), rx.recv()) + .await + .map_err(|_| "Timed out waiting for wallet registry sync response".to_string())? + .ok_or_else(|| "Wallet registry sync channel closed".to_string())? + }; + + if buffer.len() % WALLET_REGISTRY_RECORD_BYTES != 0 { + return Err("Wallet registry sync payload size was invalid".to_string()); + } + + for chunk in buffer.chunks(WALLET_REGISTRY_RECORD_BYTES) { + let short_address = &chunk[..Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + let public_key = &chunk[Wallet::SHORT_ADDRESS_BYTES_LENGTH..]; + + match register_short_address(db, short_address, public_key) { + Ok( + WalletRegistrationResult::Inserted | WalletRegistrationResult::AlreadyRegistered, + ) => {} + Ok(WalletRegistrationResult::Conflict) => { + warn!("[wallet_registry] conflict while syncing short address from peer"); + } + Err(err) => return Err(err.to_string()), + } + } + + Ok(()) +} diff --git a/src/rpc/command_maps.rs b/src/rpc/command_maps.rs new file mode 100644 index 0000000..332fda6 --- /dev/null +++ b/src/rpc/command_maps.rs @@ -0,0 +1,81 @@ +use crate::blocks::burn::BurnTransaction; +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::genesis::GenesisTransaction; +use crate::blocks::issue_token::IssueTokenTransaction; +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::blocks::marketing::MarketingTransaction; +use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::rewards::RewardsTransaction; +use crate::blocks::swap::SwapTransaction; +use crate::blocks::token::CreateTokenTransaction; +use crate::blocks::transfer::TransferTransaction; +use crate::blocks::vanity::VanityAddressTransaction; + +pub const RPC_NETWORK_INFO: u8 = 1; +pub const RPC_BLOCK_HEIGHT: u8 = 2; +pub const RPC_RANDOM_NODE: u8 = 3; +pub const RPC_TIME: u8 = 4; +pub const RPC_DIFFICULTY: u8 = 5; +pub const RPC_VALIDATE_TORRENT: u8 = 6; +pub const RPC_BLOCK_PIECE: u8 = 7; +pub const RPC_SUBMIT_TRANSACTION: u8 = 8; +pub const RPC_BLOCK_BY_HEIGHT: u8 = 9; +pub const RPC_BLOCK_BY_HASH: u8 = 10; +pub const RPC_LATEST_BLOCK: u8 = 11; +pub const RPC_TORRENT_BY_HEIGHT: u8 = 12; +pub const RPC_LARGEST_TX_FEE: u8 = 13; +pub const RPC_MEMPOOL_TX_BY_SIGNATURE: u8 = 14; +pub const RPC_MEMPOOL_TX_COUNT: u8 = 15; +pub const RPC_MEMPOOL_TX_BY_ADDRESS: u8 = 16; +pub const RPC_TRANSACTION_BY_TXID: u8 = 17; +pub const RPC_TOTAL_CONFIRMED_TX: u8 = 18; +pub const RPC_HEADER_BY_HEIGHT: u8 = 19; +pub const RPC_HEADER_BY_HASH: u8 = 20; +pub const RPC_ALL_HEADERS: u8 = 21; +pub const RPC_ADDRESS_COIN_BALANCE: u8 = 22; +pub const RPC_ADDRESS_TOTAL_BALANCE: u8 = 23; +pub const RPC_VALIDATE_ADDRESS: u8 = 24; +pub const RPC_VALIDATE_MESSAGE: u8 = 25; +pub const RPC_BLOCK_IP: u8 = 26; +pub const RPC_UNBLOCK_IP: u8 = 27; +pub const RPC_ADD_NETWORK_NODE: u8 = 28; +pub const RPC_DELETE_NETWORK_NODE: u8 = 29; +pub const RPC_REQUEST_NODE_LIST: u8 = 30; +pub const RPC_TOKEN_LIST: u8 = 31; +pub const RPC_NFT_LIST: u8 = 32; +pub const RPC_LOAN_CONTRACT: u8 = 33; +pub const RPC_SUBMIT_TORRENT: u8 = 34; +pub const RPC_TOKEN_DETAILS: u8 = 35; +pub const RPC_NFT_DETAILS: u8 = 36; +pub const RPC_CONTRACT_BY_ADDRESS: u8 = 37; +pub const RPC_REGISTER_WALLET: u8 = 38; +pub const RPC_WALLET_REGISTRY_SYNC: u8 = 39; +pub const RPC_VANITY_LOOKUP: u8 = 40; +pub const RPC_TORRENT_CANDIDATES: u8 = 41; +pub const RPC_REPLY: u8 = 255; +pub const MAX_RPC_REPLY_BYTES: usize = 64 * 1024 * 1024; + +// this allows us to define the byte size of the expected messagew +// so we can have different transaction types have different bytes +// and easily read them from stream as well as calculate the position +// in the binary file. +pub fn get_bytes(tx_type: u8) -> usize { + // These sizes include the leading tx type byte stored in the saved block format. + match tx_type { + 0 => GenesisTransaction::BYTE_LENGTH, + 1 => RewardsTransaction::BYTE_LENGTH, + 2 => TransferTransaction::BYTE_LENGTH, + 3 => CreateTokenTransaction::BYTE_LENGTH, + 4 => CreateNftTransaction::BYTE_LENGTH, + 5 => MarketingTransaction::BYTE_LENGTH, + 6 => SwapTransaction::BYTE_LENGTH, + 7 => LoanContractTransaction::BYTE_LENGTH, + 8 => ContractPaymentTransaction::BYTE_LENGTH, + 9 => CollateralClaimTransaction::BYTE_LENGTH, + 10 => BurnTransaction::BYTE_LENGTH, + 11 => IssueTokenTransaction::BYTE_LENGTH, + 12 => VanityAddressTransaction::BYTE_LENGTH, + _ => 0, + } +} diff --git a/src/rpc/commands/add_network_node.rs b/src/rpc/commands/add_network_node.rs new file mode 100644 index 0000000..bcd0188 --- /dev/null +++ b/src/rpc/commands/add_network_node.rs @@ -0,0 +1,70 @@ +use crate::records::memory::response_channels::Command; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::memory::network_mapping::structs::{AddAddressParams, SignedNodeEdit}; +use crate::rpc::read_bytes_from_stream; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; + +pub async fn add_network_node( + connections_key: &str, + stream_locked: Arc>, + db: &Db, + wallet_key: &str, + map: Arc>, +) -> Result<(u32, RpcResponse), String> { + // Command 28 carries the signed node-add payload directly after the + // UID: node address, node IP, signer, timestamp, and signature. + let (uid, _) = + read_bytes_from_stream::read_uid_from_stream(connections_key, stream_locked.clone()) + .await?; + let address_bytes = + read_bytes_from_stream::read_short_address_from_stream(connections_key, stream_locked.clone()) + .await?; + let address = Wallet::bytes_to_short_address(&address_bytes) + .ok_or_else(|| "error: Invalid short address bytes".to_string())?; + let ip = + read_bytes_from_stream::read_ip_from_stream(connections_key, stream_locked.clone()).await?; + let added_by_bytes = read_bytes_from_stream::read_usize_from_stream( + connections_key, + Wallet::ADDRESS_BYTES_LENGTH, + stream_locked.clone(), + ) + .await?; + let added_by = if added_by_bytes.iter().all(|&byte| byte == 0) { + String::new() + } else { + Wallet::bytes_to_long_address(added_by_bytes) + }; + let added_timestamp = + read_bytes_from_stream::read_u64_from_stream(connections_key, stream_locked.clone()) + .await?; + let added_signature = + read_bytes_from_stream::read_signature_from_stream(connections_key, stream_locked.clone()) + .await?; + let remote_ip = read_bytes_from_stream::read_caller_ip(stream_locked).await?; + + // NodeInfo owns the signature checks, local re-signing rules, and + // in-memory/broadcast side effects for the actual add operation. + let result = NodeInfo::add_address(AddAddressParams { + map, + edit: SignedNodeEdit { + address, + ip, + modified_by: added_by, + modified_timestamp: added_timestamp, + modified_signature: added_signature, + }, + blocks_mined: 0_u8, + remote_ip, + db: db.clone(), + wallet_key: wallet_key.to_string(), + connections_key: connections_key.to_string(), + }) + .await; + + Ok((uid, result)) +} diff --git a/src/rpc/commands/address_coin_lookup.rs b/src/rpc/commands/address_coin_lookup.rs new file mode 100644 index 0000000..4f2ff13 --- /dev/null +++ b/src/rpc/commands/address_coin_lookup.rs @@ -0,0 +1,34 @@ +use crate::records::balance_sheet::get_wallet_balance::get_balance; +use crate::records::wallet_registry::resolve_canonical_registered_short_address; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::Db; + +pub async fn lookup_wallet_coin(db: &Db, address: String, coin: String) -> RpcResponse { + // Return the saved confirmed balance for a specific address/asset pair. + if Wallet::normalize_to_short_address(&address).is_none() { + let msg = "error: Invalid wallet address" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + + let canonical_address = resolve_canonical_registered_short_address(db, &address) + .ok() + .flatten() + .unwrap_or(address); + + // Vanity addresses resolve to their registered canonical short + // address before the balance-sheet lookup is performed. + match get_balance(&canonical_address, &coin).await { + Ok(balance) => RpcResponse::Binary(balance.to_le_bytes().to_vec()), + Err(_) => { + let msg = "error: No wallet address with this coin found" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } + } +} diff --git a/src/rpc/commands/address_complete_balance_sheet.rs b/src/rpc/commands/address_complete_balance_sheet.rs new file mode 100644 index 0000000..18f9663 --- /dev/null +++ b/src/rpc/commands/address_complete_balance_sheet.rs @@ -0,0 +1,105 @@ +use crate::common::nft_assets::nft_asset_parts; +use crate::records::balance_sheet::pathing::{address_root_path, asset_name_from_relative_path}; +use crate::records::wallet_registry::resolve_canonical_registered_short_address; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::log::error; +use crate::sled::Db; +use crate::AsyncReadExt; +use crate::File; +use crate::Path; +use crate::read_dir; + +pub async fn get_token_balances(db: &Db, address: String) -> RpcResponse { + // Walk the hierarchical balance-sheet tree for one address and emit + // a compact binary list of asset name, NFT series, and balance tuples. + let mut combined_data: Vec = Vec::new(); + if Wallet::normalize_to_short_address(&address).is_none() { + return RpcResponse::Binary(b"error: Invalid wallet address".to_vec()); + } + let canonical_address = resolve_canonical_registered_short_address(db, &address) + .ok() + .flatten() + .unwrap_or(address); + // The balance directory is keyed by the canonical short address, so + // vanity inputs must be resolved before path construction. + let Some(address_path) = address_root_path(&canonical_address) else { + return RpcResponse::Binary(b"error: Invalid wallet address".to_vec()); + }; + let dir_path = Path::new(&address_path); + + if let Ok(mut entries) = read_dir(&dir_path).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_dir() { + let mut token_entries = match read_dir(&path).await { + Ok(entries) => entries, + Err(err) => { + error!("Failed to read directory {}: {:?}", path.display(), err); + continue; + } + }; + + while let Ok(Some(token_entry)) = token_entries.next_entry().await { + let token_path = token_entry.path(); + + let candidate_file = if token_path.is_file() { + token_path + } else { + token_path.join("wallet.bal") + }; + + // Some balances are direct files and NFT balances + // live one directory deeper under `wallet.bal`. + if !candidate_file.is_file() { + continue; + } + + let relative_path = match candidate_file.strip_prefix(&address_path) { + Ok(relative_path) => relative_path, + Err(_) => continue, + }; + + let Some(token) = asset_name_from_relative_path(relative_path) else { + continue; + }; + + // The response format keeps the asset name padded to 15 + // bytes plus the NFT series and raw balance bytes. + let (asset_name, nft_series) = nft_asset_parts(&token); + let mut padded_token = [0u8; 15]; + let token_bytes = asset_name.as_bytes(); + let token_length = token_bytes.len().min(15); + padded_token[..token_length].copy_from_slice(&token_bytes[..token_length]); + + let mut file = match File::open(&candidate_file).await { + Ok(file) => file, + Err(err) => { + error!( + "Failed to open file {}: {:?}", + candidate_file.display(), + err + ); + continue; + } + }; + + let mut balance_bytes = [0u8; 8]; + if let Err(err) = file.read_exact(&mut balance_bytes).await { + error!( + "Failed to read balance from file {}: {:?}", + candidate_file.display(), + err + ); + continue; + } + + combined_data.extend_from_slice(&padded_token); + combined_data.extend_from_slice(&nft_series.to_le_bytes()); + combined_data.extend_from_slice(&balance_bytes); + } + } + } + } + RpcResponse::Binary(combined_data) +} diff --git a/src/rpc/commands/bad_rpc_call.rs b/src/rpc/commands/bad_rpc_call.rs new file mode 100644 index 0000000..faf5b8e --- /dev/null +++ b/src/rpc/commands/bad_rpc_call.rs @@ -0,0 +1,18 @@ +use crate::records::ip_score::enums::InfractionType; +use crate::records::ip_score::score::update_ip_score; +use crate::records::memory::enums::ClientType; +use crate::sled::Db; +use crate::Utc; + +pub async fn record(ip: &str, client_type: ClientType, db: &Db, wallet_key: &str) { + let now = Utc::now().timestamp() as u32; + let _ = update_ip_score( + ip, + client_type.as_str(), + InfractionType::BadRpcCall, + now, + db, + wallet_key, + ) + .await; +} diff --git a/src/rpc/commands/block_by_hash.rs b/src/rpc/commands/block_by_hash.rs new file mode 100644 index 0000000..d9c9520 --- /dev/null +++ b/src/rpc/commands/block_by_hash.rs @@ -0,0 +1,64 @@ +use crate::decode; +use crate::records::unpack_block::load_by_block_number::load_block; +use crate::rpc::responses::RpcResponse; +use sled::Db; + +pub async fn request_block(db: &Db, hash: &str) -> RpcResponse { + // Resolve the block hash through the block-hash index and then reuse + // the height-based block loader to return the full serialized block. + let hash_bytes = match decode(hash) { + Ok(bytes) => bytes, + Err(err) => { + return RpcResponse::Binary( + format!("error: Failed to decode hash: {err}").into_bytes(), + ) + } + }; + + let htree = match db.open_tree("block_hashes") { + Ok(tree) => tree, + Err(err) => { + return RpcResponse::Binary(format!("error: Failed to open tree: {err}").into_bytes()) + } + }; + + let block_number = match htree.get(&hash_bytes) { + Ok(Some(value)) => { + let bytes = value.as_ref(); + if bytes.len() != std::mem::size_of::() { + return RpcResponse::Binary( + "error: Value has incorrect length".to_string().into_bytes(), + ); + } + u32::from_le_bytes( + bytes + .try_into() + .expect("Length is checked, so conversion should succeed"), + ) + } + Ok(None) => { + return RpcResponse::Binary( + "error: Failed to retrieve the value from the database" + .to_string() + .into_bytes(), + ); + } + Err(err) => { + return RpcResponse::Binary( + format!("error: Failed to get value from tree: {err}").into_bytes(), + ); + } + }; + + match load_block(block_number).await { + Ok(block) => match block.to_bytes().await { + Ok(block_bytes) => RpcResponse::Binary(block_bytes), + Err(err) => RpcResponse::Binary( + format!("error: Failed to convert block to bytes: {err}").into_bytes(), + ), + }, + Err(err) => { + RpcResponse::Binary(format!("error: Failed to load block: {err}").into_bytes()) + } + } +} diff --git a/src/rpc/commands/block_by_height.rs b/src/rpc/commands/block_by_height.rs new file mode 100644 index 0000000..c6c0d6a --- /dev/null +++ b/src/rpc/commands/block_by_height.rs @@ -0,0 +1,23 @@ +use crate::records::unpack_block::load_by_block_number::load_block; +use crate::rpc::responses::RpcResponse; + +pub async fn request_block(height: u32) -> RpcResponse { + // Load the full saved block by height and serialize it back into the + // binary RPC payload format expected by remote callers. + match load_block(height).await { + Ok(block) => { + let block_bytes = block.to_bytes().await; + match block_bytes { + Ok(result) => RpcResponse::Binary(result), + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } +} diff --git a/src/rpc/commands/block_header_by_hash.rs b/src/rpc/commands/block_header_by_hash.rs new file mode 100644 index 0000000..da87fb4 --- /dev/null +++ b/src/rpc/commands/block_header_by_hash.rs @@ -0,0 +1,30 @@ +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::decode; + +pub async fn lookup_by_hash(db: &Db, hash: &str) -> RpcResponse { + // Resolve the block hash through the block-hash index and then fetch + // the corresponding raw header bytes by height. + let htree = db.open_tree("block_hashes").unwrap(); + let hkey = decode(hash).unwrap(); + if let Ok(Some(value)) = htree.get(hkey) { + let block_number = u32::from_le_bytes(value.as_ref().try_into().unwrap()); + let tree = db.open_tree("block_headers").unwrap(); + let key = block_number.to_le_bytes(); + if let Ok(Some(value)) = tree.get(key) { + RpcResponse::Binary(value.to_vec()) + } else { + let msg = "error: Block height not found" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } + } else { + let msg = "error: Block hash not found" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } +} diff --git a/src/rpc/commands/block_header_by_height.rs b/src/rpc/commands/block_header_by_height.rs new file mode 100644 index 0000000..233d966 --- /dev/null +++ b/src/rpc/commands/block_header_by_height.rs @@ -0,0 +1,18 @@ +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn lookup_by_block_number(db: &Db, block_number: u32) -> RpcResponse { + // Block headers are stored directly under their height key, so this + // lookup can return the raw header bytes without loading the block body. + let tree = db.open_tree("block_headers").unwrap(); + let key = block_number.to_le_bytes(); + if let Ok(Some(value)) = tree.get(key) { + RpcResponse::Binary(value.to_vec()) + } else { + let msg = "error: Block height not found" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } +} diff --git a/src/rpc/commands/block_headers.rs b/src/rpc/commands/block_headers.rs new file mode 100644 index 0000000..f431fd3 --- /dev/null +++ b/src/rpc/commands/block_headers.rs @@ -0,0 +1,19 @@ +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn get_all_headers(db: &Db) -> RpcResponse { + // Concatenate every saved block header in height order so bootstrap + // and inspection tools can fetch the full header chain in one response. + let tree = db + .open_tree("block_headers") + .expect("Failed to open block_headers tree"); + + let mut combined_bytes = Vec::new(); + + for result in tree.iter() { + let (_key, value) = result.expect("Failed to read from the tree"); + combined_bytes.extend(value.as_ref()); + } + + RpcResponse::Binary(combined_bytes) +} diff --git a/src/rpc/commands/block_height.rs b/src/rpc/commands/block_height.rs new file mode 100644 index 0000000..aeaf7af --- /dev/null +++ b/src/rpc/commands/block_height.rs @@ -0,0 +1,11 @@ +use crate::records::block_height::get_block_height::get_height; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn request_block_height(db: &Db) -> RpcResponse { + // The current saved chain height is returned as a simple 4-byte + // little-endian integer for lightweight status checks. + let height = get_height(db); + let height_bytes = height.to_le_bytes().to_vec(); + RpcResponse::Binary(height_bytes) +} diff --git a/src/rpc/commands/block_peer_ip.rs b/src/rpc/commands/block_peer_ip.rs new file mode 100644 index 0000000..99660a2 --- /dev/null +++ b/src/rpc/commands/block_peer_ip.rs @@ -0,0 +1,32 @@ +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::Db; + +pub async fn block_peer(db: &Db, ip: String, signature: String, wallet_key: String) -> RpcResponse { + // Peer blocking is restricted to the local node owner, proven by a + // signature from the locally loaded wallet over the target IP string. + let wallet = match Wallet::try_obtain_wallet(wallet_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + let msg = format!("error: Wallet decryption failed: {err}") + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + }; + if Wallet::verify_transaction(&ip, &signature, &wallet.saved.long_address).await { + let tree = db.open_tree("blocked_peers").unwrap(); + let key = ip.clone(); + let value = b"blocked"; + let _ = tree.insert(key.as_bytes(), value); + key.to_string(); + let msg = "blocked".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } else { + let msg = "error: Only the node owner can block a peer" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } +} diff --git a/src/rpc/commands/contract.rs b/src/rpc/commands/contract.rs new file mode 100644 index 0000000..c548171 --- /dev/null +++ b/src/rpc/commands/contract.rs @@ -0,0 +1,308 @@ +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::Db; +use crate::{DateTime, Datelike, Local, TimeZone, Utc}; +use crate::encode; + +fn format_amount(value: u64) -> f64 { + // Contract RPC output presents coin amounts as user-facing decimal values. + value as f64 / 100_000_000.0 +} + +fn format_local_date(timestamp: u32) -> String { + // Human-readable contract summaries use the local timezone for dates. + match Local.timestamp_opt(timestamp as i64, 0).single() { + Some(datetime) => datetime.format("%m-%d-%y").to_string(), + None => "invalid date".to_string(), + } +} + +fn time_passed(start_time: u32, period: &str) -> usize { + // Contract summaries reuse the same elapsed-period logic as the + // collateral checks to compute delinquency and payment schedule state. + let start_datetime = Utc + .timestamp_opt(start_time as i64, 0) + .single() + .expect("Invalid start timestamp"); + let current_timestamp = Utc::now().timestamp() as u32; + let current_datetime = Utc + .timestamp_opt(current_timestamp as i64, 0) + .single() + .expect("Invalid current timestamp"); + let duration = current_datetime - start_datetime; + + match period { + "d" => duration.num_days() as usize, + "w" => (duration.num_days() / 7) as usize, + "m" => calculate_months_between(start_datetime, current_datetime), + _ => 0, + } +} + +fn calculate_months_between(start: DateTime, end: DateTime) -> usize { + // Monthly payment periods are calendar based, not a fixed number of + // seconds, so use year/month fields directly. + let start_date = start.date_naive(); + let end_date = end.date_naive(); + + let years_diff = end_date.year() - start_date.year(); + let months_diff = end_date.month() as i32 - start_date.month() as i32; + + (years_diff * 12 + months_diff) as usize +} + +fn payment_type_name(period: &str) -> &'static str { + // Expand the stored compact payment-period code into display text. + match period.trim() { + "d" => "daily", + "w" => "weekly", + "m" => "monthly", + _ => "unknown", + } +} + +async fn load_contract(db: &Db, hash: &[u8]) -> Result { + // Contract detail queries resolve the saved loan transaction first, + // then build higher-level summaries from that base contract payload. + let RpcResponse::Binary(bytes) = request_transaction_by_txid(db, hash.to_vec()).await; + + if bytes.is_empty() { + return Err("error: Contract not found".to_string()); + } + + let txtype = bytes[0]; + if txtype != 7 { + return Err("error: Requested hash is not a loan contract".to_string()); + } + + LoanContractTransaction::from_bytes(txtype, &bytes[1..]) + .await + .map_err(|e| format!("error: Failed to parse loan contract: {e}")) +} + +async fn collect_contract_activity( + db: &Db, + contract_hash: &str, +) -> Result< + ( + Vec, + Option, + ), + String, +> { + // Scan saved txids to gather all payments and any collateral-claim + // transaction associated with the requested contract. + let tree = db + .open_tree("txid") + .map_err(|e| format!("error: Failed to open txid tree: {e}"))?; + let mut payments = Vec::new(); + let mut collateral_claim: Option = None; + + for entry in tree.iter() { + let (txid_bytes, _) = + entry.map_err(|e| format!("error: Failed to read txid tree: {e}"))?; + let RpcResponse::Binary(bytes) = request_transaction_by_txid(db, txid_bytes.to_vec()).await; + if bytes.is_empty() { + continue; + } + + let txtype = bytes[0]; + let body = &bytes[1..]; + + // Payments and collateral claims are the only transaction types + // that can change a loan contract after creation. + if txtype == 8 { + if let Ok(payment) = ContractPaymentTransaction::from_bytes(txtype, body).await { + if payment.unsigned_contract_payment.contract_hash == contract_hash { + payments.push(payment); + } + } + } else if txtype == 9 { + if let Ok(claim) = CollateralClaimTransaction::from_bytes(txtype, body).await { + if claim.unsigned_collateral_claim.contract_hash == contract_hash { + let replace_existing = match &collateral_claim { + Some(existing) => { + // If more than one collateral claim appears, + // keep the newest one for the summary state. + claim.unsigned_collateral_claim.time + > existing.unsigned_collateral_claim.time + } + None => true, + }; + if replace_existing { + collateral_claim = Some(claim); + } + } + } + } + } + + payments.sort_by_key(|payment| payment.unsigned_contract_payment.timestamp); + Ok((payments, collateral_claim)) +} + +async fn build_contract_summary(hash: Vec, db: &Db) -> Result { + // Build the text view of a contract by combining the original loan, + // its payments, and any collateral-claim activity into one summary. + let contract_hash = encode(&hash); + let contract = load_contract(db, &hash).await?; + + let (payments, collateral_claim) = collect_contract_activity(db, &contract_hash).await?; + let loan_tree = db + .open_tree("loan") + .map_err(|err| format!("error: Failed to open loan tree: {err}"))?; + // The loan tree stores whether collateral remains claimable; if the + // tree does not have the row, fall back to discovered claim history. + let collateral_already_claimed = match loan_tree.get(&hash) { + Ok(Some(value)) => value == b"false", + _ => collateral_claim.is_some(), + }; + + let loan = &contract.unsigned_loan_contract; + let total_value_paid: u64 = payments + .iter() + .map(|payment| payment.unsigned_contract_payment.payback_amount) + .sum(); + let total_to_be_paidback = loan.payment_amount * loan.payment_number as u64; + let remaining_balance = total_to_be_paidback.saturating_sub(total_value_paid); + + let periods_elapsed = time_passed(loan.timestamp, &loan.payment_period); + let payments_due = periods_elapsed + .saturating_sub(1) + .min(loan.payment_number as usize); + let should_have_paid = payments_due as u64 * loan.payment_amount; + + // A contract is inactive once collateral was claimed; otherwise it is + // delinquent only when expected payments exceed actual payments. + let status = if collateral_already_claimed { + "inactive" + } else if should_have_paid > total_value_paid { + "delinquent" + } else { + "active" + }; + + let mut payments_output = String::new(); + for (index, payment) in payments.iter().enumerate() { + payments_output.push_str(&format!( + "payment_{}_txid={}\npayment_{}_amount={:.8}\npayment_{}_payee={}\npayment_{}_date={}\n", + index + 1, + payment.hash, + index + 1, + format_amount(payment.unsigned_contract_payment.payback_amount), + index + 1, + payment.unsigned_contract_payment.address, + index + 1, + format_local_date(payment.unsigned_contract_payment.timestamp), + )); + } + + let collateral_claimed_by = collateral_claim + .as_ref() + .map(|claim| claim.unsigned_collateral_claim.address.clone()) + .unwrap_or_default(); + + let mut output = format!( + "contract={}\nstatus={}\ncreation_date={}\nlender={}\nborrower={}\ncoin_loaned={}\nloaned_count={:.8}\ncollateral={}\ncollateral_count={:.8}\npayment_type={}\nnumber_of_payments={}\npayment_value={:.8}\nmax_late_payments={}\nmax_late_value={:.8}\ntotal_payments_made={}\ntotal_value_paid={:.8}\nremaining_balance={:.8}\ncollateral_claimed_by={}\n", + contract_hash, + status, + format_local_date(loan.timestamp), + loan.lender.trim(), + loan.borrower.trim(), + loan.loan_coin.trim(), + format_amount(loan.loan_amount), + loan.collateral.trim(), + format_amount(loan.collateral_amount), + payment_type_name(&loan.payment_period), + loan.payment_number, + format_amount(loan.payment_amount), + loan.grace_period, + format_amount(loan.max_late_value), + payments.len(), + format_amount(total_value_paid), + format_amount(remaining_balance), + collateral_claimed_by, + ); + output.push_str(&payments_output); + Ok(output) +} + +pub async fn contract_details(hash: Vec, db: &Db) -> RpcResponse { + // Return the text summary for one contract hash. + let output = match build_contract_summary(hash, db).await { + Ok(output) => output, + Err(err) => return RpcResponse::Binary(err.into_bytes()), + }; + RpcResponse::Binary(output.into_bytes()) +} + +pub async fn contract_details_by_address(address: String, db: &Db) -> RpcResponse { + // Return every saved contract where the address appears as either the + // lender or borrower, each expanded into the same text summary view. + let Some(address) = Wallet::normalize_to_short_address(&address) + else { + return RpcResponse::Binary(b"error: Invalid wallet address".to_vec()); + }; + + let tree = match db.open_tree("txid") { + Ok(tree) => tree, + Err(err) => { + return RpcResponse::Binary( + format!("error: Failed to open txid tree: {err}").into_bytes(), + ) + } + }; + + let mut contracts = Vec::new(); + + for entry in tree.iter() { + let (txid_bytes, _) = match entry { + Ok(entry) => entry, + Err(err) => { + return RpcResponse::Binary( + format!("error: Failed to read txid tree: {err}").into_bytes(), + ) + } + }; + + let RpcResponse::Binary(bytes) = request_transaction_by_txid(db, txid_bytes.to_vec()).await; + if bytes.is_empty() { + continue; + } + + let txtype = bytes[0]; + if txtype != 7 { + continue; + } + + // Only loan-contract transactions can be expanded into the + // contract summary returned by this RPC. + let contract = match LoanContractTransaction::from_bytes(txtype, &bytes[1..]).await { + Ok(contract) => contract, + Err(_) => continue, + }; + + if contract.unsigned_loan_contract.lender != address + && contract.unsigned_loan_contract.borrower != address + { + continue; + } + + let summary = match build_contract_summary(txid_bytes.to_vec(), db).await { + Ok(summary) => summary, + Err(err) => return RpcResponse::Binary(err.into_bytes()), + }; + contracts.push(summary); + } + + let mut output = format!("address={}\ncontracts={}\n", address, contracts.len()); + for (index, contract) in contracts.iter().enumerate() { + output.push_str(&format!("contract_{}:\n{}\n", index + 1, contract)); + } + + RpcResponse::Binary(output.into_bytes()) +} diff --git a/src/rpc/commands/delete_network_node.rs b/src/rpc/commands/delete_network_node.rs new file mode 100644 index 0000000..71a0b3d --- /dev/null +++ b/src/rpc/commands/delete_network_node.rs @@ -0,0 +1,61 @@ +use crate::records::memory::response_channels::Command; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::memory::network_mapping::structs::{DeleteAddressParams, SignedNodeEdit}; +use crate::rpc::read_bytes_from_stream; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; + +pub async fn delete_network_node( + connections_key: &str, + stream_locked: Arc>, + db: &Db, + wallet_key: &str, + map: Arc>, +) -> Result<(u32, RpcResponse), String> { + // Command 29 uses the same signed node-edit shape as add, but the + // payload represents a delete marker for an existing network node. + let (uid, _) = + read_bytes_from_stream::read_uid_from_stream(connections_key, stream_locked.clone()) + .await?; + let address_bytes = + read_bytes_from_stream::read_short_address_from_stream(connections_key, stream_locked.clone()) + .await?; + let address = Wallet::bytes_to_short_address(&address_bytes) + .ok_or_else(|| "error: Invalid short address bytes".to_string())?; + let ip = + read_bytes_from_stream::read_ip_from_stream(connections_key, stream_locked.clone()).await?; + let deleted_by = + read_bytes_from_stream::read_wallet_from_stream(connections_key, stream_locked.clone()) + .await?; + let deleted_timestamp = + read_bytes_from_stream::read_u64_from_stream(connections_key, stream_locked.clone()) + .await?; + let deleted_signature = + read_bytes_from_stream::read_signature_from_stream(connections_key, stream_locked.clone()) + .await?; + let remote_ip = read_bytes_from_stream::read_caller_ip(stream_locked).await?; + + // NodeInfo handles validating the signer and applying the delete + // metadata to the network map. + let result = NodeInfo::delete_address(DeleteAddressParams { + map, + edit: SignedNodeEdit { + address, + ip, + modified_by: deleted_by, + modified_timestamp: deleted_timestamp, + modified_signature: deleted_signature, + }, + remote_ip, + db: db.clone(), + wallet_key: wallet_key.to_string(), + connections_key: connections_key.to_string(), + }) + .await; + + Ok((uid, result)) +} diff --git a/src/rpc/commands/difficulty.rs b/src/rpc/commands/difficulty.rs new file mode 100644 index 0000000..122a7e8 --- /dev/null +++ b/src/rpc/commands/difficulty.rs @@ -0,0 +1,25 @@ +use crate::records::block_height::get_block_height::get_height; +use crate::records::unpack_block::unpack_header::load_block_header; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn request_difficulty(db: &Db) -> RpcResponse { + // Difficulty responses include both the current saved height and the + // next-block difficulty from the latest saved header. + let height = get_height(db); + let difficulty = match load_block_header(height).await { + Ok(data) => data.unmined_block.next_block_difficulty, + Err(_) => { + let msg = "Error loading block rewuest_difficulty" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + }; + let mut bytes = height.to_le_bytes().to_vec(); + let difficulty_bytes = difficulty.to_le_bytes(); + bytes.extend(difficulty_bytes); + + RpcResponse::Binary(bytes) +} diff --git a/src/rpc/commands/largest_tx_fee.rs b/src/rpc/commands/largest_tx_fee.rs new file mode 100644 index 0000000..b3e35d4 --- /dev/null +++ b/src/rpc/commands/largest_tx_fee.rs @@ -0,0 +1,6 @@ +use crate::records::memory::mempool::largest_fee; +use crate::rpc::responses::RpcResponse; + +pub async fn request_largest_tx_fee() -> RpcResponse { + largest_fee().await +} diff --git a/src/rpc/commands/latest_block.rs b/src/rpc/commands/latest_block.rs new file mode 100644 index 0000000..48f0d4d --- /dev/null +++ b/src/rpc/commands/latest_block.rs @@ -0,0 +1,27 @@ +use crate::records::block_height::get_block_height::get_height; +use crate::records::unpack_block::load_by_block_number::load_block; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn request_latest_block(db: &Db) -> RpcResponse { + // Use the current saved height as the chain tip and return the full + // serialized block bytes for that latest block. + let height = get_height(db); + let block = load_block(height).await; + match block { + Ok(block) => { + let block_bytes = block.to_bytes().await; + match block_bytes { + Ok(block_bytes) => RpcResponse::Binary(block_bytes), + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } +} diff --git a/src/rpc/commands/memory_by_signature.rs b/src/rpc/commands/memory_by_signature.rs new file mode 100644 index 0000000..4470270 --- /dev/null +++ b/src/rpc/commands/memory_by_signature.rs @@ -0,0 +1,7 @@ +use crate::records::memory::mempool::transaction_by_signature; +use crate::rpc::responses::RpcResponse; + +pub async fn request_transaction_by_signature(target_signature: String) -> RpcResponse { + // Proxy directly into the mempool signature lookup helper. + transaction_by_signature(&target_signature).await +} diff --git a/src/rpc/commands/mod.rs b/src/rpc/commands/mod.rs new file mode 100644 index 0000000..1545414 --- /dev/null +++ b/src/rpc/commands/mod.rs @@ -0,0 +1,44 @@ +// The rpc commands module groups the request handlers that run after a client handshake succeeds. +pub mod address_coin_lookup; +pub mod address_complete_balance_sheet; +pub mod add_network_node; +pub mod bad_rpc_call; +pub mod block_by_hash; +pub mod block_by_height; +pub mod block_header_by_hash; +pub mod block_header_by_height; +pub mod block_headers; +pub mod block_height; +pub mod block_peer_ip; +pub mod contract; +pub mod difficulty; +pub mod delete_network_node; +pub mod latest_block; +pub mod largest_tx_fee; +pub mod memory_by_signature; +pub mod network_info; +pub mod nft_list; +pub mod nft_lookup; +pub mod random_node; +pub mod receive_torrent; +pub mod request_valid_nodes; +pub mod route_reply; +pub mod structs; +pub mod time; +pub mod token_list; +pub mod token_lookup; +pub mod torrent; +pub mod torrent_by_block; +pub mod torrent_candidates; +pub mod transaction_by_txid; +pub mod tx_count; +pub mod tx_count_from_mempool; +pub mod tx_submit; +pub mod transactions_by_address; +pub mod unblock_peer_ip; +pub mod validate_address; +pub mod validate_torrent; +pub mod validate_message; +pub mod wallet_register; +pub mod wallet_registry_sync; +pub mod wallet_vanity_lookup; diff --git a/src/rpc/commands/network_info.rs b/src/rpc/commands/network_info.rs new file mode 100644 index 0000000..ffa893b --- /dev/null +++ b/src/rpc/commands/network_info.rs @@ -0,0 +1,97 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::mempool::{largest_fee, total_transactions}; +use crate::records::unpack_block::unpack_header::load_block_header; +use crate::rpc::commands::structs::NetworkInfo; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::Utc; + +async fn network_info_to_bytes(info: NetworkInfo) -> Vec { + // Serialize the network-info snapshot into the fixed RPC payload + // layout expected by peers and external clients. + let mut bytes = Vec::new(); + + let version_bytes = info.version.to_le_bytes(); + let network_bytes = info.network.as_bytes(); + let time_bytes = info.time.to_le_bytes(); + let prefix_bytes = info.wallet_prefix.as_bytes(); + let block_height_bytes = info.height.to_le_bytes(); + let next_block_difficulty_bytes = info.next_block_difficulty.to_le_bytes(); + let total_block_transactions_bytes = info.total_block_transactions.to_le_bytes(); + let transaction_response = total_transactions().await; + let RpcResponse::Binary(total_mempool_transactions_bytes) = transaction_response; + let fee_response = largest_fee().await; + let RpcResponse::Binary(largest_tx_fee_bytes) = fee_response; + + bytes.extend(version_bytes); + bytes.extend(network_bytes); + bytes.extend(time_bytes); + bytes.extend(prefix_bytes); + bytes.extend(block_height_bytes); + bytes.extend(next_block_difficulty_bytes); + bytes.extend(total_block_transactions_bytes); + bytes.extend(total_mempool_transactions_bytes); + bytes.extend(largest_tx_fee_bytes); + + bytes +} + +pub async fn request_network_info(db: &Db) -> RpcResponse { + // Build a point-in-time network snapshot from local chain state, + // mempool counts, fee stats, and network naming configuration. + let version = 1; + + let ( + network_name, + _wallet_type, + suffix, + _torrentpath, + _wallet_path, + _blockpath, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let network = network_name.to_string(); + let wallet_prefix = suffix.to_uppercase(); + + let height = get_height(db); + + let block = match load_block_header(height).await { + Ok(data) => data, + Err(_) => { + let error = "Error: Calcaulting Network Info" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(error); + } + }; + + let tree = db.open_tree("txid").unwrap(); + let total_block_transactions = tree.len() as u32; + + let time = Utc::now().timestamp() as u32; + + // These fields are filled inside `to_bytes` from their live RPC + // helpers so the serialization step stays self-contained. + let total_mempool_transactions = 0_u32; + let largest_tx_fee = 0_u64; + + let network_info = NetworkInfo { + version, + network, + time, + wallet_prefix, + height, + next_block_difficulty: block.unmined_block.next_block_difficulty, + total_block_transactions, + total_mempool_transactions, + largest_tx_fee, + }; + + let info_bytes = network_info_to_bytes(network_info).await; + + RpcResponse::Binary(info_bytes) +} diff --git a/src/rpc/commands/nft_list.rs b/src/rpc/commands/nft_list.rs new file mode 100644 index 0000000..09eac83 --- /dev/null +++ b/src/rpc/commands/nft_list.rs @@ -0,0 +1,55 @@ +use crate::common::nft_assets::nft_asset_parts; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::encode; + +pub async fn get_nfts(db: &Db) -> RpcResponse { + // Serialize every NFT asset as origin hash, padded asset name, and + // series number for the compact NFT-list RPC response format. + let tree = db.open_tree("nfts").unwrap(); + let origin_tree = db.open_tree("nft_origins").unwrap(); + + let mut serialized_nfts = Vec::new(); + + for result in tree.iter() { + match result { + Ok((key, _value)) => { + let raw_key = String::from_utf8_lossy(&key).to_string(); + let (nft_name, nft_series) = nft_asset_parts(&raw_key); + // NFT origins are kept separately so list responses can + // identify the creation transaction without loading history. + let hash = origin_tree + .get(&key) + .ok() + .flatten() + .map(|bytes| encode(&bytes)) + .unwrap_or_default(); + + if hash.is_empty() { + continue; + } + + let hash_bytes = match crate::decode(&hash) { + Ok(bytes) => bytes, + Err(_) => continue, + }; + if hash_bytes.len() != 32 { + continue; + } + + let mut padded_key = [32_u8; 15]; + let nft_name_bytes = nft_name.as_bytes(); + let key_len = nft_name_bytes.len().min(15); + padded_key[..key_len].copy_from_slice(&nft_name_bytes[..key_len]); + + // Each NFT-list row is origin hash, fixed asset name, + // and the series number. + serialized_nfts.extend_from_slice(&hash_bytes); + serialized_nfts.extend_from_slice(&padded_key); + serialized_nfts.extend_from_slice(&nft_series.to_le_bytes()); + } + Err(_) => continue, + } + } + RpcResponse::Binary(serialized_nfts) +} diff --git a/src/rpc/commands/nft_lookup.rs b/src/rpc/commands/nft_lookup.rs new file mode 100644 index 0000000..523d42f --- /dev/null +++ b/src/rpc/commands/nft_lookup.rs @@ -0,0 +1,416 @@ +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::swap::SwapTransaction; +use crate::blocks::transfer::TransferTransaction; +use crate::common::binary_conversions::binary_to_string; +use crate::common::nft_assets::{nft_asset_name, nft_asset_parts}; +use crate::records::balance_sheet::pathing::{balance_asset_segments, balance_root_path}; +use crate::records::balance_sheet::tokens_to_lower::strip_spaces_and_lowercase; +use crate::rpc::commands::transaction_by_txid::{request_transaction_by_txid, request_transaction_by_txid_with_block}; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::Db; +use crate::decode; +use crate::fs; + +const ACTION_CREATE: u8 = 1; +const ACTION_TRANSFER: u8 = 2; +const ACTION_SWAP: u8 = 3; +const ACTION_LOAN_LOCKED: u8 = 4; +const ACTION_LOAN_PAYMENT: u8 = 5; +const ACTION_COLLATERAL_CLAIMED: u8 = 6; +const ACTION_LOAN_ISSUED: u8 = 7; +const ACTION_BURNED: u8 = 8; +const HISTORY_ENTRY_SIZE: usize = 2 * Wallet::SHORT_ADDRESS_BYTES_LENGTH + 65; + +fn empty_wallet_bytes() -> [u8; Wallet::SHORT_ADDRESS_BYTES_LENGTH] { + // Missing wallet fields in the NFT lookup response are represented + // as a zeroed fixed-width wallet slot. + [0u8; Wallet::SHORT_ADDRESS_BYTES_LENGTH] +} + +fn padded_asset_bytes(asset: &str) -> [u8; 15] { + // NFT asset names travel in the same fixed-width 15-byte format used + // by token and transfer payloads. + let mut bytes = [b' '; 15]; + let asset_bytes = asset.as_bytes(); + let copy_len = asset_bytes.len().min(15); + bytes[..copy_len].copy_from_slice(&asset_bytes[..copy_len]); + bytes +} + +fn wallet_field_bytes(address: &str) -> [u8; Wallet::SHORT_ADDRESS_BYTES_LENGTH] { + // History entries always reserve a fixed wallet slot; invalid or + // unavailable addresses are encoded as all-zero bytes. + let bytes = match Wallet::normalize_to_short_address(address) + .and_then(|short| Wallet::short_address_to_bytes(&short)) + { + Some(bytes) => bytes, + None => return empty_wallet_bytes(), + }; + let mut fixed = [0u8; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + if bytes.len() == Wallet::SHORT_ADDRESS_BYTES_LENGTH { + fixed.copy_from_slice(&bytes); + } + fixed +} + +async fn find_matching_nft_asset(db: &Db, nft_name: &str, series: u32) -> Option { + // Resolve the requested NFT name and series number to the canonical + // stored asset name from the live registry or the historical origin map. + let normalized_query = strip_spaces_and_lowercase(nft_name); + let tree = db.open_tree("nfts").ok()?; + + for entry in tree.iter() { + let (key, _) = entry.ok()?; + let asset_name = binary_to_string(key.to_vec()); + let (stored_name, stored_series) = nft_asset_parts(&asset_name); + if strip_spaces_and_lowercase(&stored_name) == normalized_query && stored_series == series { + return Some(asset_name); + } + } + + let origin_tree = db.open_tree("nft_origins").ok()?; + for entry in origin_tree.iter() { + let (key, _) = entry.ok()?; + let asset_name = binary_to_string(key.to_vec()); + let (stored_name, stored_series) = nft_asset_parts(&asset_name); + if strip_spaces_and_lowercase(&stored_name) == normalized_query && stored_series == series { + return Some(asset_name); + } + } + + None +} + +async fn find_nft_origin( + db: &Db, + asset_name: &str, + nft_name: &str, + series: u32, +) -> Option> { + // Prefer the dedicated NFT origin mapping, but fall back to scanning + // saved NFT creation transactions if the mapping is missing. + let tree = db.open_tree("nft_origins").ok()?; + if let Ok(Some(bytes)) = tree.get(asset_name.as_bytes()) { + return Some(bytes.to_vec()); + } + + let normalized_query = strip_spaces_and_lowercase(nft_name); + let txid_tree = db.open_tree("txid").ok()?; + for entry in txid_tree.iter() { + let (txid_bytes, _) = entry.ok()?; + let RpcResponse::Binary(tx_bytes) = request_transaction_by_txid(db, txid_bytes.to_vec()).await; + + if tx_bytes.is_empty() || tx_bytes[0] != 4 { + continue; + } + + let contract = match CreateNftTransaction::from_bytes(4, &tx_bytes[1..]).await { + Ok(tx) => tx, + Err(_) => continue, + }; + + let contract_name = strip_spaces_and_lowercase(&contract.unsigned_create_nft.nft_name); + if contract_name != normalized_query { + continue; + } + + // Series-one NFT creation can mint a range of numbered items. + // Series-zero creation represents a single non-numbered NFT. + if contract.unsigned_create_nft.series == 1 { + if series > 0 && series <= contract.unsigned_create_nft.count { + return Some(txid_bytes.to_vec()); + } + } else if series == 0 { + return Some(txid_bytes.to_vec()); + } + } + + None +} + +async fn find_current_holder(asset_name: &str) -> String { + // Determine whether the NFT is currently held by any hashed wallet + // directory. The holder address cannot be recovered from the hashed + // path alone, so this returns an empty wallet slot when present. + let root = balance_root_path(); + let entries = match fs::read_dir(&root) { + Ok(entries) => entries, + Err(_) => return String::new(), + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let mut balance_path = path.clone(); + for segment in balance_asset_segments(asset_name) { + balance_path.push(segment); + } + balance_path.push("wallet.bal"); + + let Ok(bytes) = fs::read(&balance_path) else { + continue; + }; + + if bytes.len() >= 8 { + let mut buffer = [0u8; 8]; + buffer.copy_from_slice(&bytes[..8]); + if u64::from_le_bytes(buffer) > 0 { + return String::new(); + } + } + } + + String::new() +} + +async fn build_history_entry(db: &Db, asset_name: &str, txid_bytes: &[u8]) -> Option> { + // Expand a raw NFT history txid into a fixed-width history entry that + // captures the action, involved wallets, and any received asset details. + let RpcResponse::Binary(response) = request_transaction_by_txid_with_block(db, txid_bytes.to_vec()).await; + + if response.len() < 5 { + return None; + } + + let block = u32::from_le_bytes(response[0..4].try_into().ok()?); + let txtype = response[4]; + let body = &response[5..]; + + let mut from = empty_wallet_bytes(); + let mut to = empty_wallet_bytes(); + let mut received_asset = [b' '; 15]; + let mut received_series = 0u32; + let mut received_value = 0u64; + + let action = match txtype { + 4 => { + // Creation records establish the creator as the first holder. + let tx = CreateNftTransaction::from_bytes(txtype, body).await.ok()?; + to = wallet_field_bytes(&tx.unsigned_create_nft.creator); + ACTION_CREATE + } + 2 => { + // Transfers only count when the transferred NFT asset matches + // the lookup target. + let tx = TransferTransaction::from_bytes(txtype, body).await.ok()?; + let transfer_asset = + nft_asset_name(&tx.unsigned_transfer.coin, tx.unsigned_transfer.nft_series); + if transfer_asset != asset_name { + return None; + } + from = wallet_field_bytes(&tx.unsigned_transfer.sender); + to = wallet_field_bytes(&tx.unsigned_transfer.receiver); + ACTION_TRANSFER + } + 6 => { + // Swaps can involve the requested NFT on either side, so map + // sender/receiver and received asset from the matching side. + let tx = SwapTransaction::from_bytes(txtype, body).await.ok()?; + let asset1 = nft_asset_name(&tx.unsigned_swap.ticker1, tx.unsigned_swap.nft_series1); + let asset2 = nft_asset_name(&tx.unsigned_swap.ticker2, tx.unsigned_swap.nft_series2); + if asset1 == asset_name { + from = wallet_field_bytes(&tx.unsigned_swap.sender1); + to = wallet_field_bytes(&tx.unsigned_swap.sender2); + let (asset, series) = nft_asset_parts(&asset2); + received_asset = padded_asset_bytes(asset.trim()); + received_series = series; + received_value = tx.unsigned_swap.value2; + ACTION_SWAP + } else if asset2 == asset_name { + from = wallet_field_bytes(&tx.unsigned_swap.sender2); + to = wallet_field_bytes(&tx.unsigned_swap.sender1); + let (asset, series) = nft_asset_parts(&asset1); + received_asset = padded_asset_bytes(asset.trim()); + received_series = series; + received_value = tx.unsigned_swap.value1; + ACTION_SWAP + } else { + return None; + } + } + 7 => { + // Loans can either lock the NFT as collateral or issue the NFT + // as the loaned asset. + let tx = LoanContractTransaction::from_bytes(txtype, body) + .await + .ok()?; + if tx.unsigned_loan_contract.collateral == asset_name { + from = wallet_field_bytes(&tx.unsigned_loan_contract.borrower); + let (asset, series) = nft_asset_parts(&tx.unsigned_loan_contract.loan_coin); + received_asset = padded_asset_bytes(asset.trim()); + received_series = series; + received_value = tx.unsigned_loan_contract.loan_amount; + ACTION_LOAN_LOCKED + } else if tx.unsigned_loan_contract.loan_coin == asset_name { + from = wallet_field_bytes(&tx.unsigned_loan_contract.lender); + to = wallet_field_bytes(&tx.unsigned_loan_contract.borrower); + let (asset, series) = nft_asset_parts(&tx.unsigned_loan_contract.collateral); + received_asset = padded_asset_bytes(asset.trim()); + received_series = series; + received_value = tx.unsigned_loan_contract.collateral_amount; + ACTION_LOAN_ISSUED + } else { + return None; + } + } + 8 => { + // Loan payments affect NFT history only when the loaned asset + // in the original contract is this NFT. + let tx = ContractPaymentTransaction::from_bytes(txtype, body) + .await + .ok()?; + let contract_hash = decode(&tx.unsigned_contract_payment.contract_hash).ok()?; + let RpcResponse::Binary(contract_bytes) = request_transaction_by_txid(db, contract_hash).await; + let contract = LoanContractTransaction::from_bytes(7, &contract_bytes) + .await + .ok()?; + if contract.unsigned_loan_contract.loan_coin != asset_name { + return None; + } + from = wallet_field_bytes(&tx.unsigned_contract_payment.address); + to = wallet_field_bytes(&contract.unsigned_loan_contract.lender); + ACTION_LOAN_PAYMENT + } + 9 => { + // Collateral claims affect NFT history only when the original + // contract used this NFT as collateral. + let tx = CollateralClaimTransaction::from_bytes(txtype, body) + .await + .ok()?; + let contract_hash = decode(&tx.unsigned_collateral_claim.contract_hash).ok()?; + let RpcResponse::Binary(contract_bytes) = request_transaction_by_txid(db, contract_hash).await; + let contract = LoanContractTransaction::from_bytes(7, &contract_bytes) + .await + .ok()?; + if contract.unsigned_loan_contract.collateral != asset_name { + return None; + } + to = wallet_field_bytes(&tx.unsigned_collateral_claim.address); + ACTION_COLLATERAL_CLAIMED + } + 10 => { + // Burns remove the NFT from circulation and record the burner + // as the source wallet. + let tx = crate::blocks::burn::BurnTransaction::from_bytes(txtype, body) + .await + .ok()?; + let burned_asset = nft_asset_name(&tx.unsigned_burn.coin, tx.unsigned_burn.nft_series); + if burned_asset != asset_name { + return None; + } + from = wallet_field_bytes(&tx.unsigned_burn.address); + ACTION_BURNED + } + _ => return None, + }; + + // History entry layout: txid, block, tx type, action, from, to, + // received asset, received series, and received value. + let mut entry = Vec::with_capacity(HISTORY_ENTRY_SIZE); + entry.extend_from_slice(txid_bytes); + entry.extend_from_slice(&block.to_le_bytes()); + entry.push(txtype); + entry.push(action); + entry.extend_from_slice(&from); + entry.extend_from_slice(&to); + entry.extend_from_slice(&received_asset); + entry.extend_from_slice(&received_series.to_le_bytes()); + entry.extend_from_slice(&received_value.to_le_bytes()); + Some(entry) +} + +pub async fn lookup_nft_details(db: &Db, nft_name: String, item_number: u32) -> RpcResponse { + // Build the fixed binary NFT-details response from the registry, + // origin transaction, current holder, and expanded NFT history. + let Some(asset_name) = find_matching_nft_asset(db, &nft_name, item_number).await else { + return RpcResponse::Binary(b"error: NFT not found".to_vec()); + }; + + let (stored_name, stored_series) = nft_asset_parts(&asset_name); + let Some(genesis_bytes) = find_nft_origin(db, &asset_name, &stored_name, stored_series).await + else { + return RpcResponse::Binary(b"error: NFT genesis not found".to_vec()); + }; + + let RpcResponse::Binary(genesis_response) = request_transaction_by_txid(db, genesis_bytes.clone()).await; + if genesis_response.is_empty() || genesis_response[0] != 4 { + return RpcResponse::Binary(b"error: NFT genesis transaction not found".to_vec()); + } + + let genesis_tx = match CreateNftTransaction::from_bytes(4, &genesis_response[1..]).await { + Ok(tx) => tx, + Err(_) => return RpcResponse::Binary(b"error: Failed to parse NFT genesis".to_vec()), + }; + + let creator_bytes = + match Wallet::short_address_to_bytes(&genesis_tx.unsigned_create_nft.creator) { + Some(bytes) => bytes, + None => return RpcResponse::Binary(b"error: Failed to encode NFT creator".to_vec()), + }; + let ipfs_bytes = genesis_tx.unsigned_create_nft.item_ipfs.as_bytes().to_vec(); + let current_holder = find_current_holder(&asset_name).await; + let current_holder_bytes = if current_holder.is_empty() { + empty_wallet_bytes().to_vec() + } else { + match Wallet::short_address_to_bytes(¤t_holder) { + Some(bytes) => bytes, + None => empty_wallet_bytes().to_vec(), + } + }; + + let history_tree = match db.open_tree("nft_history") { + Ok(tree) => tree, + Err(_) => return RpcResponse::Binary(b"error: Failed to open NFT history".to_vec()), + }; + let mut history_bytes = history_tree + .get(asset_name.as_bytes()) + .ok() + .flatten() + .map(|bytes| bytes.to_vec()) + .unwrap_or_default(); + if history_bytes.is_empty() { + history_bytes.extend_from_slice(&genesis_bytes); + } + + let mut parsed_entries = Vec::new(); + for chunk in history_bytes.chunks(32) { + if chunk.len() != 32 { + continue; + } + // Each 32-byte txid in the NFT history tree is expanded into the + // fixed binary history entry layout used by the RPC response. + if let Some(entry) = build_history_entry(db, &asset_name, chunk).await { + parsed_entries.extend_from_slice(&entry); + } + } + + let history_count = (parsed_entries.len() / HISTORY_ENTRY_SIZE) as u32; + // Response layout: asset name, series, genesis txid, creator, IPFS + // field, current holder, history count, then history entries. + let mut response = Vec::with_capacity( + 15 + 4 + + 32 + + Wallet::SHORT_ADDRESS_BYTES_LENGTH + + 100 + + Wallet::SHORT_ADDRESS_BYTES_LENGTH + + 4 + + parsed_entries.len(), + ); + response.extend_from_slice(&padded_asset_bytes(stored_name.trim())); + response.extend_from_slice(&stored_series.to_le_bytes()); + response.extend_from_slice(&genesis_bytes); + response.extend_from_slice(&creator_bytes); + response.extend_from_slice(&ipfs_bytes); + response.extend_from_slice(¤t_holder_bytes); + response.extend_from_slice(&history_count.to_le_bytes()); + response.extend_from_slice(&parsed_entries); + + RpcResponse::Binary(response) +} diff --git a/src/rpc/commands/random_node.rs b/src/rpc/commands/random_node.rs new file mode 100644 index 0000000..d2fd417 --- /dev/null +++ b/src/rpc/commands/random_node.rs @@ -0,0 +1,30 @@ +use crate::records::memory::connections::CONNECTIONS; +use crate::rpc::responses::RpcResponse; + +pub async fn request_node(connections_key: &str) -> RpcResponse { + // Return one random node endpoint from the in-memory connection set + // so callers can bootstrap peer discovery from a live connection. + let result = { + let connections_lock = CONNECTIONS.read().await; + if let Some(connection) = connections_lock.as_ref() { + connection.get_random_connection(Some(connections_key)) + } else { + None + } + }; + + match result { + Some((mut ip, port)) => { + let port_bytes = port.to_le_bytes(); + ip.extend(port_bytes); + RpcResponse::Binary(ip) + } + None => { + let msg = "Error: No Connections Found" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } + } +} diff --git a/src/rpc/commands/receive_torrent.rs b/src/rpc/commands/receive_torrent.rs new file mode 100644 index 0000000..f4a6298 --- /dev/null +++ b/src/rpc/commands/receive_torrent.rs @@ -0,0 +1,339 @@ +use crate::common::check_genesis::genesis_checkup; +use crate::common::skein::skein_128_hash_bytes; +use crate::miner::flag::{is_reorganizing_mode, is_syncing_mode}; +use crate::orphans::structs::OrphanCheckup2; +use crate::orphans::sync_check::sync_checkup; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::response_channels::Command; +use crate::rpc::read_bytes_from_stream; +use crate::rpc::responses::RpcResponse; +use crate::rpc::server::flood_protection::MAX_TORRENT_METADATA_BYTES; +use crate::startup::remote_height::request_remote_height; +use crate::torrent::structs::Torrent; +use crate::torrent::create_metadata::broadcast_new_torrent_to_peers; +use crate::torrent::torrenting_system::torrent_requests::{setup_download_for_torrent, stage_and_verify_torrent}; +use crate::torrent::torrenting_system::torrent_cache::{has_recent_torrent, remember_recent_torrent}; +use crate::log::{error, warn}; +use crate::sled::Db; +use crate::Arc; +use crate::AtomicBool; +use crate::AtomicOrdering; +use crate::lazy_static; +use crate::Mutex; + +lazy_static! { + static ref ORPHAN_CHECK_RUNNING: AtomicBool = AtomicBool::new(false); +} + +pub fn should_trigger_orphan_check(error: &str) -> bool { + // These errors mean the incoming torrent may belong to a competing + // branch, so a targeted orphan check is worth attempting. + error.contains("Incorrect previous_block_hash.") + || error.contains("Difficulty mismatch with the blockchain data.") + || error.contains("Incoming block is no longer the next expected height.") + || error.contains("Error opening file ./testnet_blocks/") +} + +async fn next_expected_height(db: &Db) -> u32 { + // Normalize the current chain height into the next block height the + // node expects to accept from the network. + let local_height = get_height(db); + if local_height > 0 || genesis_checkup().await { + local_height + 1 + } else { + local_height + } +} + +fn within_orphan_window(local_height: u32, incoming_height: u32) -> bool { + // Keep automatic orphan correction limited to nearby heights so a + // badly stale torrent does not trigger expensive recovery work. + local_height.abs_diff(incoming_height) <= 10 +} + +pub async fn trigger_orphan_check( + reason: &str, + incoming_height: u32, + stream: Arc>, + db: &Db, + wallet_key: &str, + map: Arc>, + connections_key: String, +) { + if is_syncing_mode() { + return; + } + if ORPHAN_CHECK_RUNNING + .compare_exchange(false, true, AtomicOrdering::SeqCst, AtomicOrdering::SeqCst) + .is_err() + { + warn!( + "[broadcast] orphan check already running, skipping duplicate trigger: reason={reason} incoming_height={incoming_height}" + ); + return; + } + + // Refresh the peer's advertised height before running orphan logic + // so the sync check uses the best remote view available. + let local_height = get_height(db); + let remote_height = + match request_remote_height(stream.clone(), map.clone(), connections_key.clone()).await { + Ok(height) => height.max(incoming_height), + Err(err) => { + warn!("[broadcast] failed to refresh remote height before orphan check: {err}"); + incoming_height + } + }; + warn!( + "[broadcast] triggering orphan check: reason={reason} local_height={local_height} remote_height={remote_height}" + ); + let orphan_checkup_params = OrphanCheckup2 { + stream, + db: db.clone(), + local_height, + remote_height, + map, + node_syncing: false, + connections_key, + }; + match sync_checkup(orphan_checkup_params, wallet_key).await { + Ok(()) => {} + Err(err) => error!("[broadcast] orphan check error: {err}"), + }; + ORPHAN_CHECK_RUNNING.store(false, AtomicOrdering::SeqCst); +} + +pub enum TorrentSubmissionOutcome { + Accepted(RpcResponse), + Rejected(RpcResponse), + TriggerOrphan { + reason: String, + incoming_height: u32, + response: RpcResponse, + }, +} + +pub async fn torrent_submission( + height: u32, + torrent_bytes: Vec, + stream: Arc>, + db: &Db, + wallet_key: &str, + map: Arc>, + connections_key: String, +) -> TorrentSubmissionOutcome { + let expected_height = next_expected_height(db).await; + let local_height = get_height(db); + let syncing = is_syncing_mode(); + let reorganizing = is_reorganizing_mode(); + let process_now = !syncing && !reorganizing && height == expected_height; + + // The sender receives an acknowledgement for staging even when the + // torrent cannot be processed immediately. + let staged_response = if syncing { + RpcResponse::Binary("Torrent staged while syncing.".as_bytes().to_vec()) + } else if reorganizing { + RpcResponse::Binary("Torrent staged while reorganizing.".as_bytes().to_vec()) + } else if height == expected_height { + RpcResponse::Binary("Torrent accepted and saved.".as_bytes().to_vec()) + } else { + RpcResponse::Binary("Torrent staged for later processing.".as_bytes().to_vec()) + }; + + let torrent = match Torrent::from_bytes(&torrent_bytes).await { + Ok(torrent) => torrent, + Err(_) => { + warn!("[broadcast] torrent parse failed: height={height}"); + let msg = "Torrent rejected.".to_string().as_bytes().to_vec(); + return TorrentSubmissionOutcome::Rejected(RpcResponse::Binary(msg)); + } + }; + let torrent_hash = skein_128_hash_bytes(&torrent_bytes); + if has_recent_torrent(&torrent_hash, height).await { + // Recent-torrent cache prevents rebroadcast loops from repeatedly + // staging and verifying the same metadata. + let msg = "Torrent already seen.".to_string().as_bytes().to_vec(); + return TorrentSubmissionOutcome::Rejected(RpcResponse::Binary(msg)); + } + + match stage_and_verify_torrent(height, db, torrent, wallet_key, process_now).await + { + Ok(stage_result) => { + let _ = remember_recent_torrent(&torrent_hash, height).await; + if let Some((torrent, staged_path)) = stage_result { + // Accepted staged torrents start the piece-download flow in + // the background so the RPC reply can return immediately. + let stream_clone = stream.clone(); + let db_clone = db.clone(); + let wallet_key_clone = wallet_key.to_string(); + let map_clone = map.clone(); + let map_for_download = map.clone(); + let map_for_broadcast = map.clone(); + let connections_key_clone = connections_key.clone(); + let torrent_bytes_clone = torrent_bytes.clone(); + + tokio::spawn(async move { + match setup_download_for_torrent( + height, + torrent, + staged_path, + false, + db_clone.clone(), + map_for_download, + ) + .await + { + Ok(()) => { + // Only rebroadcast after the full block has + // downloaded, verified and saved locally, so + // peers that hear about it can request pieces + // from a node that actually has the block. + broadcast_new_torrent_to_peers( + height, + &torrent_bytes_clone, + map_for_broadcast, + ) + .await; + } + Err(e) => { + error!("[broadcast] setup_download error: height={height} err={e}"); + if should_trigger_orphan_check(&e) { + trigger_orphan_check( + &e, + height, + stream_clone, + &db_clone, + &wallet_key_clone, + map_clone, + connections_key_clone, + ) + .await; + } + } + } + }); + } + } + Err(err) => { + warn!("[broadcast] torrent rejected: height={height} err={err}"); + let msg = "Torrent rejected.".to_string().as_bytes().to_vec(); + if should_trigger_orphan_check(&err) { + return TorrentSubmissionOutcome::TriggerOrphan { + reason: err, + incoming_height: height, + response: RpcResponse::Binary(msg), + }; + } + return TorrentSubmissionOutcome::Rejected(RpcResponse::Binary(msg)); + } + } + + if height != expected_height { + if height < expected_height { + let genesis_exists = genesis_checkup().await; + if height > 0 && within_orphan_window(local_height, height) { + // Nearby behind-tip torrents may indicate a competing + // branch, so trigger orphan correction instead of ignoring. + warn!("[broadcast] incoming height {height} is below expected next height {expected_height}, but within orphan window of local height {local_height}, triggering orphan check"); + return TorrentSubmissionOutcome::TriggerOrphan { + reason: "incoming height behind tip within orphan window".to_string(), + incoming_height: height, + response: staged_response, + }; + } + + if height == 0 && genesis_exists { + } else { + // Far-stale torrents are left staged for cleanup/replay + // without launching expensive orphan work. + warn!("[broadcast] incoming height {height} is stale; expected next height is {expected_height}, leaving staged for replay/cleanup"); + } + return TorrentSubmissionOutcome::Accepted(staged_response); + } else { + // Ahead-of-tip torrents mean we are missing at least one + // earlier height and need orphan/sync logic to evaluate it. + warn!("[broadcast] incoming height {height} is ahead of expected next height {expected_height}, triggering orphan check"); + return TorrentSubmissionOutcome::TriggerOrphan { + reason: "incoming height mismatch".to_string(), + incoming_height: height, + response: staged_response, + }; + } + } + + TorrentSubmissionOutcome::Accepted(staged_response) +} + +pub async fn receive_torrent( + connections_key: &str, + stream: Arc>, + db: &Db, + wallet_key: &str, + map: Arc>, +) -> Result<(u32, RpcResponse), String> { + let (uid, _) = + read_bytes_from_stream::read_uid_from_stream(connections_key, stream.clone()).await?; + let size = read_bytes_from_stream::read_u32_from_stream(connections_key, stream.clone()).await?; + + // The size includes the block-height field, so the remaining bytes + // are the torrent metadata that will be parsed and staged. + let torrent_data_size_u32 = size + .checked_sub(4) + .ok_or_else(|| "error: Invalid torrent submission size".to_string())?; + let torrent_data_size = usize::try_from(torrent_data_size_u32) + .map_err(|_| "error: Torrent submission size is too large".to_string())?; + if torrent_data_size > MAX_TORRENT_METADATA_BYTES { + return Err("error: Torrent submission payload too large".to_string()); + } + + let block_number = + read_bytes_from_stream::read_u32_from_stream(connections_key, stream.clone()).await?; + let torrent_bytes = read_bytes_from_stream::read_usize_from_stream( + connections_key, + torrent_data_size, + stream.clone(), + ) + .await?; + + let outcome = torrent_submission( + block_number, + torrent_bytes, + stream.clone(), + db, + wallet_key, + map.clone(), + connections_key.to_string(), + ) + .await; + + let result = match outcome { + TorrentSubmissionOutcome::Accepted(response) => response, + TorrentSubmissionOutcome::Rejected(response) => response, + TorrentSubmissionOutcome::TriggerOrphan { + reason, + incoming_height, + response, + } => { + let stream_clone = stream.clone(); + let db_clone = db.clone(); + let wallet_key_clone = wallet_key.to_string(); + let map_clone = map.clone(); + let connections_key_clone = connections_key.to_string(); + tokio::spawn(async move { + trigger_orphan_check( + &reason, + incoming_height, + stream_clone, + &db_clone, + &wallet_key_clone, + map_clone, + connections_key_clone, + ) + .await; + }); + response + } + }; + + Ok((uid, result)) +} diff --git a/src/rpc/commands/request_valid_nodes.rs b/src/rpc/commands/request_valid_nodes.rs new file mode 100644 index 0000000..6d9bfe5 --- /dev/null +++ b/src/rpc/commands/request_valid_nodes.rs @@ -0,0 +1,6 @@ +use crate::records::memory::network_mapping::NodeInfo; +use crate::rpc::responses::RpcResponse; + +pub async fn request_valid_nodes() -> RpcResponse { + NodeInfo::request_valid_nodes().await +} diff --git a/src/rpc/commands/route_reply.rs b/src/rpc/commands/route_reply.rs new file mode 100644 index 0000000..9ccd7cd --- /dev/null +++ b/src/rpc/commands/route_reply.rs @@ -0,0 +1,73 @@ +use crate::records::memory::enums::ClientType; +use crate::records::memory::response_channels::{delete_entry, get_entry, is_retired_entry, Command}; +use crate::rpc::commands::bad_rpc_call; +use crate::rpc::command_maps::MAX_RPC_REPLY_BYTES; +use crate::rpc::read_bytes_from_stream; +use crate::log::warn; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; + +pub async fn route_reply( + connections_key: &str, + stream_locked: Arc>, + db: &Db, + wallet_key: &str, + map: Arc>, + ip: &str, + client_type: ClientType, +) -> Result<(), String> { + // Replies are command 255 packets: UID, payload length, then the + // response bytes for the task waiting on that UID. + let (_, uid) = + read_bytes_from_stream::read_uid_from_stream(connections_key, stream_locked.clone()) + .await?; + let message_length = + read_bytes_from_stream::read_u32_from_stream(connections_key, stream_locked.clone()) + .await? as usize; + if message_length > MAX_RPC_REPLY_BYTES { + bad_rpc_call::record(ip, client_type, db, wallet_key).await; + return Err(format!( + "error: RPC reply payload too large: len={message_length} max={MAX_RPC_REPLY_BYTES}" + )); + } + let tx_option = get_entry(map.clone(), uid).await; + + if let Some(tx) = tx_option { + // Replies are payload-only after the UID and length prefix; the + // waiting request task already knows how to interpret the bytes. + let buffer = read_bytes_from_stream::read_usize_from_stream( + connections_key, + message_length, + stream_locked.clone(), + ) + .await?; + if tx.send(buffer).await.is_err() { + warn!( + "[rpc] reply receiver dropped before payload delivery: {uid:?}" + ); + } + + delete_entry(map, uid).await; + return Ok(()); + } + + // Unknown replies usually mean a stale or forged UID. Drain the + // payload so the stream remains aligned for future commands. + bad_rpc_call::record(ip, client_type, db, wallet_key).await; + let retired = is_retired_entry(map.clone(), uid).await; + if retired { + warn!("[rpc] late reply arrived for retired uid: {uid:?}"); + } else { + warn!("[rpc] reply arrived for unknown uid: {uid:?}"); + } + let _ = read_bytes_from_stream::read_usize_from_stream( + connections_key, + message_length, + stream_locked, + ) + .await; + + Ok(()) +} diff --git a/src/rpc/commands/structs.rs b/src/rpc/commands/structs.rs new file mode 100644 index 0000000..fcc17a4 --- /dev/null +++ b/src/rpc/commands/structs.rs @@ -0,0 +1,11 @@ +pub struct NetworkInfo { + pub version: u8, + pub network: String, + pub time: u32, + pub wallet_prefix: String, + pub height: u32, + pub next_block_difficulty: u64, + pub total_block_transactions: u32, + pub total_mempool_transactions: u32, + pub largest_tx_fee: u64, +} diff --git a/src/rpc/commands/time.rs b/src/rpc/commands/time.rs new file mode 100644 index 0000000..b626b25 --- /dev/null +++ b/src/rpc/commands/time.rs @@ -0,0 +1,9 @@ +use crate::rpc::responses::RpcResponse; +use crate::Utc; + +pub async fn request_time() -> RpcResponse { + // Expose the node's current UTC timestamp as a compact 4-byte payload. + let timestamp = Utc::now().timestamp() as u32; + let timestamp_bytes = timestamp.to_le_bytes().to_vec(); + RpcResponse::Binary(timestamp_bytes) +} diff --git a/src/rpc/commands/token_list.rs b/src/rpc/commands/token_list.rs new file mode 100644 index 0000000..578b10f --- /dev/null +++ b/src/rpc/commands/token_list.rs @@ -0,0 +1,47 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn get_tokens(db: &Db) -> RpcResponse { + // Serialize every token ticker plus its origin hash into the compact + // fixed-width list format used by token-list RPC responses. + let tree = db.open_tree("tokens").unwrap(); + let origin_tree = db.open_tree("token_origins").unwrap(); + + let mut serialized_tokens = Vec::new(); + + for result in tree.iter() { + match result { + Ok((key, _value)) => { + let token = binary_to_string(key.to_vec()).trim().to_string(); + if token.is_empty() { + continue; + } + + // The token table stores the live supply, while the + // origin table stores the creation txid sent with each row. + let hash = origin_tree + .get(&key) + .ok() + .flatten() + .map(|bytes| bytes.to_vec()) + .unwrap_or_default(); + + if hash.len() != 64 { + continue; + } + + // Each token-list row is 15 ticker bytes followed by the + // 32-byte origin transaction hash. + let mut padded_token = key.to_vec(); + if padded_token.len() < 15 { + padded_token.resize(15, 32); + } + serialized_tokens.extend_from_slice(&padded_token[..15]); + serialized_tokens.extend_from_slice(hash.as_slice()); + } + Err(_) => continue, + } + } + RpcResponse::Binary(serialized_tokens) +} diff --git a/src/rpc/commands/token_lookup.rs b/src/rpc/commands/token_lookup.rs new file mode 100644 index 0000000..12f59ef --- /dev/null +++ b/src/rpc/commands/token_lookup.rs @@ -0,0 +1,295 @@ +use crate::blocks::token::CreateTokenTransaction; +use crate::common::binary_conversions::binary_to_string; +use crate::records::balance_sheet::pathing::{balance_asset_segments, balance_root_path}; +use crate::records::balance_sheet::tokens_to_lower::strip_spaces_and_lowercase; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::{Db, Tree}; +use crate::{decode, encode}; +use crate::fs; +use crate::PathBuf; + +fn parse_token_supply(value: &[u8]) -> Option { + // Token supply may be stored either as raw bytes or as a decimal string. + if value.len() == 8 { + let mut buffer = [0u8; 8]; + buffer.copy_from_slice(value); + return Some(u64::from_le_bytes(buffer)); + } + + let value_str = binary_to_string(value.to_vec()); + value_str.trim().parse::().ok() +} + +fn count_wallets_holding(normalized_token: &str) -> u64 { + // Spread is computed by scanning address balance files for non-zero + // holdings of the requested token. + let mut count = 0_u64; + let root = balance_root_path(); + + let addresses = match fs::read_dir(&root) { + Ok(entries) => entries, + Err(_) => return 0, + }; + + for entry in addresses.flatten() { + let entry_path = entry.path(); + if !entry_path.is_dir() { + continue; + } + + // Balance files are sharded under hashed asset path segments, so + // reconstruct the expected token balance path for each address. + let mut balance_path: PathBuf = entry_path.clone(); + for segment in balance_asset_segments(normalized_token) { + balance_path.push(segment); + } + balance_path.push("wallet.bal"); + + let bytes = match fs::read(&balance_path) { + Ok(bytes) => bytes, + Err(_) => continue, + }; + + if bytes.len() < 8 { + continue; + } + + let mut buffer = [0u8; 8]; + buffer.copy_from_slice(&bytes[..8]); + if u64::from_le_bytes(buffer) > 0 { + count += 1; + } + } + + count +} + +async fn find_origin_hash( + db: &Db, + origins_tree: &Tree, + matched_ticker: &str, + normalized_query: &str, +) -> Option { + // Prefer the dedicated token-origins tree, but fall back to scanning + // saved txids if the origin mapping is missing. + if let Ok(Some(bytes)) = origins_tree.get(matched_ticker.as_bytes()) { + return Some(binary_to_string(bytes.to_vec())); + } + + let txid_tree = db.open_tree("txid").ok()?; + for entry in txid_tree.iter() { + let (txid_bytes, _) = match entry { + Ok(pair) => pair, + Err(_) => continue, + }; + + let RpcResponse::Binary(tx_bytes) = request_transaction_by_txid(db, txid_bytes.to_vec()).await; + + // The fallback only cares about create-token transactions because + // those define the origin hash for a token. + if tx_bytes.is_empty() || tx_bytes[0] != 3 { + continue; + } + + let contract = match CreateTokenTransaction::from_bytes(tx_bytes[0], &tx_bytes[1..]).await { + Ok(tx) => tx, + Err(_) => continue, + }; + + if strip_spaces_and_lowercase(&contract.unsigned_create_token.ticker) == normalized_query { + return Some(encode(&txid_bytes)); + } + } + + None +} + +fn find_matching_ticker( + tokens_tree: &Tree, + origins_tree: &Tree, + normalized_query: &str, +) -> Option { + // Prefer live token rows, but fall back to the origin registry so + // fully burned tokens can still be looked up historically. + for entry in tokens_tree.iter() { + let (key, _) = match entry { + Ok(pair) => pair, + Err(_) => continue, + }; + + let stored_ticker = binary_to_string(key.to_vec()); + if strip_spaces_and_lowercase(&stored_ticker) == normalized_query { + return Some(stored_ticker); + } + } + + for entry in origins_tree.iter() { + let (key, _) = match entry { + Ok(pair) => pair, + Err(_) => continue, + }; + + let stored_ticker = binary_to_string(key.to_vec()); + if strip_spaces_and_lowercase(&stored_ticker) == normalized_query { + return Some(stored_ticker); + } + } + + None +} + +async fn collect_token_history( + db: &Db, + ticker: &str, + origin_hash_bytes: &[u8], +) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) { + // Token lookup expands saved token history into separate issue and + // burn txid lists so the current supply can be audited from history. + let history_tree = match db.open_tree("token_history") { + Ok(tree) => tree, + Err(_) => return (Vec::new(), Vec::new()), + }; + + let mut issued_hashes = Vec::new(); + let mut burned_hashes = Vec::new(); + let Some(history_bytes) = history_tree + .get(ticker.as_bytes()) + .ok() + .flatten() + .map(|bytes| bytes.to_vec()) + else { + return (issued_hashes, burned_hashes); + }; + + for chunk in history_bytes.chunks(32) { + if chunk.len() != 32 || chunk == origin_hash_bytes { + continue; + } + + let RpcResponse::Binary(tx_bytes) = request_transaction_by_txid(db, chunk.to_vec()).await; + + if tx_bytes.is_empty() { + continue; + } + + let mut hash = [0u8; 32]; + hash.copy_from_slice(chunk); + + // Token history stores txids only; inspect each transaction type + // to divide the history into issue and burn lists. + match tx_bytes[0] { + 10 => burned_hashes.push(hash), + 11 => issued_hashes.push(hash), + _ => {} + } + } + + (issued_hashes, burned_hashes) +} + +pub async fn lookup_token_details(db: &Db, token_name: String) -> RpcResponse { + // Build the token-details response from token supply, origin txid, + // creator wallet, holder spread, and related issuance and burn txids. + let normalized_query = strip_spaces_and_lowercase(&token_name); + if normalized_query.is_empty() { + return RpcResponse::Binary(b"error: Token name required".to_vec()); + } + + let tokens_tree = db.open_tree("tokens").unwrap(); + let origins_tree = db.open_tree("token_origins").unwrap(); + let limits_tree = db.open_tree("token_limits").unwrap(); + + let matched_ticker = match find_matching_ticker(&tokens_tree, &origins_tree, &normalized_query) + { + Some(ticker) => ticker, + None => return RpcResponse::Binary(b"error: Token not found".to_vec()), + }; + + // Current supply comes from the live token registry, but fully + // burned tokens still resolve historically through the origin tree. + let token_count = tokens_tree + .get(matched_ticker.as_bytes()) + .ok() + .flatten() + .and_then(|value| parse_token_supply(value.as_ref())); + + let origin_hash = + match find_origin_hash(db, &origins_tree, &matched_ticker, &normalized_query).await { + Some(hash) => hash, + None => return RpcResponse::Binary(b"error: Token origin not found".to_vec()), + }; + + let RpcResponse::Binary(tx_bytes) = request_transaction_by_txid(db, decode(&origin_hash).unwrap_or_default()).await; + + if tx_bytes.is_empty() { + return RpcResponse::Binary(b"error: Token contract transaction not found".to_vec()); + } + + let txtype = tx_bytes[0]; + let body = &tx_bytes[1..]; + let contract = match CreateTokenTransaction::from_bytes(txtype, body).await { + Ok(tx) => tx, + Err(_) => { + return RpcResponse::Binary(b"error: Failed to parse token contract".to_vec()); + } + }; + + let spread = count_wallets_holding(&normalized_query); + let token_count = token_count.unwrap_or(0); + let contract_hash_bytes = match decode(&origin_hash) { + Ok(bytes) if bytes.len() == 32 => bytes, + _ => return RpcResponse::Binary(b"error: Failed to decode token origin".to_vec()), + }; + + let creator_bytes = + match Wallet::short_address_to_bytes(&contract.unsigned_create_token.creator) { + Some(bytes) => bytes, + None => return RpcResponse::Binary(b"error: Failed to encode token creator".to_vec()), + }; + let ticker_bytes = contract.unsigned_create_token.ticker.as_bytes().to_vec(); + if ticker_bytes.len() != 15 || creator_bytes.len() != Wallet::SHORT_ADDRESS_BYTES_LENGTH { + return RpcResponse::Binary(b"error: Invalid token lookup response size".to_vec()); + } + + let hard_limit = limits_tree + .get(matched_ticker.as_bytes()) + .ok() + .flatten() + .and_then(|bytes| bytes.first().copied()) + .unwrap_or(contract.unsigned_create_token.hard_limit); + + let (issued_hashes, burned_hashes) = + collect_token_history(db, &matched_ticker, &contract_hash_bytes).await; + + // Response layout: ticker, origin hash, creator, live supply, holder + // spread, hard-limit flag, issue-count/list, burn-count/list. + let mut response = Vec::with_capacity( + 15 + 32 + + Wallet::SHORT_ADDRESS_BYTES_LENGTH + + 8 + + 4 + + 1 + + 4 + + (issued_hashes.len() * 32) + + 4 + + (burned_hashes.len() * 32), + ); + response.extend_from_slice(&ticker_bytes); + response.extend_from_slice(&contract_hash_bytes); + response.extend_from_slice(&creator_bytes); + response.extend_from_slice(&token_count.to_le_bytes()); + response.extend_from_slice(&(spread as u32).to_le_bytes()); + response.push(hard_limit); + response.extend_from_slice(&(issued_hashes.len() as u32).to_le_bytes()); + for hash in issued_hashes { + response.extend_from_slice(&hash); + } + response.extend_from_slice(&(burned_hashes.len() as u32).to_le_bytes()); + for hash in burned_hashes { + response.extend_from_slice(&hash); + } + + RpcResponse::Binary(response) +} diff --git a/src/rpc/commands/torrent.rs b/src/rpc/commands/torrent.rs new file mode 100644 index 0000000..bcab1e8 --- /dev/null +++ b/src/rpc/commands/torrent.rs @@ -0,0 +1,211 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::skein::skein_128_hash_bytes; +use crate::rpc::responses::RpcResponse; +use crate::torrent::structs::Torrent; +use crate::sled::Db; +use crate::{AsyncReadExt, AsyncSeekExt, SeekFrom}; +use crate::{decode, encode}; +use crate::File; +use crate::Path; +use crate::PathBuf; + +fn remove_block_pieces_from_db(db: &Db, block_number: u32, info_hash: &str) { + // When the canonical torrent exists, temporary cached pieces for that + // block are no longer needed and can be dropped from the piece cache. + let Ok(tree) = db.open_tree("block_pieces") else { + return; + }; + let prefix = format!("{block_number}-{info_hash}-"); + let iter = tree.range(prefix.as_bytes()..); + + for (key, _value) in iter.flatten() { + if !key.starts_with(prefix.as_bytes()) { + break; + } + let _ = tree.remove(key); + } +} + +pub async fn request_block_piece( + db: &Db, + block_number: u32, + requested_piece: u8, + requested_info_hash: u128, +) -> RpcResponse { + // Serve a block piece either from the temporary cached-piece tree or + // by slicing it directly from the canonical block file using torrent metadata. + let tree = match db.open_tree("block_pieces") { + Ok(tree) => tree, + Err(err) => { + let msg = format!("error: Failed to open block_pieces tree: {err}") + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + }; + let requested_info_hash_hex = encode(requested_info_hash.to_le_bytes()); + let key = format!( + "{block_number}-{requested_info_hash_hex}-{requested_piece}" + ); + + let ( + _network_name, + _padded_base_coin, + block_ext, + torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let block_filename = PathBuf::from(&block_path) + .join(format!("{block_number}.{block_ext}")) + .to_string_lossy() + .into_owned(); + let torrent_filename = PathBuf::from(&torrent_path) + .join(format!("{block_number}.torrent")) + .to_string_lossy() + .into_owned(); + + let file_exists = Path::new(&torrent_filename).exists(); + let prefix = format!("{block_number}-{requested_info_hash_hex}-"); + let pieces_exist = tree.range(prefix.as_bytes()..).peekable().peek().is_some(); + + // Once the canonical torrent exists, cached block pieces for the same + // height can be purged so the canonical file becomes the source of truth. + if file_exists && pieces_exist { + remove_block_pieces_from_db(db, block_number, &requested_info_hash_hex); + } + + if let Some(piece_data) = tree.get(&key).ok().and_then(|result| result) { + // Cached pieces are used for in-progress downloads before the + // canonical torrent file is available locally. + RpcResponse::Binary(piece_data.to_vec()) + } else if let Ok(mut torrent_file) = File::open(&torrent_filename).await { + let mut torrent_contents = Vec::new(); + if let Err(err) = torrent_file.read_to_end(&mut torrent_contents).await { + let msg = format!("error: Failed to read torrent file: {err}") + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + let torrent = match Torrent::from_bytes(&torrent_contents).await { + Ok(torrent) => torrent, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + return RpcResponse::Binary(msg); + } + }; + let torrent_info_hash_bytes = match decode(&torrent.info.info_hash) { + Ok(bytes) => bytes, + Err(err) => { + let msg = format!("error: Invalid torrent info hash: {err}") + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + }; + let torrent_info_hash = match <[u8; 16]>::try_from(torrent_info_hash_bytes.as_slice()) { + Ok(bytes) => u128::from_le_bytes(bytes), + Err(_) => { + let msg = "error: Invalid torrent info hash length" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + }; + if torrent_info_hash != requested_info_hash { + // A peer can ask for a specific candidate; reject the request + // if our canonical torrent is for a different info hash. + let msg = "error: Requested candidate not found" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + let pieces = torrent.info.pieces; + + // Use the torrent piece map to locate the expected hash, read the + // matching byte range from the block file, and verify the piece hash. + if let Some(piece_object) = pieces + .iter() + .find(|piece| piece.contains_key(&requested_piece)) + { + if let Some(expected_hash) = piece_object.get(&requested_piece) { + let piece_length = torrent.info.piece_length as u64; + if let Ok(mut block_file) = File::open(&block_filename).await { + let file_size = match block_file.metadata().await { + Ok(meta) => meta.len(), + Err(_) => { + let msg = "error: Error reading block file metadata" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + }; + + let start_byte = (requested_piece as u64 - 1) * piece_length; + if start_byte >= file_size { + let msg = "error: Requested piece is out of bounds" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + + // The last piece may be shorter than the normal + // torrent piece length, so cap the read at EOF. + let piece_size = std::cmp::min(piece_length, file_size - start_byte) as usize; + let mut piece_data = vec![0u8; piece_size]; + + if block_file.seek(SeekFrom::Start(start_byte)).await.is_err() { + let msg = "error: Error seeking block file" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + + if block_file.read_exact(&mut piece_data).await.is_err() { + let msg = "error: Error reading block file contents" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + + let calculated_hash = skein_128_hash_bytes(&piece_data); + // Never serve a block slice that does not match the + // piece hash advertised in the torrent metadata. + if &calculated_hash == expected_hash { + return RpcResponse::Binary(piece_data); + } else { + let msg = "error: Hash mismatch".to_string().as_bytes().to_vec(); + return RpcResponse::Binary(msg); + } + } else { + let msg = "error: Block not found".to_string().as_bytes().to_vec(); + return RpcResponse::Binary(msg); + } + } else { + let msg = "error: Expected hash not found" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + } else { + let msg = "error: Requested piece is out of bounds" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + } else { + let msg = "error: piece not found".to_string().as_bytes().to_vec(); + return RpcResponse::Binary(msg); + } +} diff --git a/src/rpc/commands/torrent_by_block.rs b/src/rpc/commands/torrent_by_block.rs new file mode 100644 index 0000000..5e77771 --- /dev/null +++ b/src/rpc/commands/torrent_by_block.rs @@ -0,0 +1,41 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::rpc::responses::RpcResponse; +use crate::Path; +use crate::read; + +pub async fn request_block_torrent(height: &u32) -> RpcResponse { + // Torrent files live alongside blocks under a predictable + // `.torrent` naming convention. + let filename = format!("{height}.torrent"); + let ( + _network_name, + _padded_base_coin, + _block_ext, + torrent_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let file_path = Path::new(&torrent_path).join(&filename); + + if !file_path.exists() { + let msg = format!("error: Block {height} not found") + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + + match read(&file_path).await { + Ok(binary_data) => RpcResponse::Binary(binary_data), + Err(_) => { + let msg = "error: Error reading torrent file" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } + } +} diff --git a/src/rpc/commands/torrent_candidates.rs b/src/rpc/commands/torrent_candidates.rs new file mode 100644 index 0000000..703ed82 --- /dev/null +++ b/src/rpc/commands/torrent_candidates.rs @@ -0,0 +1,64 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::orphans::snapshot_check::snapshot_height; +use crate::records::block_height::get_block_height::get_height; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::torrent::torrenting_system::save_torrent::{list_staged_torrents, read_staged_torrent}; +use crate::{read, Path}; + +async fn canonical_torrent_bytes(height: u32) -> Option> { + // Canonical torrents are the saved `.torrent` files that match + // blocks already accepted into the local chain. + let ( + _network_name, + _padded_base_coin, + _block_ext, + torrent_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let file_path = Path::new(&torrent_path).join(format!("{height}.torrent")); + read(&file_path).await.ok() +} + +pub async fn request_torrent_candidates(db: &Db) -> RpcResponse { + // Send both canonical and staged torrent files from the last saved + // snapshot onward so a freshly connected peer can fill its staging area. + let start_height = snapshot_height(db).await.unwrap_or(0); + let current_height = get_height(db); + let mut candidates = Vec::new(); + + for height in start_height..=current_height { + if let Some(torrent_bytes) = canonical_torrent_bytes(height).await { + candidates.push((height, torrent_bytes)); + } + } + + if let Ok(staged_torrents) = list_staged_torrents().await { + for (height, staged_path) in staged_torrents { + if height < start_height { + continue; + } + // Staged torrents may not yet be canonical on this node, but + // peers still need them when evaluating short-range reorgs. + if let Ok(torrent_bytes) = read_staged_torrent(&staged_path).await { + candidates.push((height, torrent_bytes)); + } + } + } + + // Response layout: candidate count, then repeated height, byte + // length, and raw torrent bytes. + let mut response = Vec::new(); + response.extend_from_slice(&(candidates.len() as u32).to_le_bytes()); + for (height, torrent_bytes) in candidates { + response.extend_from_slice(&height.to_le_bytes()); + response.extend_from_slice(&(torrent_bytes.len() as u32).to_le_bytes()); + response.extend_from_slice(&torrent_bytes); + } + + RpcResponse::Binary(response) +} diff --git a/src/rpc/commands/transaction_by_txid.rs b/src/rpc/commands/transaction_by_txid.rs new file mode 100644 index 0000000..f674888 --- /dev/null +++ b/src/rpc/commands/transaction_by_txid.rs @@ -0,0 +1,153 @@ +use crate::blocks::block::VRF_BLOCK_BYTES; +use crate::common::binary_conversions::binary_to_string; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::rpc::command_maps; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::{AsyncReadExt, AsyncSeekExt, SeekFrom}; +use crate::File; +use crate::io; +use crate::PathBuf; + +const HEADER_SIZE: u64 = VRF_BLOCK_BYTES as u64; + +pub async fn request_transaction_by_txid(db: &Db, txid: Vec) -> RpcResponse { + // Resolve the saved transaction bytes directly from the txid lookup + // tree and the referenced block file. + match lookup_transaction_location(db, txid).await { + Ok((_block, _position, block_filename)) => { + let bytes = calculate_offset(&block_filename, _position).await; + match bytes { + Some(vec) => RpcResponse::Binary(vec), + None => { + let msg = "error: Error parsing block".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(msg) => RpcResponse::Binary(msg.into_bytes()), + } +} + +pub async fn request_transaction_by_txid_with_block(db: &Db, txid: Vec) -> RpcResponse { + // Some callers need the block number alongside the raw transaction + // bytes, so this variant prefixes the payload with the block height. + match lookup_transaction_location(db, txid).await { + Ok((block, position, block_filename)) => { + let bytes = calculate_offset(&block_filename, position).await; + match bytes { + Some(vec) => { + let mut response = Vec::with_capacity(4 + vec.len()); + response.extend_from_slice(&(block as u32).to_le_bytes()); + response.extend_from_slice(&vec); + RpcResponse::Binary(response) + } + None => { + let msg = "error: Error parsing block".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(msg) => RpcResponse::Binary(msg.into_bytes()), + } +} + +async fn lookup_transaction_location(db: &Db, txid: Vec) -> Result<(u64, u32, String), String> { + // The txid tree stores `block:index`, which is enough to locate the + // transaction inside the saved block file on disk. + let tree = db.open_tree("txid").unwrap(); + let value = match tree.get(txid) { + Ok(Some(result)) => result.to_vec(), + Ok(None) => { + return Err("error: Key not found".to_string()); + } + Err(_) => { + return Err("error: Errpr retrieving value".to_string()); + } + }; + + let value_str = binary_to_string(value.to_vec()); + + // Stored txid locations are saved as ASCII `height:index`. + let parts: Vec<&str> = value_str.split(':').collect(); + + let block: u64 = parts[0].parse().unwrap_or_default(); + + let position: u32 = parts[1].parse().unwrap_or_default(); + + let ( + _network_name, + _padded_base_coin, + block_ext, + _torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + + let block_filename = PathBuf::from(block_path) + .join(format!("{block}.{block_ext}")) + .to_string_lossy() + .into_owned(); + Ok((block, position, block_filename)) +} + +async fn read_transaction_type(file_path: &str, position: u64) -> Option { + // Transaction offsets are located by repeatedly reading the type byte + // so the fixed encoded size for each saved transaction can be applied. + let mut file = match File::open(file_path).await { + Ok(file) => file, + Err(_) => return None, + }; + + file.seek(SeekFrom::Start(position)).await.ok()?; + + let mut transaction_type_byte = [0u8; 1]; + file.read_exact(&mut transaction_type_byte).await.ok()?; + + Some(transaction_type_byte[0]) +} + +async fn calculate_offset(file_path: &str, position: u32) -> Option> { + // Walk forward through the serialized block body until the requested + // transaction index is reached, then read exactly that transaction. + let mut total_bytes_to_skip: u64 = HEADER_SIZE; + + let mut current_position: u32 = 1; + + let mut transaction_type = read_transaction_type(file_path, HEADER_SIZE).await?; + + while current_position < position { + // Transaction bodies are fixed-size by type, so the type byte at + // each offset tells us how far to jump to reach the next record. + let size = command_maps::get_bytes(transaction_type) as u64; + + total_bytes_to_skip += size; + + transaction_type = read_transaction_type(file_path, total_bytes_to_skip).await?; + + current_position += 1; + } + + let size = command_maps::get_bytes(transaction_type) as u64; + + let mut file = match File::open(file_path).await { + Ok(file) => file, + Err(_) => { + return None; + } + }; + + file.seek(io::SeekFrom::Start(total_bytes_to_skip)) + .await + .ok()?; + + let mut transaction_bytes = vec![0u8; size as usize]; + file.read_exact(&mut transaction_bytes).await.ok()?; + + // Returned bytes include the transaction type byte at the front so + // callers can parse the payload without extra lookup state. + Some(transaction_bytes) +} diff --git a/src/rpc/commands/transactions_by_address.rs b/src/rpc/commands/transactions_by_address.rs new file mode 100644 index 0000000..1e420a4 --- /dev/null +++ b/src/rpc/commands/transactions_by_address.rs @@ -0,0 +1,7 @@ +use crate::records::memory::mempool::transactions_by_address; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn request_transactions_by_address(db: &Db, address: &str) -> RpcResponse { + transactions_by_address(db, address).await +} diff --git a/src/rpc/commands/tx_count.rs b/src/rpc/commands/tx_count.rs new file mode 100644 index 0000000..ed89f94 --- /dev/null +++ b/src/rpc/commands/tx_count.rs @@ -0,0 +1,150 @@ +use crate::blocks::block::VRF_BLOCK_BYTES; +use crate::common::binary_conversions::binary_to_string; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::types::{GENESIS_TYPE, REWARDS_TYPE, VANITY_ADDRESS_TYPE}; +use crate::rpc::command_maps; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::PathBuf; +use crate::{AsyncReadExt, AsyncSeekExt, File, SeekFrom}; + +const HEADER_SIZE: u64 = VRF_BLOCK_BYTES as u64; + +async fn lookup_transaction_location(db: &Db, txid: Vec) -> Result<(u64, u32, String), String> { + // The txid tree stores `block:index`, which is enough to locate the + // transaction inside the saved block file on disk. + let tree = db.open_tree("txid").unwrap(); + let value = match tree.get(txid) { + Ok(Some(result)) => result.to_vec(), + Ok(None) => { + return Err("error: Key not found".to_string()); + } + Err(_) => { + return Err("error: Error retrieving value".to_string()); + } + }; + + let value_str = binary_to_string(value); + // Stored txid locations are saved as ASCII `height:index`. + let parts: Vec<&str> = value_str.split(':').collect(); + + let block: u64 = parts[0].parse().unwrap_or_default(); + let position: u32 = parts[1].parse().unwrap_or_default(); + + let ( + _network_name, + _padded_base_coin, + block_ext, + _torrent_path, + _wallet_path, + block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let block_filename = PathBuf::from(block_path) + .join(format!("{block}.{block_ext}")) + .to_string_lossy() + .into_owned(); + Ok((block, position, block_filename)) +} + +async fn read_transaction_type(file_path: &str, position: u64) -> Option { + // Transaction offsets are located by repeatedly reading the type byte + // so the fixed encoded size for each saved transaction can be applied. + let mut file = File::open(file_path).await.ok()?; + file.seek(SeekFrom::Start(position)).await.ok()?; + + let mut transaction_type_byte = [0u8; 1]; + file.read_exact(&mut transaction_type_byte).await.ok()?; + + Some(transaction_type_byte[0]) +} + +async fn calculate_offset(file_path: &str, position: u32) -> Option> { + // Walk forward through the serialized block body until the requested + // transaction index is reached, then read exactly that transaction. + let mut total_bytes_to_skip: u64 = HEADER_SIZE; + let mut current_position: u32 = 1; + + let mut transaction_type = read_transaction_type(file_path, HEADER_SIZE).await?; + + while current_position < position { + // Transaction records are fixed-size by type, so the type byte + // determines how far to skip to reach the requested index. + let size = command_maps::get_bytes(transaction_type) as u64; + total_bytes_to_skip += size; + transaction_type = read_transaction_type(file_path, total_bytes_to_skip).await?; + current_position += 1; + } + + let size = command_maps::get_bytes(transaction_type) as u64; + let mut file = File::open(file_path).await.ok()?; + file.seek(SeekFrom::Start(total_bytes_to_skip)).await.ok()?; + + let mut transaction_bytes = vec![0u8; size as usize]; + file.read_exact(&mut transaction_bytes).await.ok()?; + + Some(transaction_bytes) +} + +fn reward_value(tx_bytes: &[u8]) -> u64 { + // Rewards are counted twice: total rewards and non-zero rewards. + if tx_bytes.len() < 13 { + return 0; + } + + let mut value_bytes = [0u8; 8]; + value_bytes.copy_from_slice(&tx_bytes[5..13]); + u64::from_le_bytes(value_bytes) +} + +pub async fn request_tx_count(db: &Db) -> RpcResponse { + // Count saved transactions by type directly from the txid index and + // block files so the response reflects the committed chain state. + let tree = db.open_tree("txid").unwrap(); + let mut totals = [0u64; (VANITY_ADDRESS_TYPE as usize) + 1]; + let mut non_zero = [0u64; (VANITY_ADDRESS_TYPE as usize) + 1]; + + for entry in tree.iter() { + let Ok((txid, _)) = entry else { + continue; + }; + + let Ok((_block, position, block_filename)) = + lookup_transaction_location(db, txid.to_vec()).await + else { + continue; + }; + + let Some(tx_bytes) = calculate_offset(&block_filename, position).await else { + continue; + }; + + let Some(&txtype) = tx_bytes.first() else { + continue; + }; + + if txtype == GENESIS_TYPE || txtype > VANITY_ADDRESS_TYPE { + continue; + } + + // Genesis is excluded from the public count table; every other + // known transaction type gets total and non-zero columns. + totals[txtype as usize] = totals[txtype as usize].saturating_add(1); + + if txtype == REWARDS_TYPE && reward_value(&tx_bytes) > 0 { + non_zero[txtype as usize] = non_zero[txtype as usize].saturating_add(1); + } + } + + let mut response = Vec::with_capacity(VANITY_ADDRESS_TYPE as usize * 17); + for txtype in 1u8..=VANITY_ADDRESS_TYPE { + // Response rows are type byte, total count, non-zero count. + response.push(txtype); + response.extend_from_slice(&totals[txtype as usize].to_le_bytes()); + response.extend_from_slice(&non_zero[txtype as usize].to_le_bytes()); + } + + RpcResponse::Binary(response) +} diff --git a/src/rpc/commands/tx_count_from_mempool.rs b/src/rpc/commands/tx_count_from_mempool.rs new file mode 100644 index 0000000..bc69c4e --- /dev/null +++ b/src/rpc/commands/tx_count_from_mempool.rs @@ -0,0 +1,6 @@ +use crate::records::memory::mempool::total_transactions; +use crate::rpc::responses::RpcResponse; + +pub async fn request_tx_count_from_mempool() -> RpcResponse { + total_transactions().await +} diff --git a/src/rpc/commands/tx_submit.rs b/src/rpc/commands/tx_submit.rs new file mode 100644 index 0000000..3b696b6 --- /dev/null +++ b/src/rpc/commands/tx_submit.rs @@ -0,0 +1,491 @@ +use crate::blocks::burn::BurnTransaction; +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::issue_token::IssueTokenTransaction; +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::blocks::marketing::MarketingTransaction; +use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::swap::SwapTransaction; +use crate::blocks::token::CreateTokenTransaction; +use crate::blocks::transfer::TransferTransaction; +use crate::blocks::vanity::VanityAddressTransaction; +use crate::common::types::{ + BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, + ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, SWAP_TYPE, TRANSFER_TYPE, VANITY_ADDRESS_TYPE, +}; +use crate::records::memory::connections::get_client_type_from_memory; +use crate::records::memory::enums::ClientType; +use crate::records::memory::response_channels::generate_uid; +use crate::rpc::command_maps::RPC_SUBMIT_TRANSACTION; +use crate::rpc::responses::RpcResponse; +use crate::torrent::torrenting_system::get_nodes::get_nodes_from_memory; +use crate::sled::Db; + +async fn broadcast_tx(tx_bytes: Vec) { + // Broadcast newly accepted mempool transactions only to miner peers, + // since those are the nodes that need transaction fan-out most. + let nodes = get_nodes_from_memory().await; + + for (key, stream) in nodes { + let client_type = get_client_type_from_memory(&key) + .await + .unwrap_or(ClientType::Miner); + if client_type != ClientType::Miner { + continue; + } + + let uid = generate_uid(); + let mut message = Vec::with_capacity(4 + tx_bytes.len()); + // Reuse the original submit-transaction wire shape when + // rebroadcasting to miner peers: command, UID, transaction bytes. + message.push(RPC_SUBMIT_TRANSACTION); + message.extend_from_slice(&uid); + message.extend_from_slice(&tx_bytes); + RpcResponse::send_raw(&stream, Some(&key), &message).await; + } +} + +pub async fn save_and_submit(txtype: u8, tx: Vec, db: &Db) -> RpcResponse { + // Decode, verify, add to the mempool, and then broadcast according to + // the declared transaction type. + // In each branch, a non-empty verifier response means the transaction + // already exists in the mempool and should not be rebroadcast. + match txtype { + TRANSFER_TYPE => match TransferTransaction::from_bytes(txtype, &tx).await { + Ok(transfer) => match transfer.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match transfer.to_bytes().await { + Ok(transfer_bytes) => match transfer.add_to_memory().await { + Ok(_) => { + broadcast_tx(transfer_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + CREATE_TOKEN_TYPE => match CreateTokenTransaction::from_bytes(txtype, &tx).await { + Ok(create_token) => match create_token.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match create_token.to_bytes().await { + Ok(token_bytes) => match create_token.add_to_memory().await { + Ok(_) => { + broadcast_tx(token_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + CREATE_NFT_TYPE => match CreateNftTransaction::from_bytes(txtype, &tx).await { + Ok(create_nft) => match create_nft.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match create_nft.to_bytes().await { + Ok(nft_bytes) => match create_nft.add_to_memory().await { + Ok(_) => { + broadcast_tx(nft_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + BURN_TYPE => match BurnTransaction::from_bytes(txtype, &tx).await { + Ok(burn) => match burn.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match burn.to_bytes().await { + Ok(burn_bytes) => match burn.add_to_memory().await { + Ok(_) => { + broadcast_tx(burn_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + ISSUE_TOKEN_TYPE => match IssueTokenTransaction::from_bytes(txtype, &tx).await { + Ok(issue_token) => match issue_token.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match issue_token.to_bytes().await { + Ok(issue_token_bytes) => match issue_token.add_to_memory().await { + Ok(_) => { + broadcast_tx(issue_token_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + MARKETING_TYPE => match MarketingTransaction::from_bytes(txtype, &tx).await { + Ok(marketing) => match marketing.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match marketing.to_bytes().await { + Ok(marketing_bytes) => match marketing.add_to_memory().await { + Ok(_) => { + broadcast_tx(marketing_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + SWAP_TYPE => match SwapTransaction::from_bytes(txtype, &tx).await { + Ok(swap) => match swap.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match swap.to_bytes().await { + Ok(swap_bytes) => match swap.add_to_memory().await { + Ok(_) => { + broadcast_tx(swap_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + LENDER_TYPE => match LoanContractTransaction::from_bytes(txtype, &tx).await { + Ok(loan) => match loan.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match loan.to_bytes().await { + Ok(loan_bytes) => match loan.add_to_memory().await { + Ok(_) => { + broadcast_tx(loan_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + BORROWER_TYPE => match ContractPaymentTransaction::from_bytes(txtype, &tx).await { + Ok(payment) => match payment.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match payment.to_bytes().await { + Ok(payment_bytes) => match payment.add_to_memory().await { + Ok(_) => { + broadcast_tx(payment_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + COLLATERAL_TYPE => match CollateralClaimTransaction::from_bytes(txtype, &tx).await { + Ok(collateral) => match collateral.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match collateral.to_bytes().await { + Ok(collateral_bytes) => match collateral.add_to_memory().await { + Ok(_) => { + broadcast_tx(collateral_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + VANITY_ADDRESS_TYPE => match VanityAddressTransaction::from_bytes(txtype, &tx).await { + Ok(vanity) => match vanity.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match vanity.to_bytes().await { + Ok(vanity_bytes) => match vanity.add_to_memory().await { + Ok(_) => { + broadcast_tx(vanity_bytes).await; + let msg = + "successful_broadcast: true".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + } + } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, + _ => { + let msg = "error: No such transaction type" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } + } +} diff --git a/src/rpc/commands/unblock_peer_ip.rs b/src/rpc/commands/unblock_peer_ip.rs new file mode 100644 index 0000000..28a9971 --- /dev/null +++ b/src/rpc/commands/unblock_peer_ip.rs @@ -0,0 +1,36 @@ +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::Db; + +pub async fn unblock_peer( + db: &Db, + ip: String, + signature: String, + wallet_key: String, +) -> RpcResponse { + // Peer unblocking is restricted to the local node owner, proven by a + // signature from the locally loaded wallet over the target IP string. + let wallet = match Wallet::try_obtain_wallet(wallet_key, None).await { + Ok(wallet) => wallet, + Err(err) => { + let msg = format!("error: Wallet decryption failed: {err}") + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + }; + + if Wallet::verify_transaction(&ip, &signature, &wallet.saved.long_address).await { + let tree = db.open_tree("blocked_peers").unwrap(); + let key = ip.clone(); + let _ = tree.remove(key); + let msg = "unblocked".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } else { + let msg = "error: Only the node owner can unblock a peer" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } +} diff --git a/src/rpc/commands/validate_address.rs b/src/rpc/commands/validate_address.rs new file mode 100644 index 0000000..807150e --- /dev/null +++ b/src/rpc/commands/validate_address.rs @@ -0,0 +1,19 @@ +use crate::records::wallet_registry::resolve_canonical_registered_short_address; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn validate(address: String, db: &Db) -> RpcResponse { + // Wallet validation returns a simple text status so lightweight RPC + // clients can check that an address is both well-formed and + // registered in the wallet registry, including registered vanity aliases. + if resolve_canonical_registered_short_address(db, &address) + .unwrap_or(None) + .is_some() + { + let msg = "valid".to_string().as_bytes().to_vec(); + return RpcResponse::Binary(msg); + } + + let msg = "invalid".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) +} diff --git a/src/rpc/commands/validate_message.rs b/src/rpc/commands/validate_message.rs new file mode 100644 index 0000000..e5b1304 --- /dev/null +++ b/src/rpc/commands/validate_message.rs @@ -0,0 +1,14 @@ +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; + +pub async fn validate(message: String, signature: String, address: String) -> RpcResponse { + // Signed-message verification returns a simple text status for + // lightweight external clients. + if Wallet::verify_transaction(&message, &signature, &address).await { + let msg = "valid".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } else { + let msg = "invalid".to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } +} diff --git a/src/rpc/commands/validate_torrent.rs b/src/rpc/commands/validate_torrent.rs new file mode 100644 index 0000000..2d62dba --- /dev/null +++ b/src/rpc/commands/validate_torrent.rs @@ -0,0 +1,54 @@ +use crate::rpc::read_bytes_from_stream; +use crate::rpc::responses::RpcResponse; +use crate::rpc::server::flood_protection::MAX_TORRENT_METADATA_BYTES; +use crate::torrent::structs::Torrent; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; + +pub async fn validate( + connections_key: &str, + stream_locked: Arc>, + db: &Db, + wallet_key: &str, +) -> Result<(u32, RpcResponse), String> { + // Command 6 validates torrent metadata for a specific block height + // without accepting or staging it as a broadcast torrent. + let (uid, _) = + read_bytes_from_stream::read_uid_from_stream(connections_key, stream_locked.clone()) + .await?; + let message_size = + read_bytes_from_stream::read_u32_from_stream(connections_key, stream_locked.clone()) + .await?; + let block_number = + read_bytes_from_stream::read_u32_from_stream(connections_key, stream_locked.clone()) + .await?; + + // The first four bytes after the size field are the block height, so + // only the remaining bytes are the torrent metadata body. + let size_u32 = message_size + .checked_sub(4) + .ok_or_else(|| "error: Invalid torrent payload size".to_string())?; + let size = usize::try_from(size_u32) + .map_err(|_| "error: Torrent payload size is too large".to_string())?; + if size > MAX_TORRENT_METADATA_BYTES { + return Err("error: Torrent payload too large".to_string()); + } + + let torrent_bytes = + read_bytes_from_stream::read_usize_from_stream(connections_key, size, stream_locked) + .await?; + let torrent = Torrent::from_bytes(&torrent_bytes) + .await + .map_err(|err| err.to_string())?; + + // Verification checks the torrent against local chain data and + // returns the verifier message as the binary RPC payload. + let result = match torrent.verify(block_number, db, wallet_key).await { + Ok(()) => RpcResponse::Binary(b"msg: Validation passed".to_vec()), + Err(err) => RpcResponse::Binary(err.into_bytes()), + }; + + Ok((uid, result)) +} diff --git a/src/rpc/commands/wallet_register.rs b/src/rpc/commands/wallet_register.rs new file mode 100644 index 0000000..9229d36 --- /dev/null +++ b/src/rpc/commands/wallet_register.rs @@ -0,0 +1,155 @@ +use crate::common::skein::skein_256_hash_bytes; +use crate::records::memory::connections::CONNECTIONS; +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::records::wallet_registry::{register_short_address, WalletRegistrationResult}; +use crate::rpc::command_maps::RPC_REGISTER_WALLET; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::log::warn; +use crate::sled::Db; +use crate::decode; +use crate::Arc; +use crate::Duration; +use crate::Mutex; +use crate::timeout; + +async fn broadcast_wallet_registration( + short_address: &[u8], + long_address_bytes: &[u8], + signature: &str, + map: Arc>, + remote_ip: &str, + connections_key: &str, +) { + // Registration broadcasts are forwarded to peers after local + // acceptance so wallet lookups converge across connected nodes. + let signature_bytes = match decode(signature) { + Ok(bytes) if bytes.len() == Wallet::SIGNATURE_LENGTH => bytes, + _ => return, + }; + + let streams = { + let connections_lock = CONNECTIONS.read().await; + connections_lock + .as_ref() + .map(|connection| connection.get_all_peer_streams()) + .unwrap_or_default() + }; + + for unlocked_stream in streams { + let peer_addr = { + let stream = unlocked_stream.lock().await; + stream.peer_addr() + }; + + if let Ok(addr) = peer_addr { + if !remote_ip.is_empty() && addr.ip().to_string() == remote_ip { + // Do not immediately echo a registration back to the peer + // that sent it to us. + continue; + } + } + + let (hashmap_key, _hashmap_tx, hashmap_rx) = reserve_entry(map.clone()).await; + let mut message = Vec::with_capacity( + 1 + 3 + + Wallet::SHORT_ADDRESS_BYTES_LENGTH + + Wallet::ADDRESS_BYTES_LENGTH + + Wallet::SIGNATURE_LENGTH, + ); + // Wire layout: command, UID, short address, long address, signature. + message.push(RPC_REGISTER_WALLET); + message.extend_from_slice(&hashmap_key); + message.extend_from_slice(short_address); + message.extend_from_slice(long_address_bytes); + message.extend_from_slice(&signature_bytes); + + RpcResponse::send_raw(&unlocked_stream, Some(connections_key), &message).await; + + let response = { + let mut rx = hashmap_rx.lock().await; + timeout(Duration::from_secs(5), rx.recv()).await + }; + + if response.is_err() { + warn!("[wallet_registry] timed out waiting for rebroadcast acknowledgement"); + } + } +} + +pub async fn register( + short_address_bytes: Vec, + long_address: String, + signature: String, + db: &Db, + map: Arc>, + remote_ip: String, + connections_key: String, +) -> RpcResponse { + // Convert the short-address bytes back to string form so the normal + // wallet/network validation path can reject malformed registrations. + let short_address = match Wallet::bytes_to_short_address(&short_address_bytes) { + Some(address) => address, + None => return RpcResponse::Binary(b"0".to_vec()), + }; + + if !Wallet::short_address_validation(&short_address) { + return RpcResponse::Binary(b"0".to_vec()); + } + + if !Wallet::wallet_validation(&long_address).await { + return RpcResponse::Binary(b"0".to_vec()); + } + + let long_address_bytes = Wallet::long_address_to_bytes(long_address.clone()); + if long_address_bytes.len() != Wallet::ADDRESS_BYTES_LENGTH { + return RpcResponse::Binary(b"0".to_vec()); + } + + let expected_short_address = + match Wallet::long_address_bytes_to_short_address_bytes(&long_address_bytes) { + Some(address) => address, + None => return RpcResponse::Binary(b"0".to_vec()), + }; + + // The claimed short address must be the deterministic short address + // derived from the submitted long wallet address. + if expected_short_address != short_address_bytes { + return RpcResponse::Binary(b"0".to_vec()); + } + + let mut signed_payload = + Vec::with_capacity(1 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + Wallet::ADDRESS_BYTES_LENGTH); + signed_payload.push(RPC_REGISTER_WALLET); + signed_payload.extend_from_slice(&short_address_bytes); + signed_payload.extend_from_slice(&long_address_bytes); + + // Registrations are signed over the exact command payload so peers + // can verify rebroadcasted registrations without trusting the sender. + let payload_hash = skein_256_hash_bytes(&signed_payload); + if !Wallet::verify_transaction(&payload_hash, &signature, &long_address).await { + return RpcResponse::Binary(b"0".to_vec()); + } + + let public_key_bytes = &long_address_bytes[1..]; + + // The registry stores short address -> public key; the long-address + // network byte is not part of the Falcon public key. + match register_short_address(db, &short_address_bytes, public_key_bytes) { + Ok(WalletRegistrationResult::Inserted) => { + broadcast_wallet_registration( + &short_address_bytes, + &long_address_bytes, + &signature, + map, + &remote_ip, + &connections_key, + ) + .await; + RpcResponse::Binary(b"1".to_vec()) + } + Ok(WalletRegistrationResult::AlreadyRegistered) => RpcResponse::Binary(b"1".to_vec()), + Ok(WalletRegistrationResult::Conflict) => RpcResponse::Binary(b"0".to_vec()), + Err(_) => RpcResponse::Binary(b"0".to_vec()), + } +} diff --git a/src/rpc/commands/wallet_registry_sync.rs b/src/rpc/commands/wallet_registry_sync.rs new file mode 100644 index 0000000..897192a --- /dev/null +++ b/src/rpc/commands/wallet_registry_sync.rs @@ -0,0 +1,32 @@ +use crate::records::wallet_registry::list_registered_wallets; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::Db; + +pub const WALLET_REGISTRY_RECORD_BYTES: usize = + Wallet::SHORT_ADDRESS_BYTES_LENGTH + Wallet::PUBLIC_KEY_LENGTH; + +pub async fn request_registry(db: &Db) -> RpcResponse { + // Registry sync returns a packed list of short-address/public-key + // records so peers can rebuild deterministic wallet lookups locally. + let wallets = match list_registered_wallets(db) { + Ok(wallets) => wallets, + Err(_) => return RpcResponse::Binary(Vec::new()), + }; + + let mut response_bytes = Vec::with_capacity(wallets.len() * WALLET_REGISTRY_RECORD_BYTES); + for (short_address, public_key) in wallets { + if short_address.len() != Wallet::SHORT_ADDRESS_BYTES_LENGTH + || public_key.len() != Wallet::PUBLIC_KEY_LENGTH + { + continue; + } + + // Fixed-width rows make the client-side parser a simple chunked + // read with no delimiter or JSON dependency. + response_bytes.extend_from_slice(&short_address); + response_bytes.extend_from_slice(&public_key); + } + + RpcResponse::Binary(response_bytes) +} diff --git a/src/rpc/commands/wallet_vanity_lookup.rs b/src/rpc/commands/wallet_vanity_lookup.rs new file mode 100644 index 0000000..f8afb87 --- /dev/null +++ b/src/rpc/commands/wallet_vanity_lookup.rs @@ -0,0 +1,15 @@ +use crate::records::wallet_registry::get_registered_vanity_for_owner; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub async fn lookup(owner_short_address: String, db: &Db) -> RpcResponse { + // Vanity lookup is intentionally owner -> vanity only; all normal + // chain activity should continue using the canonical short address. + match get_registered_vanity_for_owner(db, &owner_short_address) { + Ok(Some(vanity_address)) => RpcResponse::Binary(vanity_address.into_bytes()), + Ok(None) => RpcResponse::Binary(Vec::new()), + Err(err) => RpcResponse::Binary( + format!("error: Failed to lookup vanity address: {err}").into_bytes(), + ), + } +} diff --git a/src/rpc/handshake_constants.rs b/src/rpc/handshake_constants.rs new file mode 100644 index 0000000..f41ec06 --- /dev/null +++ b/src/rpc/handshake_constants.rs @@ -0,0 +1,13 @@ +use crate::wallets::structures::Wallet; + +pub const HANDSHAKE_MESSAGE_BYTES: usize = 2; +pub const HANDSHAKE_TIME_BYTES: usize = 4; +pub const HANDSHAKE_IP_BYTES: usize = 18; + +pub const HANDSHAKE_SIGNATURE_OFFSET: usize = HANDSHAKE_MESSAGE_BYTES; +pub const HANDSHAKE_ADDRESS_OFFSET: usize = HANDSHAKE_SIGNATURE_OFFSET + Wallet::SIGNATURE_LENGTH; +pub const HANDSHAKE_TIME_OFFSET: usize = HANDSHAKE_ADDRESS_OFFSET + Wallet::ADDRESS_BYTES_LENGTH; +pub const HANDSHAKE_IP_OFFSET: usize = HANDSHAKE_TIME_OFFSET + HANDSHAKE_TIME_BYTES; + +pub const HANDSHAKE_REQUEST_BYTES: usize = HANDSHAKE_IP_OFFSET + HANDSHAKE_IP_BYTES; +pub const HANDSHAKE_RESPONSE_BYTES: usize = HANDSHAKE_ADDRESS_OFFSET + Wallet::ADDRESS_BYTES_LENGTH; diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs new file mode 100644 index 0000000..5feb3b7 --- /dev/null +++ b/src/rpc/mod.rs @@ -0,0 +1,8 @@ +// The rpc module groups the server, client, command, and framing code for peer communication. +pub mod client; +pub mod command_maps; +pub mod commands; +pub mod handshake_constants; +pub mod read_bytes_from_stream; +pub mod responses; +pub mod server; diff --git a/src/rpc/read_bytes_from_stream.rs b/src/rpc/read_bytes_from_stream.rs new file mode 100644 index 0000000..3ff92e0 --- /dev/null +++ b/src/rpc/read_bytes_from_stream.rs @@ -0,0 +1,216 @@ +use crate::common::binary_conversions::binary_to_ip; +use crate::encode; +use crate::log::warn; +use crate::rpc::server::connection_memory_manager; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; +use crate::{timeout, AsyncReadExt, AsyncWriteExt, Duration}; + +const STREAM_READ_TIMEOUT_SECONDS: u64 = 30; + +async fn read_exact_from_stream( + key: &str, + stream: &mut TcpStream, + buffer: &mut [u8], +) -> Result<(), String> { + // Once a command byte has been accepted, payload reads must still + // complete promptly so a half-sent command cannot hold a connection slot. + match timeout( + Duration::from_secs(STREAM_READ_TIMEOUT_SECONDS), + stream.read_exact(buffer), + ) + .await + { + Ok(Ok(_)) => Ok(()), + Ok(Err(err)) => { + if let Err(e) = stream.shutdown().await { + warn!("Error shutting down stream: {e}"); + } + connection_memory_manager::remove_key_from_memory(key).await; + Err(err.to_string()) + } + Err(_) => { + if let Err(e) = stream.shutdown().await { + warn!("Error shutting down stream: {e}"); + } + connection_memory_manager::remove_key_from_memory(key).await; + Err(format!( + "Timed out reading from stream after {STREAM_READ_TIMEOUT_SECONDS} seconds" + )) + } + } +} + +pub async fn read_caller_ip(stream_locked: Arc>) -> Result { + // Read the remote socket address directly from the live stream so + // node-update commands can compare claimed and actual peer IPs. + let stream = stream_locked.lock().await; + let ip = match stream.peer_addr() { + Ok(addr) => Ok(addr.ip().to_string()), // Convert the IP address to a string + Err(err) => Err(format!("Failed to get peer address: {err}")), + }; + drop(stream); + ip +} + +pub async fn read_first_byte( + key: &str, + stream_locked: Arc>, +) -> Result { + // Read the command byte and clean up the in-memory connection state + // if the peer disconnects mid-read. + let mut stream = stream_locked.lock().await; + let mut buffer = [0u8; 1]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + Ok(u8::from_le_bytes(buffer)) +} + +pub async fn read_uid_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result<(u32, [u8; 3]), String> { + // Most protocol messages carry a 3-byte UID so requests and replies + // can be matched through the shared hashmap. + let mut stream = stream_locked.lock().await; + let mut uid_bytes = [0u8; 3]; + read_exact_from_stream(key, &mut stream, &mut uid_bytes).await?; + let uid = u32::from_le_bytes([0, uid_bytes[0], uid_bytes[1], uid_bytes[2]]); + drop(stream); + Ok((uid, uid_bytes)) +} + +pub async fn read_u8_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result { + let mut stream = stream_locked.lock().await; + let mut buffer = [0u8; 1]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + Ok(u8::from_le_bytes(buffer)) +} + +pub async fn read_u16_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result { + let mut stream = stream_locked.lock().await; + let mut buffer = [0u8; 2]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + Ok(u16::from_le_bytes(buffer)) +} + +pub async fn read_u32_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result { + let mut stream = stream_locked.lock().await; + let mut buffer = [0u8; 4]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + Ok(u32::from_le_bytes(buffer)) +} + +pub async fn read_u64_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result { + let mut stream = stream_locked.lock().await; + let mut buffer = [0u8; 8]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + Ok(u64::from_le_bytes(buffer)) +} + +pub async fn read_u128_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result { + let mut stream = stream_locked.lock().await; + let mut buffer = [0u8; 16]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + Ok(u128::from_le_bytes(buffer)) +} + +pub async fn read_usize_from_stream( + key: &str, + bytes: usize, + stream_locked: Arc>, +) -> Result, String> { + // Read an exact payload length from the socket without decoding it, + // leaving interpretation to the calling command handler. + let mut stream = stream_locked.lock().await; + let mut buffer = vec![0u8; bytes]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + Ok(buffer) +} + +pub async fn read_wallet_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result { + // Wallet addresses use a fixed binary length on the wire and are + // decoded into their CLTC string form here. + let mut stream = stream_locked.lock().await; + let mut buffer = vec![0u8; Wallet::ADDRESS_BYTES_LENGTH]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + let address = Wallet::bytes_to_long_address(buffer.to_vec()); + Ok(address) +} + +pub async fn read_short_address_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result, String> { + // Short wallet addresses use the fixed 22-byte hashed format on the + // wire and stay as raw bytes until the command handler validates them. + let mut stream = stream_locked.lock().await; + let mut buffer = vec![0u8; Wallet::SHORT_ADDRESS_BYTES_LENGTH]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + Ok(buffer) +} + +pub async fn read_short_address_string_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result { + let buffer = read_short_address_from_stream(key, stream_locked).await?; + Wallet::bytes_to_short_address(&buffer) + .ok_or_else(|| "error: Invalid short address bytes".to_string()) +} + +pub async fn read_ip_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result { + // IP payloads are stored in the fixed 16-byte network format used by + // the node list and peer-management RPC calls. + let mut stream = stream_locked.lock().await; + let mut buffer = vec![0u8; 16]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + let ip = binary_to_ip(buffer.to_vec()); + Ok(ip) +} + +pub async fn read_signature_from_stream( + key: &str, + stream_locked: Arc>, +) -> Result { + // Signatures travel as raw bytes on the wire and are normalized back + // into hex strings for downstream verification logic. + let mut stream = stream_locked.lock().await; + let mut buffer = vec![0u8; Wallet::SIGNATURE_LENGTH]; + read_exact_from_stream(key, &mut stream, &mut buffer).await?; + drop(stream); + let signature = encode(buffer); + Ok(signature) +} diff --git a/src/rpc/responses.rs b/src/rpc/responses.rs new file mode 100644 index 0000000..39d4e9b --- /dev/null +++ b/src/rpc/responses.rs @@ -0,0 +1,103 @@ +use crate::log::warn; +use crate::rpc::command_maps::{MAX_RPC_REPLY_BYTES, RPC_REPLY}; +use crate::rpc::server::connection_memory_manager::remove_key_from_memory; +use crate::Arc; +use crate::AsyncWriteExt; +use crate::Mutex; +use crate::Serialize; +use crate::TcpStream; +use std::io::ErrorKind; + +#[derive(Debug, Serialize)] +pub enum RpcResponse { + Binary(Vec), +} + +impl RpcResponse { + fn is_expected_disconnect(err: &std::io::Error) -> bool { + matches!( + err.kind(), + ErrorKind::BrokenPipe + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::UnexpectedEof + ) + } + + pub async fn send_raw( + stream: &Arc>, + connections_key: Option<&str>, + bytes: &[u8], + ) { + // Raw sends are used for messages that already include their + // command framing and payload layout. + let mut stream_guard = stream.lock().await; + if let Err(err) = stream_guard.write_all(bytes).await { + if !Self::is_expected_disconnect(&err) { + warn!("Error sending binary response: {err:?}"); + } + if let Some(key) = connections_key { + remove_key_from_memory(key).await; + } + let _ = stream_guard.shutdown().await; + return; + } + if let Err(err) = stream_guard.flush().await { + if !Self::is_expected_disconnect(&err) { + warn!("Error flushing stream: {err:?}"); + } + } + } + + pub async fn send( + &self, + stream: &Arc>, + connections_key: Option<&str>, + uid: u32, + ) { + // Standard RPC responses are framed with command 255 and the + // request UID so the waiting caller can route the reply. + let mut stream_guard = stream.lock().await; + + let uid_bytes = uid.to_le_bytes()[1..4].to_vec(); + let command = RPC_REPLY; + match self { + RpcResponse::Binary(bytes) => { + let oversized_reply = b"error: RPC reply exceeds maximum payload size"; + let payload = if bytes.len() > MAX_RPC_REPLY_BYTES { + warn!( + "RPC reply payload too large: len={} max={}", + bytes.len(), + MAX_RPC_REPLY_BYTES + ); + oversized_reply.as_slice() + } else { + bytes.as_slice() + }; + let mut response_bytes = Vec::with_capacity(8 + payload.len()); + let bytes_length = payload.len() as u32; + let bytes_as_slice = bytes_length.to_le_bytes(); + response_bytes.push(command); + response_bytes.extend_from_slice(&uid_bytes); + response_bytes.extend_from_slice(&bytes_as_slice); + response_bytes.extend_from_slice(payload); + if let Err(err) = stream_guard.write_all(&response_bytes).await { + if !Self::is_expected_disconnect(&err) { + warn!("Error sending binary response: {err:?}"); + } + if let Some(key) = connections_key { + remove_key_from_memory(key).await; + } + let _ = stream_guard.shutdown().await; + return; + } + } + } + if let Err(err) = stream_guard.flush().await { + if !Self::is_expected_disconnect(&err) { + warn!("Error flushing stream: {err:?}"); + } + } + } +} diff --git a/src/rpc/server/command_loop_state.rs b/src/rpc/server/command_loop_state.rs new file mode 100644 index 0000000..aa8b59f --- /dev/null +++ b/src/rpc/server/command_loop_state.rs @@ -0,0 +1,89 @@ +use crate::records::memory::connections::get_client_type_from_memory; +use crate::records::memory::enums::ClientType; +use crate::rpc::command_maps::RPC_REPLY; +use crate::rpc::read_bytes_from_stream::read_first_byte; +use crate::rpc::server::connection_memory_manager::remove_stream_from_memory; +use crate::rpc::server::flood_protection::check_request_frequency_with_client_type; +use crate::rpc::server::structs::IncomingCommand; +use crate::log::warn; +use crate::sled::Db; +use crate::Arc; +use crate::Duration; +use crate::Mutex; +use crate::sleep; +use crate::TcpStream; +use crate::timeout; + +async fn wait_for_stream_data(stream_locked: &Arc>) -> Result { + // Poll the socket with peek so the command byte stays in the stream + // until the normal byte reader consumes it. + let timeout_duration = Duration::from_millis(100); + + loop { + let stream = stream_locked.lock().await; + let mut buffer = [0; 1]; + + match timeout(timeout_duration, async { stream.peek(&mut buffer).await }).await { + Ok(Ok(n)) => { + if n > 0 { + return Ok(true); + } + if stream.peer_addr().is_err() { + warn!("Dropped stream: {:?}", stream.peer_addr().unwrap_err()); + drop(stream); + remove_stream_from_memory(stream_locked).await; + return Ok(false); + } + } + Ok(Err(_)) | Err(_) => { + drop(stream); + sleep(timeout_duration).await; + } + } + } +} + +async fn peer_ip(stream_locked: &Arc>) -> String { + // Resolve the peer address once per incoming command so scoring and + // command handlers can use the same caller identity. + let stream = stream_locked.lock().await; + stream + .peer_addr() + .map(|addr| addr.ip().to_string()) + .unwrap_or_else(|_| "unknown".into()) +} + +pub async fn next_incoming_command( + stream_locked: Arc>, + db: &Db, + connections_key: &str, + wallet_key: &str, +) -> Result, String> { + // A disconnected socket returns None so the caller can end the RPC + // loop without treating a clean disconnect as a command failure. + if !wait_for_stream_data(&stream_locked).await? { + return Ok(None); + } + + let command = + read_first_byte(connections_key, stream_locked.clone()).await?; + let ip = peer_ip(&stream_locked).await; + + // Connection memory is the source of truth for whether this stream + // belongs to a node/miner or a wallet-backed RPC client. + let client_type = get_client_type_from_memory(connections_key) + .await + .unwrap_or(ClientType::Miner); + + // Replies belong to an existing request path, so only new inbound + // commands are counted against flood protection. + if command != RPC_REPLY { + check_request_frequency_with_client_type(db, ip.clone(), client_type, wallet_key).await; + } + + Ok(Some(IncomingCommand { + command, + ip, + client_type, + })) +} diff --git a/src/rpc/server/connection_memory_manager.rs b/src/rpc/server/connection_memory_manager.rs new file mode 100644 index 0000000..d9cf397 --- /dev/null +++ b/src/rpc/server/connection_memory_manager.rs @@ -0,0 +1,120 @@ +use crate::records::memory::connections::CONNECTIONS; +use crate::records::memory::enums::{ClientType, ConnectionType}; +use crate::records::memory::response_channels::Command; +use crate::records::memory::structs::StoreConnectionParams; +use crate::Arc; +use crate::AsyncWriteExt; +use crate::Mutex; +use crate::TcpStream; + +fn split_ip_port_key(value: &str) -> Option<(String, u16)> { + // Use rsplit_once so IPv4 host:port values work normally and IPv6 + // bracketed addresses can keep their internal colons. + let (ip_part, port_part) = value.rsplit_once(':')?; + let ip = ip_part + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + .unwrap_or(ip_part) + .to_string(); + let port = port_part.parse::().ok()?; + Some((ip, port)) +} + +// write an incoming connection to memory +// client type defines if connection +// is another node or a client connecting +// to use rpc services +pub async fn write_to_memory( + received_ip_port: &str, + stream: Arc>, + client_type: &str, + wallet: &str, + command_map: Arc>, +) -> String { + // Reject unknown connection labels before the connection manager is + // locked so invalid handshakes do not touch shared state. + let Ok(client_type) = client_type.parse::() else { + return "false".to_string(); + }; + + let mut connection_instance = CONNECTIONS.write().await; + if let Some(mut connection) = connection_instance.take() { + // The connection manager stores IP and port separately because + // connection direction is tracked in the key alongside them. + let Some((ip, port)) = split_ip_port_key(received_ip_port) else { + *connection_instance = Some(connection); + return "false".to_string(); + }; + + let added = connection.store_connection(StoreConnectionParams { + connection_type: ConnectionType::Incoming, + ip: ip.clone(), + port, + stream: stream.clone(), + client_type, + wallet_address: wallet.to_string(), + command_map, + }); + + *connection_instance = Some(connection); + if added { + // Return the original ip:port string as the connection key + // used by stream readers and cleanup paths. + drop(connection_instance); + received_ip_port.to_string() + } else { + // Duplicate streams are told why they are being closed before + // the socket is shut down. + drop(connection_instance); + let duplicate_message = + "The connection is already in the connection manager Please wait 10 minutes and try again"; + let mut stream_guard = stream.lock().await; + let _ = stream_guard.write_all(duplicate_message.as_bytes()).await; + let _ = stream_guard.flush().await; + let _ = stream_guard.shutdown().await; + "false".to_string() + } + } else { + "false".to_string() + } +} + +// delete a connection from memory +pub async fn remove_key_from_memory(key: &str) { + let mut connection_instance = CONNECTIONS.write().await; + // A key can represent either direction, so cleanup removes both + // incoming and outgoing entries with the same endpoint. + let Some((ip, port)) = split_ip_port_key(key) else { + return; + }; + if let Some(connection) = connection_instance.as_mut() { + connection.drop_connection(ConnectionType::Incoming, ip.clone(), port); + connection.drop_connection(ConnectionType::Outgoing, ip, port); + } +} + +pub async fn remove_stream_from_memory(stream: &Arc>) { + let mut connection_instance = CONNECTIONS.write().await; + let Some(connection) = connection_instance.as_mut() else { + return; + }; + + // Stream cleanup is used when only the socket handle is known, so + // search the connection map for the matching Arc before dropping it. + let matching_connection = connection + .connection_map + .iter() + .find_map(|(connection_key, connection_info)| { + let connection_type = ConnectionType::from_bytes(&connection_key.connection_type)?; + if Arc::ptr_eq(&connection_info.stream, stream) { + Some((connection_type, connection_key.ip.clone(), connection_key.port)) + } else { + None + } + }); + + if let Some((connection_type, ip_bytes, port)) = matching_connection { + let ip = crate::common::binary_conversions::binary_to_ip(ip_bytes); + connection.drop_connection(connection_type, ip, port); + } +} diff --git a/src/rpc/server/flood_protection.rs b/src/rpc/server/flood_protection.rs new file mode 100644 index 0000000..2b4d0f4 --- /dev/null +++ b/src/rpc/server/flood_protection.rs @@ -0,0 +1,79 @@ +use crate::records::ip_score::enums::InfractionType; +use crate::records::ip_score::score::update_ip_score; +use crate::records::memory::enums::ClientType; +use crate::rpc::server::structs::RpcFloodState; +use crate::sled::Db; +use crate::Utc; + +pub const MAX_TORRENT_METADATA_BYTES: usize = 8192; +pub const RPC_SHORT_WINDOW_SECS: i64 = 2; +pub const RPC_LONG_WINDOW_SECS: i64 = 60; +pub const RPC_LONG_WINDOW_LIMIT: u32 = 250; + +pub fn request_subject(ip: &str, client_type: ClientType) -> String { + // Wallet-backed clients share an address across changing IPs, so + // flood tracking treats them differently from long-lived node peers. + if client_type == ClientType::Client { + format!("client:{ip}") + } else { + ip.to_string() + } +} + +pub async fn check_request_frequency_with_client_type( + db: &Db, + ip: String, + client_type: ClientType, + wallet_key: &str, +) { + // Keep one compact flood-tracker row per subject and decay the + // counters by elapsed time so stale request history expires + // automatically without growing the tree unbounded. + let tree = match db.open_tree("rpc_flood_tracker") { + Ok(t) => t, + Err(_) => return, + }; + + let now = Utc::now().timestamp(); + let subject = request_subject(&ip, client_type); + let key = subject.as_bytes(); + + // Missing or unreadable flood state starts a fresh window instead of + // blocking the request path on bookkeeping. + let existing_state = match tree.get(key) { + Ok(Some(value)) => { + RpcFloodState::from_bytes(value.as_ref()).unwrap_or_else(|| RpcFloodState::new(now)) + } + Ok(None) => RpcFloodState::new(now), + Err(_) => return, + }; + + let mut state = existing_state; + // Recording the request also rolls expired short/long windows + // forward before the new count is stored. + state.record_request(now); + + if tree.insert(key, state.to_bytes().to_vec()).is_err() { + return; + } + + // Only sustained long-window flooding is scored; short-window state + // exists so the serialized tracker can be extended without changing + // the storage shape again. + if state.is_flooding() { + let _ = update_ip_score( + &ip, + client_type.as_str(), + InfractionType::RpcFloodAttack, + now as u32, + db, + wallet_key, + ) + .await; + + // Reset the flood window after a penalty so repeated bursts + // require a fresh full threshold crossing before scoring again. + state = RpcFloodState::new(now); + let _ = tree.insert(key, state.to_bytes().to_vec()); + } +} diff --git a/src/rpc/server/handshake.rs b/src/rpc/server/handshake.rs new file mode 100644 index 0000000..1c74c5f --- /dev/null +++ b/src/rpc/server/handshake.rs @@ -0,0 +1,176 @@ +use crate::records::memory::response_channels::Command; +use crate::records::memory::response_channels::generate_uid; +use crate::rpc::responses::RpcResponse; +use crate::rpc::server::connection_memory_manager::write_to_memory; +use crate::rpc::server::handshake_processing::{combine_and_send_data, parse_received_data}; +use crate::rpc::server::structs::{CombineAndSendDataParams, HandshakeTestParams}; +use crate::rpc::server::handshake_verifications::{connection_count, perform_handshake_tests}; +use crate::rpc::server::tests::{endpoint_port, is_port_open}; +use crate::sled::Db; +use crate::Arc; +use crate::AsyncWriteExt; +use crate::Mutex; +use crate::Settings; +use crate::TcpStream; +use crate::Utc; + +async fn drop_failed_handshake(stream: &Arc>) { + // Failed handshakes are never stored in connection memory, but the + // accepted TCP socket should still be closed immediately. + let mut stream_guard = stream.lock().await; + let _ = stream_guard.flush().await; + let _ = stream_guard.shutdown().await; +} + +async fn get_connection_counts() -> (u8, u8) { + // Handshake limits come from settings so the node can change its + // connection policy without recompiling. + let settings = Settings::load().expect("Failed to load settings"); + let incoming = settings.incoming_connections; + let outgoing = settings.outgoing_connections; + (incoming, outgoing) +} + +// this function validates incoming handshake and determined +// what type of connection was made +pub async fn handle_handshake( + stream: Arc>, + db: Db, + wallet_key: String, + map: Arc>, +) { + // read number of connected clients or set to 0 if none + let count = connection_count().await; + + // Only incoming capacity matters here; outgoing is loaded with the + // same settings call but enforced by the connection starter. + let (incoming_connections, _outgoing_connection) = get_connection_counts().await; + + // get data from stream + let Ok(( + received_message, + received_signed_message, + received_address, + hash, + received_ip, + peer_time, + )) = parse_received_data(stream.clone()).await + else { + return; + }; + + // get local timestamp + let timestamp = Utc::now().timestamp() as u32; + + // received message should be "aced" + // aced is used instead of ping and pong + // as its HEX and compressed better + if received_message == "aced" { + //validate handshake tests + if !perform_handshake_tests(HandshakeTestParams { + map: map.clone(), + stream: stream.clone(), + count, + peer_time, + timestamp, + incoming_connections, + hash: &hash, + received_signed_message: &received_signed_message, + received_address: &received_address, + received_ip: &received_ip, + }) + .await + { + // Each failed test sends its own error response, so the + // handshake can stop here without writing another message. + drop_failed_handshake(&stream).await; + return; + } + + // Port 0 is the explicit client marker. A node advertising a + // nonzero miner port must actually be reachable before it can be + // stored in connection memory or the network map. + let Some(advertised_port) = endpoint_port(&received_ip) else { + let hashmap_key = generate_uid(); + let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0]; + let uid = u32::from_le_bytes(padded_bytes); + let response_bytes = + RpcResponse::Binary("error: Invalid advertised endpoint.".as_bytes().to_vec()); + response_bytes.send(&stream, None, uid).await; + drop_failed_handshake(&stream).await; + return; + }; + + let connection_type = if advertised_port == 0 { + "client" + } else if is_port_open(&received_ip).await.unwrap_or(false) { + "miner" + } else { + let hashmap_key = generate_uid(); + let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0]; + let uid = u32::from_le_bytes(padded_bytes); + let response_bytes = RpcResponse::Binary( + "error: Handshake failed: advertised miner port is not reachable." + .as_bytes() + .to_vec(), + ); + response_bytes.send(&stream, None, uid).await; + drop_failed_handshake(&stream).await; + return; + }; + + if connection_type == "miner" { + let miner_reserved_limit = incoming_connections.saturating_sub(1) as usize; + let current_count = connection_count().await; + if current_count >= miner_reserved_limit { + let hashmap_key = generate_uid(); + let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0]; + let uid = u32::from_le_bytes(padded_bytes); + let response_bytes = RpcResponse::Binary( + "error: Miner connection slots are filled. Please try again later." + .as_bytes() + .to_vec(), + ); + response_bytes.send(&stream, None, uid).await; + drop_failed_handshake(&stream).await; + return; + } + } + + // write to memory + let connections_key = write_to_memory( + &received_ip, + stream.clone(), + connection_type, + &received_address, + map.clone(), + ) + .await; + + if connections_key != "false" { + // Once the peer is accepted into memory, return our signed + // handshake response and start the long-lived RPC loop. + let params = CombineAndSendDataParams { + stream, + db: db.clone(), + connections_key, + connection_type: connection_type.to_string(), + wallet_key: wallet_key.clone(), + map, + returned_address: received_address.clone(), + }; + let _ = combine_and_send_data(params).await; + } else { + drop_failed_handshake(&stream).await; + } + } else { + let response_bytes = RpcResponse::Binary({ + "error: Invalid Handshake: Signature Failed" + .to_string() + .as_bytes() + .to_vec() + }); + response_bytes.send(&stream, None, 0).await; + drop_failed_handshake(&stream).await; + } +} diff --git a/src/rpc/server/handshake_processing.rs b/src/rpc/server/handshake_processing.rs new file mode 100644 index 0000000..fb3d69f --- /dev/null +++ b/src/rpc/server/handshake_processing.rs @@ -0,0 +1,193 @@ +use crate::common::binary_conversions::binary_to_ip_port; +use crate::common::skein::skein_256_hash_data; +use crate::rpc::handshake_constants::{ + HANDSHAKE_ADDRESS_OFFSET, HANDSHAKE_IP_OFFSET, HANDSHAKE_MESSAGE_BYTES, + HANDSHAKE_REQUEST_BYTES, HANDSHAKE_RESPONSE_BYTES, HANDSHAKE_SIGNATURE_OFFSET, + HANDSHAKE_TIME_OFFSET, +}; +use crate::rpc::server::connection_memory_manager::remove_key_from_memory; +use crate::rpc::server::rpc_command_loop::start_loop; +use crate::rpc::server::structs::CombineAndSendDataParams; +use crate::wallets::structures::Wallet; +use crate::log::error; +use crate::io::ErrorKind; +use crate::AsyncReadExt; +use crate::Arc; +use crate::AsyncWriteExt; +use crate::Mutex; +use crate::TcpStream; +use crate::{decode, encode}; + +pub async fn parse_received_data( + stream: Arc>, +) -> Result<(String, String, String, String, String, u32), String> { + // A server-side handshake request has a fixed binary size, so read + // the full frame before slicing out individual fields. + let mut buffer = vec![0u8; HANDSHAKE_REQUEST_BYTES]; + let read_result = stream.lock().await.read_exact(&mut buffer).await; + match read_result { + // compiler requires an okay message even though we don't need it + Ok(_) => {} + Err(ref err) if err.kind() == ErrorKind::UnexpectedEof => { + if let Err(e) = stream.lock().await.shutdown().await { + error!("Error shutting down stream: {e}"); + } + return Err(err.to_string()); + } + Err(_) => { + if let Err(e) = stream.lock().await.shutdown().await { + error!("Error shutting down stream: {e}"); + return Err(e.to_string()); + } + return Err("error: Failed to read handshake request".to_string()); + } + } + + // The first bytes are the fixed challenge message; the signature + // proves the sender controls the wallet address that follows it. + let received_message = encode(&buffer[..HANDSHAKE_MESSAGE_BYTES]); + let received_signed_message = + encode(&buffer[HANDSHAKE_SIGNATURE_OFFSET..HANDSHAKE_ADDRESS_OFFSET]); + let received_address_bytes = &buffer[HANDSHAKE_ADDRESS_OFFSET..HANDSHAKE_TIME_OFFSET]; + + // Long wallet addresses carry the network byte at the front, so + // reject the handshake before rebuilding an address from bad bytes. + if Wallet::map_byte_to_wallet(received_address_bytes[0]).is_empty() { + return Err("error: Invalid handshake wallet network byte".to_string()); + } + let received_address = Wallet::bytes_to_long_address(received_address_bytes.to_vec()); + + // The timestamp and announced ip:port are kept as raw protocol bytes + // until this point so the offsets remain explicit. + let peer_time = u32::from_le_bytes( + buffer[HANDSHAKE_TIME_OFFSET..HANDSHAKE_IP_OFFSET] + .try_into() + .unwrap(), + ); + let received_ip = binary_to_ip_port(&buffer[HANDSHAKE_IP_OFFSET..HANDSHAKE_REQUEST_BYTES]); + let hash = skein_256_hash_data(&received_message); + + Ok(( + received_message, + received_signed_message, + received_address, + hash, + received_ip, + peer_time, + )) +} + +pub async fn generate_and_sign_message( + connection_type: &str, + wallet_key: String, +) -> Result<(String, String, String), String> { + // get the wallet info so we can sign our return message + let wallet = Wallet::try_obtain_wallet(wallet_key, None).await?; + let address = wallet.saved.long_address; + + // if miner face is the return message, used because its hex so compressed better + // otherwise face spelledbackwards is used + if connection_type == "miner" { + // Miner responses use the opposite challenge from the incoming + // request so both sides prove they can sign independently. + let message = "face"; + let hashed = skein_256_hash_data(message); + let signed_message = Wallet::sign_transaction(&hashed, &wallet.saved.private_key).await; + Ok(( + address.to_string(), + message.to_string(), + signed_message.to_string(), + )) + } else { + // Non-miner clients get a client challenge while still using the + // same wallet-signature verification path. + let message = "ecaf"; + let hashed = skein_256_hash_data(message); + let signed_message = Wallet::sign_transaction(&hashed, &wallet.saved.private_key).await; + Ok(( + address.to_string(), + message.to_string(), + signed_message.to_string(), + )) + } +} + +pub async fn return_handshake( + stream: Arc>, + address: &str, + message: &str, + signed_message: &str, + connections_key: &str, +) -> Result<(), String> { + // convert to binary/bytes + let address_bin = Wallet::long_address_to_bytes(address.to_string()); + let message_bin = decode(message).expect("Failed to decode message"); + let signed_bin = decode(signed_message).expect("Failed to decode signed message"); + let mut data = Vec::with_capacity(HANDSHAKE_RESPONSE_BYTES); + + // Response frames mirror the request layout: challenge, signature, + // then the wallet address that signed the challenge. + data.extend_from_slice(&message_bin); + data.extend_from_slice(&signed_bin); + data.extend(&address_bin); + + // get stream lock + let mut stream_guard = stream.lock().await; + + // write to stream + if let Err(err) = stream_guard.write_all(&data).await { + let _ = remove_key_from_memory(connections_key).await; + error!("Error writing to stream: {err:?}"); + return Err("error: Error writing to stream".to_string()); + } else if let Err(err) = stream_guard.flush().await { + error!("Error flushing stream: {err:?}"); + return Err("error: Error flushing stream".to_string()); + } + + // drop lock + drop(stream_guard); + Ok(()) +} + +pub async fn combine_and_send_data(params: CombineAndSendDataParams) { + let CombineAndSendDataParams { + stream, + db, + connections_key, + connection_type, + wallet_key, + map, + returned_address: _, + } = params; + // generate the message to send + let result = generate_and_sign_message(&connection_type, wallet_key.clone()).await; + if let Err(err) = result { + error!("Failed: {err}"); + return; + } + let (address, message, signed_message) = result.unwrap(); + + // The handshake response must complete before the command loop is + // spawned, otherwise the peer may start reading command data early. + if let Err(err) = return_handshake( + stream.clone(), + &address, + &message, + &signed_message, + &connections_key, + ) + .await + { + error!("Handshake failed: {err}"); + return; + } + + // start the rpc loop + tokio::spawn(start_loop( + stream.clone(), + db, + connections_key.to_string(), + wallet_key, + map, + )); +} diff --git a/src/rpc/server/handshake_verifications.rs b/src/rpc/server/handshake_verifications.rs new file mode 100644 index 0000000..3e67278 --- /dev/null +++ b/src/rpc/server/handshake_verifications.rs @@ -0,0 +1,180 @@ +use crate::records::memory::connections::CONNECTIONS; +use crate::records::memory::response_channels::{generate_uid, Command}; +use crate::rpc::responses::RpcResponse; +use crate::rpc::server::structs::HandshakeTestParams; +use crate::rpc::server::tests::{ip_test, is_within_one_second}; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; + +pub async fn connection_count() -> usize { + // get memory read lock + let connection_storage = CONNECTIONS.read().await; + + // read number of connected clients or set to 0 if none + let count = if let Some(connections) = connection_storage.as_ref() { + connections.count_incoming_connections() + } else { + 0 + }; + + drop(connection_storage); + count +} + +pub async fn check_max_connections( + _map: Arc>, + stream: Arc>, + count: usize, + incoming_connections: u8, +) -> bool { + if count >= incoming_connections as usize { + // Handshake failures still use the normal RPC response framing, + // so generate a temporary UID for the error packet. + let hashmap_key = generate_uid(); + let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0]; + let uid = u32::from_le_bytes(padded_bytes); + + let response_bytes = RpcResponse::Binary({ + let msg = "error: Sorry all our connections are filled. Please try again later." + .to_string() + .as_bytes() + .to_vec(); + msg + }); + response_bytes.send(&stream, None, uid).await; + true + } else { + false + } +} + +pub async fn verify_timestamp( + _map: Arc>, + stream: Arc>, + peer_time: u32, + timestamp: u32, +) -> bool { + if !is_within_one_second(timestamp, peer_time) { + // Tight timestamp checks keep peers from replaying stale signed + // handshakes after the connection attempt has passed. + let hashmap_key = generate_uid(); + let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0]; + let uid = u32::from_le_bytes(padded_bytes); + + let response_bytes = RpcResponse::Binary({ + let msg = format!( + "error: Handshake Failed: The time on your computer must be within 1 second of the time of our server. Your local time: {peer_time}. Our server time: {timestamp}. Please consider installing NTP to ensure proper timestamps." + ) + .as_bytes() + .to_vec(); + msg + }); + + response_bytes.send(&stream, None, uid).await; + false + } else { + true + } +} + +pub async fn verify_ip_address( + _map: Arc>, + stream: Arc>, + received_ip: &str, +) -> bool { + if !ip_test(received_ip, &stream).await { + // The peer must announce the same public endpoint it is using + // for this socket, otherwise it cannot be trusted as a node. + let hashmap_key = generate_uid(); + let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0]; + let uid = u32::from_le_bytes(padded_bytes); + + let response_bytes = RpcResponse::Binary({ + let msg = "error: Handshake Failed: You must use a public IP, and your computer must be accessible by others. Please check your configuration, firewall settings, or NAT settings.".to_string().as_bytes().to_vec(); + msg + }); + + response_bytes.send(&stream, None, uid).await; + false + } else { + true + } +} + +pub async fn verify_handshake( + stream: Arc>, + _map: Arc>, + hash: &str, + received_signed_message: &str, + received_address: &str, +) -> bool { + // Signature verification proves the peer controls the wallet address + // embedded in the handshake request. + let hashmap_key = generate_uid(); + let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0]; + let uid = u32::from_le_bytes(padded_bytes); + + if !Wallet::verify_transaction(hash, received_signed_message, received_address).await { + let response_bytes = RpcResponse::Binary({ + "error: Handshake failed: Invalid handshake" + .to_string() + .as_bytes() + .to_vec() + }); + response_bytes.send(&stream, None, uid).await; + return false; + } + true +} + +pub async fn perform_handshake_tests(params: HandshakeTestParams<'_>) -> bool { + // Stop at the first failed gate because each check sends the exact + // error response that explains why the handshake was rejected. + //check connection limits + if check_max_connections( + params.map.clone(), + params.stream.clone(), + params.count, + params.incoming_connections, + ) + .await + { + return false; + } + // verify timestmap + if !verify_timestamp( + params.map.clone(), + params.stream.clone(), + params.peer_time, + params.timestamp, + ) + .await + { + return false; + } + // verify IP + if !verify_ip_address( + params.map.clone(), + params.stream.clone(), + params.received_ip, + ) + .await + { + return false; + } + // verify handshake + if !verify_handshake( + params.stream.clone(), + params.map.clone(), + params.hash, + params.received_signed_message, + params.received_address, + ) + .await + { + return false; + } + true +} diff --git a/src/rpc/server/mod.rs b/src/rpc/server/mod.rs new file mode 100644 index 0000000..dc3d35e --- /dev/null +++ b/src/rpc/server/mod.rs @@ -0,0 +1,11 @@ +// The rpc server module contains the listener, handshake, and command-loop entrypoints. +pub mod connection_memory_manager; +pub mod command_loop_state; +pub mod handshake; +pub mod handshake_processing; +pub mod handshake_verifications; +pub mod flood_protection; +pub mod rpc_command_loop; +pub mod start_rpc; +pub mod structs; +pub mod tests; diff --git a/src/rpc/server/rpc_command_loop.rs b/src/rpc/server/rpc_command_loop.rs new file mode 100644 index 0000000..023ce00 --- /dev/null +++ b/src/rpc/server/rpc_command_loop.rs @@ -0,0 +1,833 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::records::memory::enums::ClientType; +use crate::records::memory::response_channels::Command; +use crate::rpc::server::command_loop_state::next_incoming_command; +use crate::rpc::server::connection_memory_manager::remove_stream_from_memory; +use crate::rpc::*; +use crate::sled::Db; +use crate::Arc; +use crate::AsyncWriteExt; +use crate::encode; +use crate::Mutex; +use crate::TcpStream; + + +pub async fn start_loop( + stream_locked: Arc>, + db: Db, + connections_key: String, + wallet_key: String, + map: Arc>, +) -> Result<(), String> { + 'outer: loop { + let Some(incoming_command) = next_incoming_command( + stream_locked.clone(), + &db, + &connections_key, + &wallet_key, + ) + .await? + else { + break 'outer Ok(()); + }; + let command = incoming_command.command; + let ip = incoming_command.ip; + let client_type = incoming_command.client_type; + + match command { + 1 => { + // requst network info + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::network_info::request_network_info(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 2 => { + // request the current block height + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::block_height::request_block_height(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 3 => { + // request a random node + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::random_node::request_node(&connections_key).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 4 => { + // request the current time + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::time::request_time().await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 5 => { + // request current block difficulty + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::difficulty::request_difficulty(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 6 => { + // validate a torrent file + let (uid, result) = commands::validate_torrent::validate( + &connections_key, + stream_locked.clone(), + &db, + &wallet_key, + ) + .await?; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 7 => { + // request a piece of a block + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let block_number = read_bytes_from_stream::read_u32_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let block_piece = read_bytes_from_stream::read_u8_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let info_hash = read_bytes_from_stream::read_u128_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::torrent::request_block_piece( + &db, + block_number, + block_piece, + info_hash, + ) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 8 => { + // submit a transaction + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let txtype = read_bytes_from_stream::read_u8_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let txsize = command_maps::get_bytes(txtype); + if txsize == 0 { + let result = responses::RpcResponse::Binary( + "error: No such transaction type".as_bytes().to_vec(), + ); + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + continue; + } + let size = (txsize - 1) as usize; + let tx = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + size, + stream_locked.clone(), + ) + .await?; + + let result = commands::tx_submit::save_and_submit(txtype, tx, &db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 9 => { + // request a block by block height + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let block_number = read_bytes_from_stream::read_u32_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::block_by_height::request_block(block_number).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 10 => { + // request a block by block hash + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let hash = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + 32, + stream_locked.clone(), + ) + .await?; + let hash = encode(hash); + + let result = commands::block_by_hash::request_block(&db, &hash).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 11 => { + // request the latest block + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let result = commands::latest_block::request_latest_block(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 12 => { + // request the torrent of a given block + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let block_number = read_bytes_from_stream::read_u32_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::torrent_by_block::request_block_torrent(&block_number).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 13 => { + // request the largest txfee + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::largest_tx_fee::request_largest_tx_fee().await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 14 => { + // get transaction from mempool by signature + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let signature = read_bytes_from_stream::read_signature_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = + commands::memory_by_signature::request_transaction_by_signature(signature) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 15 => { + // get total count of tranasctions in mempool + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::tx_count_from_mempool::request_tx_count_from_mempool().await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 16 => { + // get all transactions of a given address from memory + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let address = read_bytes_from_stream::read_short_address_string_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = + commands::transactions_by_address::request_transactions_by_address(&db, &address) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 17 => { + // get a transaction from given txid + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let hash = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + 32, + stream_locked.clone(), + ) + .await?; + + let result = commands::transaction_by_txid::request_transaction_by_txid_with_block( + &db, hash, + ) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 18 => { + // get total confirmed transaction count + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::tx_count::request_tx_count(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 19 => { + // get header of given height + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let block_number = read_bytes_from_stream::read_u32_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = + commands::block_header_by_height::lookup_by_block_number(&db, block_number) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 20 => { + // get header of given block hash + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let hash = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + 32, + stream_locked.clone(), + ) + .await?; + let hash = encode(hash); + + let result = commands::block_header_by_hash::lookup_by_hash(&db, &hash).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 21 => { + // get all block headers + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let result = commands::block_headers::get_all_headers(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 22 => { + // get balance of address for given coin or token + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let coin = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + 15, + stream_locked.clone(), + ) + .await?; + let coin = binary_to_string(coin); + let address = read_bytes_from_stream::read_short_address_string_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = + commands::address_coin_lookup::lookup_wallet_coin(&db, address, coin).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 23 => { + // get complete balance sheet for a wallet. + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let address = read_bytes_from_stream::read_short_address_string_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = + commands::address_complete_balance_sheet::get_token_balances(&db, address) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 24 => { + // validate wallet address + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let address = read_bytes_from_stream::read_short_address_string_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::validate_address::validate(address, &db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 25 => { + // validate signed message + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let message_size = read_bytes_from_stream::read_u16_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let message_bytes = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + message_size as usize, + stream_locked.clone(), + ) + .await?; + let message = binary_to_string(message_bytes); + let address = read_bytes_from_stream::read_wallet_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let signature = read_bytes_from_stream::read_signature_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = + commands::validate_message::validate(message, address, signature).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 26 => { + // unblock and ip address + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let ip = read_bytes_from_stream::read_ip_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let signature = read_bytes_from_stream::read_signature_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = + commands::block_peer_ip::block_peer(&db, ip, signature, wallet_key.to_string()) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 27 => { + // block an ip address + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let ip = read_bytes_from_stream::read_ip_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let signature = read_bytes_from_stream::read_signature_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::unblock_peer_ip::unblock_peer( + &db, + ip, + signature, + wallet_key.to_string(), + ) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 28 => { + // add a new network node + let (uid, result) = commands::add_network_node::add_network_node( + &connections_key, + stream_locked.clone(), + &db, + &wallet_key, + map.clone(), + ) + .await?; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 29 => { + // delete a new network node + let (uid, result) = commands::delete_network_node::delete_network_node( + &connections_key, + stream_locked.clone(), + &db, + &wallet_key, + map.clone(), + ) + .await?; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 30 => { + // request node list + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::request_valid_nodes::request_valid_nodes().await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 31 => { + // request token list + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::token_list::get_tokens(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 32 => { + // request nft list + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::nft_list::get_nfts(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 33 => { + // request contract and all payments + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let contract_hash = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + 32, + stream_locked.clone(), + ) + .await?; + + let result = commands::contract::contract_details(contract_hash, &db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 34 => { + // submit torrent file + let (uid, result) = commands::receive_torrent::receive_torrent( + &connections_key, + stream_locked.clone(), + &db, + &wallet_key, + map.clone(), + ) + .await?; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 35 => { + // request token details + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let token_name = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + 15, + stream_locked.clone(), + ) + .await?; + let token_name = binary_to_string(token_name); + + let result = commands::token_lookup::lookup_token_details(&db, token_name).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 36 => { + // request nft details + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let nft_name = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + 15, + stream_locked.clone(), + ) + .await?; + let nft_name = binary_to_string(nft_name); + let series = read_bytes_from_stream::read_u32_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::nft_lookup::lookup_nft_details(&db, nft_name, series).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 37 => { + // request contract details by address + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let address = read_bytes_from_stream::read_short_address_string_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::contract::contract_details_by_address(address, &db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 38 => { + // register a deterministic short wallet address + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let short_address = read_bytes_from_stream::read_short_address_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let long_address = read_bytes_from_stream::read_wallet_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let signature = read_bytes_from_stream::read_signature_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::wallet_register::register( + short_address, + long_address, + signature, + &db, + map.clone(), + ip.clone(), + connections_key.clone(), + ) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 39 => { + // request the deterministic short wallet registry snapshot + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::wallet_registry_sync::request_registry(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 40 => { + // request the vanity address registered to a canonical wallet + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let address = read_bytes_from_stream::read_short_address_string_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::wallet_vanity_lookup::lookup(address, &db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 41 => { + // request canonical and staged torrent candidates from snapshot onward + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::torrent_candidates::request_torrent_candidates(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 255 => { + commands::route_reply::route_reply( + &connections_key, + stream_locked.clone(), + &db, + &wallet_key, + map.clone(), + &ip, + client_type, + ) + .await?; + } + _ => { + // Unknown commands are ignored at the protocol level but + // still count as bad RPC behavior for scoring purposes. + commands::bad_rpc_call::record(&ip, client_type, &db, &wallet_key).await; + } + } + + // Wallet-backed client sessions are short-lived request/response + // connections, so close them after each non-reply command. + if client_type == ClientType::Client && command != command_maps::RPC_REPLY { + let mut stream = stream_locked.lock().await; + let _ = stream.shutdown().await; + drop(stream); + remove_stream_from_memory(&stream_locked).await; + break 'outer Ok(()); + } + } +} diff --git a/src/rpc/server/start_rpc.rs b/src/rpc/server/start_rpc.rs new file mode 100644 index 0000000..1b03014 --- /dev/null +++ b/src/rpc/server/start_rpc.rs @@ -0,0 +1,71 @@ +use crate::records::memory::response_channels::Command; +use crate::rpc::server::handshake::handle_handshake; +use crate::log::error; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; +use crate::SocketAddr; +use crate::TcpListener; + +// wait incomming connections +pub async fn start_rpc( + db: &Db, + server_address: String, + wallet_key: String, + map: Arc>, +) { + // Parse once at startup so the accept loop can work with a concrete + // socket address instead of reparsing the settings string each time. + let server_socket: SocketAddr = server_address + .parse() + .expect("Failed to parse server address"); + let db_clone = db.clone(); + + // The listener runs in the background while startup continues with + // the rest of node initialization. + tokio::spawn(async move { + rpc_server(server_socket, &db_clone, wallet_key, map).await; + }); +} + +// generate a connection when it comes in +async fn rpc_server( + server_socket: SocketAddr, + db: &Db, + wallet_key: String, + map: Arc>, +) { + // Bind failure means this node cannot accept RPC traffic, so log the + // reason and leave the background task instead of panicking. + let listener = match TcpListener::bind(&server_socket).await { + Ok(listener) => listener, + Err(e) => { + error!("Failed to bind to socket: {e:?}"); + return; + } + }; + loop { + match listener.accept().await { + Ok((stream, _)) => { + // Every accepted socket gets its own handshake task so + // slow peers do not block the listener from accepting. + let stream = Arc::new(Mutex::new(stream)); + let db_clone = db.clone(); + let wallet_key_clone = wallet_key.clone(); + let map_clone = map.clone(); + tokio::spawn(async move { + handle_handshake( + stream, + db_clone, + wallet_key_clone, + map_clone, + ) + .await; + }); + } + Err(e) => { + error!("Error accepting connection: {e:?}"); + } + } + } +} diff --git a/src/rpc/server/structs.rs b/src/rpc/server/structs.rs new file mode 100644 index 0000000..3aaa581 --- /dev/null +++ b/src/rpc/server/structs.rs @@ -0,0 +1,111 @@ +use crate::records::memory::enums::ClientType; +use crate::records::memory::response_channels::Command; +use crate::rpc::server::flood_protection::{RPC_LONG_WINDOW_LIMIT, RPC_LONG_WINDOW_SECS, RPC_SHORT_WINDOW_SECS}; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; + +pub struct IncomingCommand { + pub command: u8, + pub ip: String, + pub client_type: ClientType, +} + +// CombineAndSendDataParams carries the accepted handshake context into +// the response writer and RPC-loop spawner. +pub struct CombineAndSendDataParams { + pub stream: Arc>, + pub db: Db, + pub connections_key: String, + pub connection_type: String, + pub wallet_key: String, + pub map: Arc>, + pub returned_address: String, +} + +// HandshakeTestParams groups the parsed handshake data and shared +// server context needed to validate an incoming peer connection. +pub struct HandshakeTestParams<'a> { + pub map: Arc>, + pub stream: Arc>, + pub count: usize, + pub peer_time: u32, + pub timestamp: u32, + pub incoming_connections: u8, + pub hash: &'a str, + pub received_signed_message: &'a str, + pub received_address: &'a str, + pub received_ip: &'a str, +} + + +// RpcFloodState is stored as a compact sled value so flood counters can +// survive across commands without keeping a separate in-memory map. +pub struct RpcFloodState { + pub short_window_start: i64, + pub short_window_count: u32, + pub long_window_start: i64, + pub long_window_count: u32, +} + +impl RpcFloodState { + pub fn new(now: i64) -> Self { + // New subjects start both flood windows at the current timestamp + // with zero counted requests. + Self { + short_window_start: now, + short_window_count: 0, + long_window_start: now, + long_window_count: 0, + } + } + + pub fn from_bytes(bytes: &[u8]) -> Option { + // The serialized form is four little-endian fields: + // i64, u32, i64, u32. + if bytes.len() != 24 { + return None; + } + + Some(Self { + short_window_start: i64::from_le_bytes(bytes[0..8].try_into().ok()?), + short_window_count: u32::from_le_bytes(bytes[8..12].try_into().ok()?), + long_window_start: i64::from_le_bytes(bytes[12..20].try_into().ok()?), + long_window_count: u32::from_le_bytes(bytes[20..24].try_into().ok()?), + }) + } + + pub fn to_bytes(&self) -> [u8; 24] { + // Keep the byte layout stable because old flood tracker rows may + // still exist while the node is running. + let mut bytes = [0u8; 24]; + bytes[0..8].copy_from_slice(&self.short_window_start.to_le_bytes()); + bytes[8..12].copy_from_slice(&self.short_window_count.to_le_bytes()); + bytes[12..20].copy_from_slice(&self.long_window_start.to_le_bytes()); + bytes[20..24].copy_from_slice(&self.long_window_count.to_le_bytes()); + bytes + } + + pub fn record_request(&mut self, now: i64) { + // Expired windows reset before counting the current request. + if now.saturating_sub(self.short_window_start) >= RPC_SHORT_WINDOW_SECS { + self.short_window_start = now; + self.short_window_count = 0; + } + + if now.saturating_sub(self.long_window_start) >= RPC_LONG_WINDOW_SECS { + self.long_window_start = now; + self.long_window_count = 0; + } + + self.short_window_count = self.short_window_count.saturating_add(1); + self.long_window_count = self.long_window_count.saturating_add(1); + } + + pub fn is_flooding(&self) -> bool { + // Scoring uses the long window so normal short bursts from real + // clients do not immediately penalize a peer. + self.long_window_count > RPC_LONG_WINDOW_LIMIT + } +} diff --git a/src/rpc/server/tests.rs b/src/rpc/server/tests.rs new file mode 100644 index 0000000..7e578f3 --- /dev/null +++ b/src/rpc/server/tests.rs @@ -0,0 +1,57 @@ +use crate::common::network_startup::is_public_network_address; +use crate::Arc; +use crate::Mutex; +use crate::SocketAddr; +use crate::TcpStream; + +pub fn endpoint_port(ip_port: &str) -> Option { + ip_port + .rsplit_once(':') + .and_then(|(_, port)| port.parse::().ok()) +} + +// Verify the announced endpoint is public and matches the connected peer. +pub async fn ip_test(ip_port: &str, stream: &Arc>) -> bool { + // Private, loopback, malformed, and otherwise non-public addresses + // are rejected before comparing them with the connected socket. + if !is_public_network_address(ip_port) { + return false; + } + + let stream_guard = stream.lock().await; + let remote_addr = stream_guard.peer_addr(); + + // Strip the port from either IPv4 host:port or bracketed IPv6 + // [host]:port forms before comparing against the socket address. + let submitted_ip = ip_port + .strip_prefix('[') + .and_then(|value| value.split_once(']').map(|(ip, _)| ip)) + .or_else(|| ip_port.rsplit_once(':').map(|(ip, _)| ip)) + .unwrap_or(ip_port); + + remote_addr.is_ok_and(|addr| addr.ip().to_string() == submitted_ip) +} + +// check if port is open +pub async fn is_port_open(ip_port: &str) -> Result> { + // Port 0 is the explicit client marker; it is never a miner endpoint. + if endpoint_port(ip_port) == Some(0) { + return Ok(false); + } + + // A successful outbound connect confirms the peer can accept inbound + // node traffic on the port it advertised. + let Ok(addr) = ip_port.parse::() else { + return Ok(false); + }; + match tokio::net::TcpStream::connect(addr).await { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } +} + +// verify connecting client time is within 1 second of our own. +pub fn is_within_one_second(timestamp1: u32, timestamp2: u32) -> bool { + let duration = timestamp1.wrapping_sub(timestamp2); + duration <= 1 || timestamp2.wrapping_sub(timestamp1) <= 1 +} diff --git a/src/standalone_tools/connections/handshake.rs b/src/standalone_tools/connections/handshake.rs new file mode 100644 index 0000000..b5c90bc --- /dev/null +++ b/src/standalone_tools/connections/handshake.rs @@ -0,0 +1,200 @@ +use crate::common::binary_conversions::ip_port_to_binary; +use crate::common::network_startup::get_ip_and_port; +use crate::common::skein::skein_256_hash_data; +use crate::records::memory::response_channels::Byte3; +use crate::rpc::handshake_constants::{ + HANDSHAKE_ADDRESS_OFFSET, HANDSHAKE_MESSAGE_BYTES, HANDSHAKE_REQUEST_BYTES, + HANDSHAKE_RESPONSE_BYTES, HANDSHAKE_SIGNATURE_OFFSET, +}; +use crate::standalone_tools::connections::sending_request::request; +use crate::wallets::structures::Wallet; +use crate::{AsyncReadExt, AsyncWriteExt}; +use crate::{decode, encode}; +use crate::Duration; +use crate::io; +use crate::SocketAddr; +use crate::TcpStream; +use crate::timeout; +use crate::Utc; + +pub enum HandshakeWallet { + // Most standalone tools only have the wallet password and need to load the saved wallet. + WalletKey(String), + // Wallet recovery tools already have the address and private key before the wallet is saved. + WalletParts { + long_address: String, + private_key: String, + }, +} + +pub async fn connect_and_handshake( + addr: SocketAddr, + json: String, + rpc_command: usize, + wallet_source: HandshakeWallet, + hashmap_key: Byte3, +) -> Result, io::Error> { + // Resolve the wallet material before opening the stream so the handshake can sign its challenge. + let (long_address, private_key) = match wallet_source { + HandshakeWallet::WalletKey(encryption_key) => { + let wallet = Wallet::try_obtain_wallet(encryption_key, None) + .await + .map_err(io::Error::other)?; + (wallet.saved.long_address, wallet.saved.private_key) + } + HandshakeWallet::WalletParts { + long_address, + private_key, + } => (long_address, private_key), + }; + + let stream = TcpStream::connect(addr).await?; + + // Once connected, prove wallet ownership to the peer and then send the requested RPC payload. + let result = perform_handshake( + stream, + json, + rpc_command, + long_address, + private_key, + hashmap_key, + ) + .await?; + Ok(result) +} + +async fn get_client_ip() -> String { + // The standalone client announces its local IP with port 0 because it is not opening a server port. + let (_port, _ip, real_ip) = get_ip_and_port().await; + let parts: Vec<&str> = real_ip.split(':').collect(); + let ip = parts.first().copied().unwrap_or("127.0.0.1"); + format!("{ip}:0") +} + +async fn perform_handshake( + mut stream: TcpStream, + json: String, + rpc_command: usize, + address: String, + private_key: String, + hashmap_key: Byte3, +) -> Result, io::Error> { + let real_ip = get_client_ip().await; + + // The client challenge is a fixed marker. The signature proves this wallet owns the private key. + let message = "aced"; + let hash = skein_256_hash_data(message); + let signed_message = Wallet::sign_transaction(&hash, &private_key).await; + + // The peer expects the handshake as fixed-width binary fields in this order. + let address_bin = Wallet::long_address_to_bytes(address); + let message_bin = + decode(message).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let signed_bin = + decode(signed_message).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let ip = ip_port_to_binary(&real_ip).map_err(io::Error::other)?; + let time_bin = (Utc::now().timestamp() as u32).to_le_bytes(); + let mut data = Vec::with_capacity(HANDSHAKE_REQUEST_BYTES); + + // Build the complete client handshake packet. + data.extend_from_slice(&message_bin); + data.extend_from_slice(&signed_bin); + data.extend_from_slice(&address_bin); + data.extend_from_slice(&time_bin); + data.extend_from_slice(&ip); + + // Send the handshake before any standalone RPC request bytes are written. + if let Err(err) = stream.write_all(data.as_slice()).await { + eprintln!("Error writing to stream: {err:?}"); + } else if let Err(err) = stream.flush().await { + eprintln!("Error flushing stream: {err:?}"); + } + + // Read until the full fixed-size handshake response arrives or the peer closes the stream. + let mut buffer = vec![0u8; HANDSHAKE_RESPONSE_BYTES]; + let mut total_read = 0usize; + loop { + if total_read >= buffer.len() { + break; + } + let read_result = timeout( + Duration::from_secs(10), + stream.read(&mut buffer[total_read..]), + ) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "Timed out waiting for handshake response", + ) + })??; + if read_result == 0 { + if total_read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Peer closed handshake without sending a response", + )); + } + break; + } + total_read += read_result; + } + + let received = buffer[..total_read].to_vec(); + + // Some peers may return a plain-text rejection instead of the binary handshake response. + if let Ok(text) = String::from_utf8(received.clone()) { + let trimmed = text.trim_matches(char::from(0)).trim().to_string(); + if !trimmed.is_empty() + && (trimmed == "The connection is already in the connection manager Please wait 10 minutes and try again" + || trimmed.starts_with("error:")) + { + return Err(io::Error::other(trimmed)); + } + } + + if total_read != HANDSHAKE_RESPONSE_BYTES { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!( + "Incomplete handshake response: received {total_read} of {HANDSHAKE_RESPONSE_BYTES} bytes" + ), + )); + } + + // Split the fixed-size server response into marker, signature, and long-address fields. + let response = &received; + let returned_message_bin = &response[..HANDSHAKE_MESSAGE_BYTES]; + let returned_signed_bin = &response[HANDSHAKE_SIGNATURE_OFFSET..HANDSHAKE_ADDRESS_OFFSET]; + let returned_address_bin = &response[HANDSHAKE_ADDRESS_OFFSET..HANDSHAKE_RESPONSE_BYTES]; + + // Convert the returned binary fields into the formats used by wallet signature verification. + let returned_message = encode(returned_message_bin); + let returned_signed_message = encode(returned_signed_bin); + let complete_returned_address = Wallet::bytes_to_long_address(returned_address_bin.to_vec()); + if complete_returned_address.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Handshake returned an invalid wallet address", + )); + } + + // The server challenge must be the expected marker and its signature must match its address. + let hash = skein_256_hash_data(&returned_message); + if returned_message == "ecaf" { + if Wallet::verify_transaction(&hash, &returned_signed_message, &complete_returned_address) + .await + { + // At this point the handshake is complete, so the stream can carry the RPC request. + request(&mut stream, json, rpc_command, hashmap_key).await + } else { + Err(io::Error::other( + "Handshake failed: Invalid response", + )) + } + } else { + Err(io::Error::other( + "Handshake failed: Invalid response", + )) + } +} diff --git a/src/standalone_tools/connections/mod.rs b/src/standalone_tools/connections/mod.rs new file mode 100644 index 0000000..68b32b2 --- /dev/null +++ b/src/standalone_tools/connections/mod.rs @@ -0,0 +1,3 @@ +// The connections helpers support standalone tools that need to behave like RPC clients. +pub mod handshake; +pub mod sending_request; diff --git a/src/standalone_tools/connections/sending_request.rs b/src/standalone_tools/connections/sending_request.rs new file mode 100644 index 0000000..4f1398a --- /dev/null +++ b/src/standalone_tools/connections/sending_request.rs @@ -0,0 +1,459 @@ +use crate::common::binary_conversions::ip_to_binary; +use crate::records::memory::response_channels::Byte3; +use crate::rpc::command_maps::{ + RPC_ADDRESS_TOTAL_BALANCE, RPC_BLOCK_BY_HASH, RPC_BLOCK_BY_HEIGHT, RPC_BLOCK_HEIGHT, + RPC_BLOCK_IP, RPC_CONTRACT_BY_ADDRESS, RPC_DIFFICULTY, RPC_LARGEST_TX_FEE, + MAX_RPC_REPLY_BYTES, + RPC_LOAN_CONTRACT, RPC_MEMPOOL_TX_BY_ADDRESS, RPC_MEMPOOL_TX_BY_SIGNATURE, + RPC_MEMPOOL_TX_COUNT, RPC_NETWORK_INFO, RPC_NFT_DETAILS, RPC_NFT_LIST, RPC_REGISTER_WALLET, + RPC_TIME, RPC_TOKEN_DETAILS, RPC_TOKEN_LIST, RPC_TORRENT_BY_HEIGHT, RPC_TOTAL_CONFIRMED_TX, + RPC_TRANSACTION_BY_TXID, RPC_UNBLOCK_IP, RPC_VANITY_LOOKUP, +}; +use crate::standalone_tools::transaction_creator::create_transaction_request; +use crate::wallets::structures::Wallet; +use crate::{AsyncReadExt, AsyncWriteExt}; +use crate::decode; +use crate::Duration; +use crate::io; +use crate::TcpStream; +use crate::timeout; + +pub async fn request( + stream: &mut TcpStream, + command_input: String, + rpc_command: usize, + hashmap_key: Byte3, +) -> Result, io::Error> { + // Build the command-specific wire bytes before writing anything to the stream. + let bin_msg = build_request_bytes(command_input, rpc_command, hashmap_key).await?; + + // Send the standalone RPC request across the already-verified handshake stream. + stream.write_all(&bin_msg).await?; + stream.flush().await?; + + // The first 4 reply bytes are the response UID/header. Standalone tools only need + // the payload, so this reads and discards those bytes before reading the message. + let mut discard_buffer = [0u8; 4]; + timeout( + Duration::from_secs(10), + stream.read_exact(&mut discard_buffer), + ) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "Timed out waiting for RPC reply header", + ) + })??; + + // The next 4 bytes tell us how large the actual reply payload is. + let mut length_buffer = [0u8; 4]; + timeout( + Duration::from_secs(10), + stream.read_exact(&mut length_buffer), + ) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "Timed out waiting for RPC reply length", + ) + })??; + + let length = u32::from_le_bytes(length_buffer) as usize; + if length > MAX_RPC_REPLY_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("RPC reply payload too large: len={length} max={MAX_RPC_REPLY_BYTES}"), + )); + } + + // Read and return only the command payload so each standalone tool can decode + // the response according to the command it sent. + let mut message_buffer = vec![0u8; length]; + timeout( + Duration::from_secs(10), + stream.read_exact(&mut message_buffer), + ) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "Timed out waiting for RPC reply payload", + ) + })??; + + Ok(message_buffer) +} + +async fn build_request_bytes( + command_input: String, + rpc_command: usize, + hashmap_key: Byte3, +) -> Result, io::Error> { + let mut bin_msg: Vec = Vec::new(); + + match rpc_command { + // Network-info request. + 1 => { + let command_number: u8 = RPC_NETWORK_INFO; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } + // Network height request. + 2 => { + let command_number: u8 = RPC_BLOCK_HEIGHT; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } + // Node UTC timestamp request. + 4 => { + let command_number: u8 = RPC_TIME; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } + // Difficulty request. + 5 => { + let command_number: u8 = RPC_DIFFICULTY; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } + // Broadcast a signed transaction built by the standalone transaction creator. + 8 => { + let msg = create_transaction_request(command_input, hashmap_key).await.map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Failed to build broadcast request: {err}"), + ) + })?; + bin_msg.extend_from_slice(&msg); + } + // Lookup a block by height. + 9 => { + let command_number: u8 = RPC_BLOCK_BY_HEIGHT; + let block_number = command_input.parse::().map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid block number") + })?; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&block_number.to_le_bytes()); + } + // Lookup a block by 32-byte hash. + 10 => { + let command_number: u8 = RPC_BLOCK_BY_HASH; + let block_hash = decode(&command_input).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid block hash: {err}"), + ) + })?; + if block_hash.len() != 32 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Block hash must decode to 32 bytes", + )); + } + + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&block_hash); + } + // Lookup a torrent by block height. + 12 => { + let command_number: u8 = RPC_TORRENT_BY_HEIGHT; + let block_number = command_input.parse::().map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid torrent block number") + })?; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&block_number.to_le_bytes()); + } + // Total transaction count request. + 13 => { + let command_number: u8 = RPC_LARGEST_TX_FEE; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } + // Lookup one unprocessed mempool transaction by signature. + 14 => { + let command_number: u8 = RPC_MEMPOOL_TX_BY_SIGNATURE; + let signature = decode(&command_input).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid signature: {err}"), + ) + })?; + if signature.len() != Wallet::SIGNATURE_LENGTH { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Signature has the wrong byte length", + )); + } + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&signature); + } + // Mempool transaction count request. + 15 => { + let command_number: u8 = RPC_MEMPOOL_TX_COUNT; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } + // Lookup unprocessed mempool transactions tied to an address. + 16 => { + let command_number: u8 = RPC_MEMPOOL_TX_BY_ADDRESS; + let address_bytes = Wallet::normalize_to_short_address(&command_input) + .and_then(|short| Wallet::short_address_to_bytes(&short)) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid wallet address") + })?; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&address_bytes); + } + // Lookup a transaction by 32-byte transaction id. + 17 => { + let command_number: u8 = RPC_TRANSACTION_BY_TXID; + let txid = decode(&command_input).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid transaction id: {err}"), + ) + })?; + if txid.len() != 32 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Transaction id must decode to 32 bytes", + )); + } + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&txid); + } + // Largest transaction-fee request. + 18 => { + let command_number: u8 = RPC_TOTAL_CONFIRMED_TX; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } + // Lookup a wallet balance by long, short, or vanity address. + 23 => { + let command_number: u8 = RPC_ADDRESS_TOTAL_BALANCE; + let address_bytes = Wallet::normalize_to_short_address(&command_input) + .and_then(|short| Wallet::short_address_to_bytes(&short)) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid wallet address") + })?; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&address_bytes) + } + // Server-owner block IP request. + 26 => { + let command_number: u8 = RPC_BLOCK_IP; + let (ip, signature) = command_input.split_once('|').ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Server owner IP command input must be ip|signature", + ) + })?; + let ip_bytes = ip_to_binary(ip); + if ip_bytes.len() != 16 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid IP address")); + } + let signature_bytes = decode(signature).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid signature: {err}"), + ) + })?; + if signature_bytes.len() != Wallet::SIGNATURE_LENGTH { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Signature has the wrong byte length", + )); + } + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&ip_bytes); + bin_msg.extend_from_slice(&signature_bytes); + } + // Server-owner unblock IP request. + 27 => { + let command_number: u8 = RPC_UNBLOCK_IP; + let (ip, signature) = command_input.split_once('|').ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Server owner IP command input must be ip|signature", + ) + })?; + let ip_bytes = ip_to_binary(ip); + if ip_bytes.len() != 16 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid IP address")); + } + let signature_bytes = decode(signature).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid signature: {err}"), + ) + })?; + if signature_bytes.len() != Wallet::SIGNATURE_LENGTH { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Signature has the wrong byte length", + )); + } + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&ip_bytes); + bin_msg.extend_from_slice(&signature_bytes); + } + // Token-list request. + 31 => { + let command_number: u8 = RPC_TOKEN_LIST; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } + // NFT-list request. + 32 => { + let command_number: u8 = RPC_NFT_LIST; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } + // Lookup a loan contract by 32-byte contract hash. + 33 => { + let command_number: u8 = RPC_LOAN_CONTRACT; + let contract_bytes = decode(&command_input).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid contract hash: {err}"), + ) + })?; + if contract_bytes.len() != 32 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Contract hash must decode to 32 bytes", + )); + } + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&contract_bytes); + } + // Lookup token metadata by fixed-width ticker/name. + 35 => { + let command_number: u8 = RPC_TOKEN_DETAILS; + if command_input.trim().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Token name cannot be empty", + )); + } + let mut token_bytes = command_input.into_bytes(); + token_bytes.truncate(15); + if token_bytes.len() < 15 { + token_bytes.resize(15, b' '); + } + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&token_bytes); + } + // Lookup NFT metadata by "name|series". + 36 => { + let command_number: u8 = RPC_NFT_DETAILS; + let (name, series_text) = command_input.split_once('|').ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "NFT lookup input must be name|series", + ) + })?; + if name.trim().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "NFT name cannot be empty", + )); + } + let series = series_text.parse::().map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid NFT series") + })?; + let mut nft_name_bytes = name.as_bytes().to_vec(); + nft_name_bytes.truncate(15); + if nft_name_bytes.len() < 15 { + nft_name_bytes.resize(15, b' '); + } + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&nft_name_bytes); + bin_msg.extend_from_slice(&series.to_le_bytes()); + } + // Lookup transactions by long, short, or vanity address. + 37 => { + let command_number: u8 = RPC_CONTRACT_BY_ADDRESS; + let address_bytes = Wallet::normalize_to_short_address(&command_input) + .and_then(|short| Wallet::short_address_to_bytes(&short)) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid wallet address") + })?; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&address_bytes); + } + // Register a wallet using "short_address|long_address|signature". + 38 => { + let command_number: u8 = RPC_REGISTER_WALLET; + let (short_address, rest) = command_input.split_once('|').ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Wallet registration input must be short|long|signature", + ) + })?; + let (long_address, signature) = rest.split_once('|').ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Wallet registration input must be short|long|signature", + ) + })?; + let short_address_bytes = Wallet::short_address_to_bytes(short_address).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid short wallet address") + })?; + let long_address_bytes = Wallet::long_address_to_bytes(long_address.to_string()); + if long_address_bytes.len() != Wallet::ADDRESS_BYTES_LENGTH { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid long wallet address", + )); + } + let signature_bytes = decode(signature).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid wallet registration signature: {err}"), + ) + })?; + + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&short_address_bytes); + bin_msg.extend_from_slice(&long_address_bytes); + bin_msg.extend_from_slice(&signature_bytes); + } + // Lookup remote balance by long, short, or vanity address. + 40 => { + let command_number: u8 = RPC_VANITY_LOOKUP; + let address_bytes = Wallet::normalize_to_short_address(&command_input) + .and_then(|short| Wallet::short_address_to_bytes(&short)) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid wallet address") + })?; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&address_bytes); + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Unsupported standalone RPC command: {rpc_command}"), + )); + } + } + + Ok(bin_msg) +} diff --git a/src/standalone_tools/mod.rs b/src/standalone_tools/mod.rs new file mode 100644 index 0000000..632a9d1 --- /dev/null +++ b/src/standalone_tools/mod.rs @@ -0,0 +1,3 @@ +// The standalone_tools module holds utility helpers that mirror node serialization and connection behavior. +pub mod connections; +pub mod transaction_creator; diff --git a/src/standalone_tools/transaction_creator.rs b/src/standalone_tools/transaction_creator.rs new file mode 100644 index 0000000..bcaa920 --- /dev/null +++ b/src/standalone_tools/transaction_creator.rs @@ -0,0 +1,458 @@ +use crate::blocks::burn::{BurnTransaction, UnsignedBurnTransaction}; +use crate::blocks::collateral::{CollateralClaimTransaction, UnsignedCollateralClaimTransaction}; +use crate::blocks::issue_token::{IssueTokenTransaction, UnsignedIssueTokenTransaction}; +use crate::blocks::loan_payment::{ContractPaymentTransaction, UnsignedContractPaymentTransaction}; +use crate::blocks::loans::{LoanContractTransaction, UnsignedLoanContractTransaction}; +use crate::blocks::marketing::{MarketingTransaction, UnsignedMarketingTransaction}; +use crate::blocks::nft::{CreateNftTransaction, UnsignedCreateNftTransaction}; +use crate::blocks::swap::{SwapTransaction, UnsignedSwapTransaction}; +use crate::blocks::token::{CreateTokenTransaction, UnsignedCreateTokenTransaction}; +use crate::blocks::transfer::{TransferTransaction, UnsignedTransferTransaction}; +use crate::blocks::vanity::{UnsignedVanityAddressTransaction, VanityAddressTransaction}; +use crate::records::memory::response_channels::Byte3; +use crate::rpc::command_maps::RPC_SUBMIT_TRANSACTION; +use crate::{from_str, Value}; + +// Some standalone tools accept either timestamp or time in their JSON payloads, so +// this helper keeps both field names compatible. +fn read_u32_field(transaction: &Value, primary: &str, fallback: &str) -> Option { + transaction[primary] + .as_u64() + .or_else(|| transaction[fallback].as_u64()) + .map(|value| value as u32) +} + +pub async fn create_transaction_request( + command_input: String, + hashkey: Byte3, +) -> Result, String> { + // Transaction broadcast requests start with the broadcast command and UID, + // followed by the signed transaction bytes rebuilt from the input JSON. + let transaction: Value = match from_str(command_input.as_str()) { + Ok(data) => data, + Err(_) => return Err("Invalid JSON format".to_string()), + }; + + let mut binary_data = Vec::new(); + binary_data.push(RPC_SUBMIT_TRANSACTION); + binary_data.extend(&hashkey); + + let transaction_bytes = create_transaction_bytes(&transaction).await?; + + binary_data.extend(transaction_bytes); + Ok(binary_data) +} + +async fn create_transaction_bytes(transaction: &Value) -> Result, String> { + let txtype = transaction["txtype"].as_u64().ok_or("Missing txtype.")? as u8; + + // Each transaction type is rebuilt through the same unsigned -> signed -> bytes + // flow used elsewhere so the standalone tool stays aligned with node serialization. + let transaction_bytes = match txtype { + 2 => { + let unsigned_transfer = UnsignedTransferTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["value"] + .as_u64() + .ok_or("Missing transaction value.")?, + transaction["coin"] + .as_str() + .ok_or("Missing or invalid coin.")?, + transaction["nft_series"].as_u64().unwrap_or_default() as u32, + transaction["sender"] + .as_str() + .ok_or("Missing sender wallet address.")?, + transaction["receiver"] + .as_str() + .ok_or("Missing receiver wallet address.")?, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + TransferTransaction::load( + unsigned_transfer, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize transfer transaction: {e}"))? + } + 3 => { + let unsigned_token = UnsignedCreateTokenTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["creator"] + .as_str() + .ok_or("Missing creator wallet address.")?, + transaction["ticker"] + .as_str() + .ok_or("Missing or invalid ticker.")?, + transaction["number"] + .as_u64() + .ok_or("Missing number of tokens.")?, + transaction["hard_limit"] + .as_u64() + .ok_or("Missing hard_limit.")? as u8, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + CreateTokenTransaction::load( + unsigned_token, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize token transaction: {e}"))? + } + 4 => { + let unsigned_nft = UnsignedCreateNftTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["creator"] + .as_str() + .ok_or("Missing creator wallet address.")?, + transaction["series"].as_u64().ok_or("Missing series.")? as u8, + transaction["nft_name"] + .as_str() + .ok_or("Missing or invalid NFT name.")?, + transaction["item_ipfs"] + .as_str() + .ok_or("Missing or invalid item IPFS.")?, + transaction["count"].as_u64().ok_or("Missing count.")? as u32, + transaction["desc"] + .as_str() + .ok_or("Missing or invalid description.")?, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + CreateNftTransaction::load( + unsigned_nft, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize NFT transaction: {e}"))? + } + 5 => { + let unsigned_marketing = UnsignedMarketingTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["campaign"] + .as_u64() + .ok_or("Missing campaign.")?, + transaction["ad_type"].as_str().ok_or("Missing ad type.")?, + transaction["keyword"].as_str().ok_or("Missing keyword.")?, + transaction["displayed"] + .as_str() + .ok_or("Missing display location.")?, + transaction["impression"] + .as_u64() + .ok_or("Missing impression.")? as u8, + transaction["click"].as_u64().ok_or("Missing click.")? as u8, + transaction["impression_value"] + .as_u64() + .ok_or("Missing impression value.")? as u16, + transaction["click_value"] + .as_u64() + .ok_or("Missing click value.")? as u16, + transaction["advertiser"] + .as_str() + .ok_or("Missing advertiser wallet address.")?, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + MarketingTransaction::load( + unsigned_marketing, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize marketing transaction: {e}"))? + } + 6 => { + let unsigned_swap = UnsignedSwapTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["offer_expiration"] + .as_u64() + .ok_or("Missing offer expiration.")? as u32, + transaction["ticker1"] + .as_str() + .ok_or("Missing or invalid ticker1.")?, + transaction["nft_series1"].as_u64().unwrap_or_default() as u32, + transaction["value1"] + .as_u64() + .ok_or("Missing transaction value1.")?, + transaction["ticker2"] + .as_str() + .ok_or("Missing or invalid ticker2.")?, + transaction["nft_series2"].as_u64().unwrap_or_default() as u32, + transaction["value2"] + .as_u64() + .ok_or("Missing transaction value2.")?, + transaction["sender1"] + .as_str() + .ok_or("Missing sender1 wallet address.")?, + transaction["sender2"] + .as_str() + .ok_or("Missing sender2 wallet address.")?, + transaction["tip1"].as_u64().ok_or("Missing tip1.")?, + transaction["tip2"].as_u64().ok_or("Missing tip2.")?, + transaction["txfee1"].as_u64().ok_or("Missing txfee1.")?, + transaction["txfee2"].as_u64().ok_or("Missing txfee2.")?, + ) + .await; + + SwapTransaction::load( + unsigned_swap, + transaction["signature1"] + .as_str() + .ok_or("Missing signature1.")?, + transaction["signature2"] + .as_str() + .ok_or("Missing signature2.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize swap transaction: {e}"))? + } + 7 => { + let collateral_amount = transaction["collateral_amount"] + .as_u64() + .or_else(|| transaction["collateral_value"].as_u64()) + .ok_or("Missing collateral amount.")?; + + let unsigned_loan = UnsignedLoanContractTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["loan_coin"] + .as_str() + .ok_or("Missing or invalid loan coin.")?, + transaction["loan_amount"] + .as_u64() + .ok_or("Missing loan amount.")?, + transaction["lender"] + .as_str() + .ok_or("Missing lender wallet address.")?, + transaction["collateral"] + .as_str() + .ok_or("Missing or invalid collateral.")?, + collateral_amount, + transaction["borrower"] + .as_str() + .ok_or("Missing borrower wallet address.")?, + transaction["payment_period"] + .as_str() + .ok_or("Missing payment period.")?, + transaction["payment_number"] + .as_u64() + .ok_or("Missing payment number.")? as u8, + transaction["payment_amount"] + .as_u64() + .ok_or("Missing payment amount.")?, + transaction["grace_period"] + .as_u64() + .ok_or("Missing grace period.")? as u8, + transaction["max_late_value"] + .as_u64() + .ok_or("Missing max late value.")?, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + LoanContractTransaction::load( + unsigned_loan, + transaction["hash"] + .as_str() + .ok_or("Missing transaction hash.")?, + transaction["signature1"] + .as_str() + .ok_or("Missing signature1.")?, + transaction["signature2"] + .as_str() + .ok_or("Missing signature2.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize loan contract transaction: {e}"))? + } + 8 => { + let unsigned_payment = UnsignedContractPaymentTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["payback_amount"] + .as_u64() + .ok_or("Missing payback amount.")?, + transaction["contract_hash"] + .as_str() + .ok_or("Missing contract hash.")?, + transaction["address"] + .as_str() + .ok_or("Missing sender wallet address.")?, + transaction["tip"].as_u64().unwrap_or_default(), + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + ContractPaymentTransaction::load( + unsigned_payment, + transaction["hash"] + .as_str() + .ok_or("Missing transaction hash.")?, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize contract payment transaction: {e}"))? + } + 9 => { + let unsigned_collateral = UnsignedCollateralClaimTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["contract_hash"] + .as_str() + .ok_or("Missing contract hash.")?, + transaction["address"] + .as_str() + .ok_or("Missing sender wallet address.")?, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + CollateralClaimTransaction::load( + unsigned_collateral, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize collateral claim transaction: {e}"))? + } + 10 => { + let unsigned_burn = UnsignedBurnTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["address"] + .as_str() + .ok_or("Missing burner wallet address.")?, + transaction["coin"] + .as_str() + .ok_or("Missing or invalid coin.")?, + transaction["nft_series"].as_u64().unwrap_or_default() as u32, + transaction["value"].as_u64().ok_or("Missing burn value.")?, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + BurnTransaction::load( + unsigned_burn, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize burn transaction: {e}"))? + } + 11 => { + let unsigned_issue_token = UnsignedIssueTokenTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["creator"] + .as_str() + .ok_or("Missing creator wallet address.")?, + transaction["ticker"] + .as_str() + .ok_or("Missing or invalid ticker.")?, + transaction["number"] + .as_u64() + .ok_or("Missing number of issued tokens.")?, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + IssueTokenTransaction::load( + unsigned_issue_token, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize issue-token transaction: {e}"))? + } + 12 => { + let unsigned_vanity = UnsignedVanityAddressTransaction::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["address"] + .as_str() + .ok_or("Missing sender wallet address.")?, + transaction["vanity_address"] + .as_str() + .ok_or("Missing vanity address.")?, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + + VanityAddressTransaction::load( + unsigned_vanity, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize vanity transaction: {e}"))? + } + _ => return Err("No such transaction type".to_string()), + }; + + Ok(transaction_bytes) +} diff --git a/src/startup/connections.rs b/src/startup/connections.rs new file mode 100644 index 0000000..5894515 --- /dev/null +++ b/src/startup/connections.rs @@ -0,0 +1,90 @@ +use crate::common::network_startup::get_connections; +use crate::log::{error, info}; +use crate::miner::flag::{ + clear_mining_stop_request, set_mining_state, set_node_mode, MiningState, NodeMode, +}; +use crate::records::memory::response_channels::Command; +use crate::rpc::client::handshake::connect_and_handshake; +use crate::rpc::client::structs::Connect; +use crate::sled::Db; +use crate::sleep; +use crate::Arc; +use crate::Duration; +use crate::Mutex; + +pub async fn handle_connections( + db: Db, + wallet_key: String, + map: Arc>, +) -> Result<(), String> { + // A zero outgoing limit means this node should not open any bootstrap + // connection during startup. + let outgoing_connections = crate::Settings::load() + .map(|settings| settings.outgoing_connections) + .unwrap_or(0); + if outgoing_connections == 0 { + info!("OUTGOING_CONNECTIONS is 0; skipping startup bootstrap."); + set_node_mode(NodeMode::Normal); + clear_mining_stop_request(); + set_mining_state(MiningState::Idle); + return Ok(()); + } + + // try the configured bootstrap peers one by one until a + // handshake succeeds or the list is exhausted + let filtered_servers = get_connections().await; + let mut last_error: Option = None; + + for server in filtered_servers { + // build the outbound handshake request using cloned + // shared state so each attempt can run independently + let db_clone = db.clone(); + + let wallet_key_cloned = wallet_key.clone(); + + // parse the configured peer string once before spawning + // the outbound connection attempt + let socket_address = server.parse().expect("Failed to parse the socket address"); + + // Clone the Arc for use in other async functions + let map_clone = Arc::clone(&map); + + let first: bool = true; + let connect_params = Connect { + addr: socket_address, + db: db_clone, + node_ip: server.to_string(), + wallet_key: wallet_key_cloned, + map: map_clone, + first, + }; + + let err_string = match connect_and_handshake(connect_params).await { + Ok(()) => { + info!("Connected to {server}"); + return Ok(()); + } + Err(err) => err.to_string(), + }; + + // A peer can reject us because it already has this connection recorded. + // In that case retrying other bootstrap peers would not fix the local duplicate state. + if err_string.contains("The connection is already in the connection manager Please wait 10 minutes and try again") { + return Err(err_string); + } + error!("Error connecting to {server}: {err_string}"); + last_error = Some(err_string.clone()); + sleep(Duration::from_secs(5)).await; + } + + if let Some(err) = last_error { + info!("No bootstrap peers connected during startup: {err}"); + } else { + info!("No bootstrap peers connected during startup."); + } + // Startup can continue as a standalone node even if no bootstrap peer is reachable. + set_node_mode(NodeMode::Normal); + clear_mining_stop_request(); + set_mining_state(MiningState::Idle); + Ok(()) +} diff --git a/src/startup/daemonize.rs b/src/startup/daemonize.rs new file mode 100644 index 0000000..f20c8ac --- /dev/null +++ b/src/startup/daemonize.rs @@ -0,0 +1,236 @@ +#[cfg(unix)] +use crate::common::network_paths_and_settings::block_extension_and_paths; +#[cfg(unix)] +use crate::env; +#[cfg(unix)] +use crate::lazy_static; +#[cfg(unix)] +use crate::log::{error, info, warn}; +#[cfg(unix)] +use crate::sled::Db; +#[cfg(unix)] +use crate::task; +#[cfg(unix)] +use crate::PathBuf; +#[cfg(unix)] +use nix::sys::signal::{kill, Signal}; +#[cfg(unix)] +use nix::unistd::{daemon, Pid}; +#[cfg(unix)] +use std::sync::Mutex as StdMutex; + +#[cfg(unix)] +lazy_static! { + static ref PID_FILE_PATH: StdMutex> = StdMutex::new(None); +} + +#[cfg(unix)] +fn should_daemonize() -> bool { + // --foreground keeps Linux startup attached to the terminal for debugging. + !env::args().any(|arg| arg == "--foreground") +} + +#[cfg(unix)] +fn read_pid(pid_path: &PathBuf) -> Result> { + // PID files are plain text so shell tools can inspect them too. + let pid_contents = std::fs::read_to_string(pid_path)?; + Ok(pid_contents.trim().parse::()?) +} + +#[cfg(unix)] +fn pid_file_path() -> PathBuf { + // PID files live under the network-scoped db path so testnet/mainnet never collide. + let ( + _network_name, + _padded_base_coin, + _suffix, + _torrent_path, + _wallet_path, + _block_path, + db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + PathBuf::from(db_path).join("contractless.pid") +} + +#[cfg(unix)] +fn existing_process_running(pid_path: &PathBuf) -> Result> { + // The PID file is treated as the source of truth for duplicate-start checks. + if !pid_path.exists() { + return Ok(false); + } + + let pid = read_pid(pid_path)?; + + match kill(Pid::from_raw(pid), None) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } +} + +#[cfg(unix)] +fn remove_pid_file_if_present(pid_path: &PathBuf) { + // Missing PID files are harmless during cleanup. + let _ = std::fs::remove_file(pid_path); +} + +#[cfg(unix)] +fn write_pid_file(pid_path: &PathBuf) -> Result<(), Box> { + // The PID file is written only after the process has detached so later control + // commands point at the background daemon rather than the original shell session. + if let Some(parent) = pid_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let pid = std::process::id().to_string(); + std::fs::write(pid_path, pid)?; + + let mut slot = PID_FILE_PATH.lock().expect("failed to lock pid file slot"); + *slot = Some(pid_path.clone()); + + Ok(()) +} + +#[cfg(unix)] +pub fn remove_registered_pid_file() { + let pid_path = { + let slot = PID_FILE_PATH.lock().expect("failed to lock pid file slot"); + slot.clone() + }; + + if let Some(path) = pid_path { + remove_pid_file_if_present(&path); + } +} + +#[cfg(unix)] +pub fn daemonize_after_wallet_prompt() -> Result> { + // Linux waits until after the wallet prompt to detach so the encryption key never + // needs to be handed off to a second process. + if !should_daemonize() { + return Ok(false); + } + + let pid_path = pid_file_path(); + // Refuse to detach if an active process already owns the network-scoped PID file. + if existing_process_running(&pid_path)? { + return Err(format!( + "Contractless is already running. Remove stale PID file if needed: {}", + pid_path.display() + ) + .into()); + } + + remove_pid_file_if_present(&pid_path); + // daemon(true, false) keeps the working directory but redirects stdio for background mode. + daemon(true, false)?; + write_pid_file(&pid_path)?; + info!("Daemonized node process with pid {}", std::process::id()); + + Ok(true) +} + +#[cfg(unix)] +pub fn handle_control_command() -> Result> { + let args: Vec = env::args().skip(1).collect(); + let pid_path = pid_file_path(); + + // The control commands operate through the PID file rather than by interacting + // with a terminal session, so they work even after the node detaches. + if args.iter().any(|arg| arg == "--status") { + if existing_process_running(&pid_path)? { + let pid = read_pid(&pid_path)?; + println!("Contractless is running with pid {pid}."); + } else if pid_path.exists() { + println!( + "Contractless is not running, but a stale PID file exists at {}.", + pid_path.display() + ); + } else { + println!("Contractless is not running."); + } + return Ok(true); + } + + if args.iter().any(|arg| arg == "--stop") { + if !pid_path.exists() { + println!("Contractless is not running."); + return Ok(true); + } + + let pid = read_pid(&pid_path)?; + // SIGTERM triggers install_shutdown_cleanup in the daemon process. + match kill(Pid::from_raw(pid), Some(Signal::SIGTERM)) { + Ok(_) => { + println!("Sent SIGTERM to Contractless process {pid}."); + } + Err(err) => { + remove_pid_file_if_present(&pid_path); + return Err(format!("Failed to stop Contractless process {pid}: {err}").into()); + } + } + + return Ok(true); + } + + Ok(false) +} + +#[cfg(unix)] +pub fn install_shutdown_cleanup(db: Db) { + task::spawn(async move { + use tokio::signal::unix::{signal, SignalKind}; + + // The Unix shutdown handler flushes sled and removes the PID file so stop, + // reboot, and signal-driven exits leave the node in a clean state. + let mut sigterm = match signal(SignalKind::terminate()) { + Ok(stream) => stream, + Err(err) => { + error!("Failed to register SIGTERM handler: {err}"); + return; + } + }; + + let mut sigint = match signal(SignalKind::interrupt()) { + Ok(stream) => stream, + Err(err) => { + error!("Failed to register SIGINT handler: {err}"); + return; + } + }; + + tokio::select! { + _ = sigterm.recv() => { + warn!("Received SIGTERM, shutting down."); + } + _ = sigint.recv() => { + warn!("Received SIGINT, shutting down."); + } + } + + if let Err(err) = db.flush_async().await { + error!("Failed to flush sled during shutdown: {err}"); + } + + // Removing the PID file here lets the next startup proceed without manual cleanup. + remove_registered_pid_file(); + std::process::exit(0); + }); +} + +#[cfg(not(unix))] +pub fn daemonize_after_wallet_prompt() -> Result> { + Ok(false) +} + +#[cfg(not(unix))] +pub fn handle_control_command() -> Result> { + Ok(false) +} + +#[cfg(not(unix))] +pub fn install_shutdown_cleanup(_db: crate::sled::Db) {} + +#[cfg(not(unix))] +pub fn remove_registered_pid_file() {} diff --git a/src/startup/initialize_startup.rs b/src/startup/initialize_startup.rs new file mode 100644 index 0000000..5c5d79d --- /dev/null +++ b/src/startup/initialize_startup.rs @@ -0,0 +1,109 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::cli_prompts::cli_options; +use crate::exit; +use crate::fs; +use crate::log::error; +use crate::miner::flag::{ + request_mining_stop, set_mining_state, set_node_mode, MiningState, NodeMode, +}; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::averages::{load_initial_blocks, DIFFICULTY_AVERAGE_WINDOW}; +use crate::records::memory::connections::initialize_connection; +use crate::wallets::structures::Wallet; +use crate::Path; + +pub async fn create_file_paths() { + // get all final network-scoped paths from the shared path helper + let ( + _network_name, + _padded_base_coin, + _short_base_coin, + torrent_path, + wallet_path, + block_path, + db_path, + balance_path, + log_path, + ) = block_extension_and_paths(); + + // create_dir_all creates missing parent folders too, so the base + // settings paths do not need to be created separately. + fs::create_dir_all(&block_path).expect("Failed to create blocks folder"); + fs::create_dir_all(&torrent_path).expect("Failed to create torrents folder"); + fs::create_dir_all(&db_path).expect("Failed to create db folder"); + // wallet_path points to the wallet file itself, so only its parent directory is created here. + if let Some(wallet_parent) = Path::new(&wallet_path).parent() { + if !wallet_parent.as_os_str().is_empty() { + fs::create_dir_all(wallet_parent).expect("Failed to create wallet folder"); + } + } + fs::create_dir_all(&balance_path).expect("Failed to create balance sheet folder"); + fs::create_dir_all(&log_path).expect("Failed to create log folder"); +} + +pub async fn obtain_valid_wallet() -> (String, Wallet) { + // keep prompting until a wallet can be opened or created + // using the supplied key material + let wallet_path = Wallet::get_wallet_path().await; + let wallet_exists = Path::new(&wallet_path).exists(); + + loop { + let wallet_key = cli_options().await; + match Wallet::try_obtain_wallet(wallet_key.clone(), None).await { + Ok(wallet) => return (wallet_key, wallet), + Err(e) => { + // Existing wallets fail closed because a bad key cannot safely create a replacement. + if wallet_exists { + eprintln!("Wallet error: {e}."); + error!("Wallet error: {e}."); + exit(1); + } + println!("Wallet error: {e}. Please try again.\n"); + } + } + } +} + +pub async fn prepare_pre_wallet_startup() { + // Prepare the local filesystem and in-memory startup state before any + // wallet-dependent node identity or network activity begins. + set_node_mode(NodeMode::Startup); + request_mining_stop(); + set_mining_state(MiningState::Idle); + initialize_connection().await; + create_file_paths().await; +} + +pub async fn obtain_startup_wallet_key() -> String { + // Open or create the configured wallet and return the validated + // encryption key once the wallet can be used safely. + let (wallet_key, _wallet) = obtain_valid_wallet().await; + wallet_key +} + +pub async fn open_chain_state() -> sled::Db { + // Open the sled state database and warm the rolling averages cache + // once the process is fully ready to continue startup. + let ( + _network_name, + _padded_base_coin, + _suffix, + _torrent_path, + _wallet_path, + _block_path, + db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let db = sled::Config::new() + .path(db_path) + .open() + .expect("Failed to open the database"); + + let latest_block = get_height(&db); + let start_block = latest_block.saturating_sub(DIFFICULTY_AVERAGE_WINDOW.saturating_sub(1)); + // Warm the rolling difficulty cache from the newest window before mining starts. + load_initial_blocks(start_block, latest_block).await; + + db +} diff --git a/src/startup/mod.rs b/src/startup/mod.rs new file mode 100644 index 0000000..c18dcbb --- /dev/null +++ b/src/startup/mod.rs @@ -0,0 +1,10 @@ +// The startup module contains the platform-specific bootstrapping paths that lead into the shared node runtime. +pub mod connections; +pub mod daemonize; +pub mod initialize_startup; +pub mod network_broadcast; +pub mod node_runtime; +pub mod remote_height; +pub mod unlock_pipe; +pub mod unlock_structs; +pub mod windows_service; diff --git a/src/startup/network_broadcast.rs b/src/startup/network_broadcast.rs new file mode 100644 index 0000000..3ddbc86 --- /dev/null +++ b/src/startup/network_broadcast.rs @@ -0,0 +1,203 @@ +use crate::common::binary_conversions::{binary_to_ip, binary_to_string, ip_to_binary}; +use crate::common::network_startup::get_ip_and_port; +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::memory::network_mapping::structs::{ + AddAddressParams, DeleteAddressParams, SignedNodeEdit, NODE_ADDED_BY_OFFSET, + NODE_ADDED_SIGNATURE_OFFSET, NODE_ADDED_TIMESTAMP_OFFSET, NODE_BLOCKS_MINED_OFFSET, + NODE_DELETED_BLOCK_OFFSET, NODE_DELETED_BY_OFFSET, NODE_DELETED_SIGNATURE_OFFSET, + NODE_DELETED_TIMESTAMP_OFFSET, NODE_IP_OFFSET, NODE_RECORD_BYTES, +}; +use crate::rpc::command_maps::{RPC_ADD_NETWORK_NODE, RPC_REQUEST_NODE_LIST}; +use crate::rpc::responses::RpcResponse; +use crate::wallets::structures::Wallet; +use crate::sled::Db; +use crate::Arc; +use crate::encode; +use crate::Mutex; +use crate::TcpStream; +use crate::Utc; + +fn decode_optional_signature(bytes: &[u8]) -> String { + // Network mapping records use zero-filled signature fields when no signature exists yet. + if bytes.iter().all(|&byte| byte == 0) { + String::new() + } else { + encode(bytes) + } +} + +pub async fn announce_self_to_network( + unlocked_stream: Arc>, + address: &str, + command_map: Arc>, + db: &Db, + wallet_key: &str, + connections_key: &str, +) { + // announce the local node to the connected peer, then + // request its current network mapping on success + let message_type = RPC_ADD_NETWORK_NODE; + let (ip, _, _) = get_ip_and_port().await; + + // Reserve a reply key so the peer's acknowledgement returns to this request. + let (hashmap_key, _hashmap_tx, hashmap_rx) = reserve_entry(command_map.clone()).await; + + // Encode the local node identity into the same binary shape used by network commands. + let ip_bytes = ip_to_binary(&ip); + let address_bytes = match Wallet::short_address_to_bytes(address) { + Some(bytes) => bytes, + None => return, + }; + let modified_by_bytes = vec![0u8; Wallet::ADDRESS_BYTES_LENGTH]; + let time = Utc::now().timestamp_millis() as u64; + let modified_timestamp_bytes = time.to_le_bytes(); + // Self-announcement is intentionally unsigned. The receiving node + // adopts it by re-signing the membership edit with its own wallet. + let modified_signature_bytes = vec![0u8; Wallet::SIGNATURE_LENGTH]; + + let mut message: Vec = Vec::with_capacity( + 1 + 3 + + Wallet::SHORT_ADDRESS_BYTES_LENGTH + + 16 + + Wallet::ADDRESS_BYTES_LENGTH + + 8 + + Wallet::SIGNATURE_LENGTH, + ); + message.push(message_type); + message.extend_from_slice(&hashmap_key); + message.extend_from_slice(&address_bytes); + message.extend_from_slice(&ip_bytes); + message.extend_from_slice(&modified_by_bytes); + message.extend_from_slice(&modified_timestamp_bytes); + message.extend_from_slice(&modified_signature_bytes); + + RpcResponse::send_raw(&unlocked_stream, Some(connections_key), &message).await; + + // Only pull the peer's network mapping after it accepts our self-announcement. + let mut rx = hashmap_rx.lock().await; + + // Handle the received data + if let Some(buffer) = rx.recv().await { + if binary_to_string(buffer.clone()) == "Success" { + get_network_mapping( + unlocked_stream, + command_map.clone(), + db, + wallet_key, + connections_key, + ) + .await + } + } +} + +pub async fn get_network_mapping( + unlocked_stream: Arc>, + command_map: Arc>, + db: &Db, + wallet_key: &str, + connections_key: &str, +) { + // request the remote peer's serialized node list and + // import each advertised add/delete record locally + let message_type = RPC_REQUEST_NODE_LIST; + + let (download_hashmap_key, _hashmap_tx, download_hashmap_rx) = + reserve_entry(command_map.clone()).await; + + let mut message: Vec = Vec::new(); + message.push(message_type); + message.extend_from_slice(&download_hashmap_key); + + // The remote reply is a concatenated list of fixed-width node records. + RpcResponse::send_raw(&unlocked_stream, Some(connections_key), &message).await; + + let mut rx = download_hashmap_rx.lock().await; + // each node record is serialized into one fixed-width payload so + // startup import can safely drain the response in-place. + if let Some(mut buffer) = rx.recv().await { + while buffer.len() >= NODE_RECORD_BYTES { + let chunk: Vec = buffer.drain(0..NODE_RECORD_BYTES).collect(); + // The first part of each record describes the advertised node address and IP. + let Some(address) = Wallet::bytes_to_short_address(&chunk[0..NODE_IP_OFFSET]) else { + continue; + }; + let ip = binary_to_ip(chunk[NODE_IP_OFFSET..NODE_BLOCKS_MINED_OFFSET].to_vec()); + let _advertised_blocks_mined = chunk[NODE_BLOCKS_MINED_OFFSET]; + let blocks_mined = 0_u8; + let added_by_bytes = &chunk[NODE_ADDED_BY_OFFSET..NODE_ADDED_TIMESTAMP_OFFSET]; + let added_by = if added_by_bytes.iter().all(|&byte| byte == 0) { + String::new() + } else { + Wallet::bytes_to_long_address(added_by_bytes.to_vec()) + }; + let added_timestamp = u64::from_le_bytes( + chunk[NODE_ADDED_TIMESTAMP_OFFSET..NODE_ADDED_SIGNATURE_OFFSET] + .try_into() + .unwrap(), + ); + let added_signature = decode_optional_signature( + &chunk[NODE_ADDED_SIGNATURE_OFFSET..NODE_DELETED_BY_OFFSET], + ); + let remote_ip = ""; + // Add records are imported through NodeInfo so local validation/signing rules stay central. + NodeInfo::add_address(AddAddressParams { + map: command_map.clone(), + edit: SignedNodeEdit { + address: address.clone(), + ip: ip.clone(), + modified_by: added_by, + modified_timestamp: added_timestamp, + modified_signature: added_signature, + }, + blocks_mined, + remote_ip: remote_ip.to_string(), + db: db.clone(), + wallet_key: wallet_key.to_string(), + connections_key: connections_key.to_string(), + }) + .await; + + if !chunk[NODE_DELETED_BY_OFFSET..NODE_DELETED_TIMESTAMP_OFFSET] + .iter() + .all(|&byte| byte == 0) + { + // Deleted fields are optional; when present, replay the delete record locally too. + let deleted_by = Wallet::bytes_to_long_address( + chunk[NODE_DELETED_BY_OFFSET..NODE_DELETED_TIMESTAMP_OFFSET].to_vec(), + ); + let deleted_timestamp = u64::from_le_bytes( + chunk[NODE_DELETED_TIMESTAMP_OFFSET..NODE_DELETED_BLOCK_OFFSET] + .try_into() + .unwrap(), + ); + let deleted_block = u32::from_le_bytes( + chunk[NODE_DELETED_BLOCK_OFFSET..NODE_DELETED_SIGNATURE_OFFSET] + .try_into() + .unwrap(), + ); + let deleted_signature = decode_optional_signature( + &chunk[NODE_DELETED_SIGNATURE_OFFSET..NODE_RECORD_BYTES], + ); + let deleted_address = address.clone(); + let _ = NodeInfo::delete_address(DeleteAddressParams { + map: command_map.clone(), + edit: SignedNodeEdit { + address, + ip, + modified_by: deleted_by, + modified_timestamp: deleted_timestamp, + modified_signature: deleted_signature, + }, + remote_ip: remote_ip.to_string(), + db: db.clone(), + wallet_key: wallet_key.to_string(), + connections_key: connections_key.to_string(), + }) + .await; + NodeInfo::set_deleted_block_from_mapping(&deleted_address, deleted_block).await; + } + } + } +} diff --git a/src/startup/node_runtime.rs b/src/startup/node_runtime.rs new file mode 100644 index 0000000..a27b163 --- /dev/null +++ b/src/startup/node_runtime.rs @@ -0,0 +1,171 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::network_startup::get_listen_socket; +use crate::miner::genesis::create_genesis_transaction; +use crate::miner::mining::mine_block; +use crate::records::memory::response_channels::Command; +use crate::records::memory::mempool::{init_db, setup_mempool}; +use crate::rpc::server::start_rpc::start_rpc; +use crate::startup::connections::handle_connections; +use crate::startup::daemonize::{install_shutdown_cleanup, remove_registered_pid_file}; +use crate::startup::initialize_startup::open_chain_state; +use crate::verifications::verification_service::initialize_global_verification_service; +use crate::flexi_logger::{ + Cleanup, Criterion, DeferredNow, FileSpec, + Logger, LoggerHandle, Naming, Record, WriteMode +}; +use crate::log::{error, info}; +use crate::sled::Db; +use crate::Arc; +use crate::create_dir_all; +use crate::Error; +use crate::exit; +use crate::HashMap; +use crate::Mutex; +use crate::panic; +use crate::Settings; +use std::io::Write; +use std::path::Path; + +// The runtime logger is shared by the normal console node path and the Windows service path. +fn format_log( + writer: &mut dyn Write, + now: &mut DeferredNow, + record: &Record<'_>, +) -> Result<(), std::io::Error> { + write!( + writer, + "[{}] {} [{}] {}", + now.now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + record.target(), + record.args() + ) +} + +pub async fn initialize_node_logging() -> Result> { + let ( + _network_name, + _padded_base_coin, + _suffix, + _torrent_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + log_path, + ) = block_extension_and_paths(); + + // Create the log folder before flexi_logger tries to open the rotating file. + create_dir_all(Path::new(&log_path)).await?; + + // Runtime logging goes to a rotating node log shared by service and console startup paths. + let handle = Logger::try_with_str(&Settings::load()?.log_level)? + .format(format_log) + .log_to_file( + FileSpec::default() + .directory(&log_path) + .basename("node"), + ) + .rotate( + Criterion::Size(10_000_000), + Naming::Numbers, + Cleanup::KeepLogFiles(10), + ) + .write_mode(WriteMode::BufferAndFlush) + .start()?; + + Ok(handle) +} + +pub fn install_panic_cleanup() { + // Panic cleanup removes the Linux PID file so later restarts do not trip over stale state. + panic::set_hook(Box::new(|info| { + eprintln!("Application panicked: {info}"); + error!("Application panicked: {info}"); + remove_registered_pid_file(); + exit(1); + })); +} + +pub fn clear_ip_scores(db: &Db) -> sled::Result<()> { + // IP reputation is runtime-only state, so each process start begins with a clean score table. + let tree = db.open_tree("ip_rep_system")?; + tree.clear()?; + Ok(()) +} + +pub async fn run_unlocked_node(wallet_key: String, install_shutdown: bool) -> Result<(), String> { + // Once the wallet is available, the shared node runtime performs the remaining + // startup work for both Linux foreground/daemon mode and the Windows service path. + info!( + "Initializing PostgreSQL mempool connection for host={} port={} dbname={}", + Settings::load().map_err(|e| e.to_string())?.pg_host, + Settings::load().map_err(|e| e.to_string())?.pg_port, + Settings::load().map_err(|e| e.to_string())?.pg_dbname + ); + init_db().await.map_err(|e| e.to_string())?; + + info!("Creating or validating PostgreSQL mempool tables."); + setup_mempool().await.map_err(|e| e.to_string())?; + info!("PostgreSQL mempool tables are ready."); + + // Open sled after Postgres is ready so block state and mempool state start together. + let db = open_chain_state().await; + + if install_shutdown { + // Console/daemon mode owns signal cleanup; Windows service shutdown is handled separately. + install_shutdown_cleanup(db.clone()); + } + + if let Err(e) = clear_ip_scores(&db) { + eprintln!("Failed to clear IP scores: {e}"); + error!("Failed to clear IP scores: {e}"); + } + + let wallet_key_clone = wallet_key.clone(); + let map: Arc> = Arc::new(Mutex::new(HashMap::new())); + let map_cloned = Arc::clone(&map); + let db_server = db.clone(); + let verification_service = Arc::new(initialize_global_verification_service()); + let server_address = get_listen_socket().await; + + // Mainnet is intentionally blocked until launch. + #[cfg(feature = "mainnet")] + { + compile_error!("The 'mainnet' feature is not allowed to be built."); + } + + // The RPC server starts first so handshake traffic can begin while the rest of the + // node initialization continues. + tokio::spawn(async move { + start_rpc( + &db_server, + server_address, + wallet_key_clone, + map_cloned, + ) + .await; + }); + + // Connection management, genesis creation, and mining then proceed in the same + // unlocked runtime regardless of how the process was launched. + handle_connections(db.clone(), wallet_key.clone(), map.clone()) + .await + .map_err(|e| format!("Startup connection error: {e}"))?; + + create_genesis_transaction( + &db, + verification_service.clone(), + wallet_key.clone(), + map.clone(), + ) + .await; + + mine_block(&db, verification_service, wallet_key.clone(), map.clone()) + .await + .map_err(|e| format!("Mining loop error: {e}"))?; + + // If mining exits normally, cleanup any daemon PID file left by startup. + remove_registered_pid_file(); + Ok(()) +} diff --git a/src/startup/remote_height.rs b/src/startup/remote_height.rs new file mode 100644 index 0000000..7f99240 --- /dev/null +++ b/src/startup/remote_height.rs @@ -0,0 +1,41 @@ +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::rpc::command_maps::RPC_BLOCK_HEIGHT; +use crate::rpc::responses::RpcResponse; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; +use crate::{timeout, Duration}; + +pub async fn request_remote_height( + stream: Arc>, + map: Arc>, + connections_key: String, +) -> Result { + // request the remote node's current chain height using + // the standard reply-channel request/response flow + let (hashmap_key, _tx, rx) = reserve_entry(map.clone()).await; + + // message format is the height command plus the unique + // reply key used to route the response back here + let mut message = vec![RPC_BLOCK_HEIGHT]; + message.extend_from_slice(&hashmap_key); + + RpcResponse::send_raw(&stream, Some(&connections_key), &message).await; + + // The response is routed back through the reserved hashmap receiver. + let mut rx = rx.lock().await; + let buffer = timeout(Duration::from_secs(15), rx.recv()) + .await + .map_err(|_| "Timed out waiting for height response".to_string())? + .ok_or("No height response received")?; + + if buffer.len() != 4 { + return Err(format!( + "Invalid height response length: expected 4, got {}", + buffer.len() + )); + } + + // Heights are encoded as exactly one little-endian u32. + Ok(u32::from_le_bytes(buffer.try_into().unwrap())) +} diff --git a/src/startup/unlock_pipe.rs b/src/startup/unlock_pipe.rs new file mode 100644 index 0000000..93482f1 --- /dev/null +++ b/src/startup/unlock_pipe.rs @@ -0,0 +1,238 @@ +#[cfg(windows)] +use crate::common::network_paths_and_settings::block_extension_and_paths; +#[cfg(windows)] +use crate::from_slice; +#[cfg(windows)] +use crate::log::{error, info, warn}; +#[cfg(windows)] +use crate::startup::unlock_structs::{ServiceWaitState, UnlockPipeRequest, UnlockPipeResponse}; +#[cfg(windows)] +use crate::to_string; +#[cfg(windows)] +use crate::wallets::structures::Wallet; +#[cfg(windows)] +use crate::Arc; +#[cfg(windows)] +use crate::{sleep, timeout, AsyncReadExt, AsyncWriteExt, Duration, RwLock}; +#[cfg(windows)] +use crate::{AtomicBool, AtomicOrdering}; +#[cfg(windows)] +use tokio::net::windows::named_pipe::ServerOptions; +#[cfg(windows)] +use tokio::sync::mpsc; +#[cfg(windows)] +use windows_sys::Win32::Foundation::LocalFree; +#[cfg(windows)] +use windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; +#[cfg(windows)] +use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; + +#[cfg(windows)] +pub fn pipe_name() -> String { + // Include the active network name so testnet and mainnet services never share a pipe. + let ( + network_name, + _padded_base_coin, + _suffix, + _torrent_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + format!(r"\\.\pipe\contractless_{network_name}_unlock") +} + +#[cfg(windows)] +fn create_pipe_server( + pipe_name: &str, + first_instance: bool, +) -> std::io::Result { + // The unlock pipe is local-only IPC between the Windows service and the helper + // tool that submits the wallet key after the service has already started. + let mut options = ServerOptions::new(); + options.reject_remote_clients(true); + if first_instance { + options.first_pipe_instance(true); + } + + // Allow the service, local administrators, interactive users, and normal local users + // to talk to the unlock pipe while still rejecting remote clients at the pipe layer. + let security_descriptor = wide_null("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)(A;;GRGW;;;BU)"); + let mut raw_sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + + let converted = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + security_descriptor.as_ptr(), + 1, + &mut raw_sd, + std::ptr::null_mut(), + ) + }; + + if converted == 0 || raw_sd.is_null() { + return Err(std::io::Error::last_os_error()); + } + + let mut attrs = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: raw_sd as *mut _, + bInheritHandle: 0, + }; + + let server = unsafe { + options.create_with_security_attributes_raw(pipe_name, &mut attrs as *mut _ as *mut _) + }; + + unsafe { + LocalFree(raw_sd as *mut _); + } + + server +} + +#[cfg(windows)] +fn wide_null(value: &str) -> Vec { + // Windows security APIs expect null-terminated UTF-16 strings. + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[cfg(windows)] +pub async fn run_unlock_pipe_server( + service_state: Arc>, + shutdown: Arc, + unlock_sender: mpsc::UnboundedSender, +) { + let pipe_name = pipe_name(); + let mut first_instance = true; + + info!("Named pipe listener started at {pipe_name}"); + + // A new pipe instance is created for each request so the service can accept + // repeated status checks and a later wallet submission through the same name. + while !shutdown.load(AtomicOrdering::SeqCst) { + let mut server = match create_pipe_server(&pipe_name, first_instance) { + Ok(server) => server, + Err(err) => { + error!("Failed to create named pipe {pipe_name}: {err}"); + sleep(Duration::from_secs(1)).await; + continue; + } + }; + + first_instance = false; + + // Use a short connect timeout so shutdown checks are not blocked by an idle pipe. + match timeout(Duration::from_secs(1), server.connect()).await { + Ok(Ok(())) => {} + Ok(Err(err)) => { + warn!("Named pipe connect failed: {err}"); + continue; + } + Err(_) => { + continue; + } + } + + // Requests are length-prefixed JSON so the service can read exactly one command. + let request_len = match server.read_u32_le().await { + Ok(len) => len as usize, + Err(err) => { + warn!("Named pipe length read failed: {err}"); + continue; + } + }; + + let mut request_bytes = vec![0u8; request_len]; + if let Err(err) = server.read_exact(&mut request_bytes).await { + warn!("Named pipe read failed: {err}"); + continue; + } + + let response = + handle_request(&request_bytes, service_state.clone(), unlock_sender.clone()).await; + + // Responses use the same length-prefixed JSON shape as requests. + let response_bytes = match to_string(&response) { + Ok(json) => json.into_bytes(), + Err(err) => { + error!("Failed to serialize named pipe response: {err}"); + continue; + } + }; + + if let Err(err) = server.write_u32_le(response_bytes.len() as u32).await { + warn!("Named pipe response length write failed: {err}"); + continue; + } + + if let Err(err) = server.write_all(&response_bytes).await { + warn!("Named pipe write failed: {err}"); + } + } + + info!("Named pipe listener stopped."); +} + +#[cfg(windows)] +async fn handle_request( + request_bytes: &[u8], + service_state: Arc>, + unlock_sender: mpsc::UnboundedSender, +) -> UnlockPipeResponse { + // Malformed helper requests are reported back through the pipe instead of panicking the service. + let request = match from_slice::(request_bytes) { + Ok(request) => request, + Err(err) => { + return UnlockPipeResponse::Error { + message: format!("Invalid request payload: {err}"), + }; + } + }; + + match request { + UnlockPipeRequest::Ping => UnlockPipeResponse::Pong, + UnlockPipeRequest::Status => { + let state = *service_state.read().await; + UnlockPipeResponse::Status { state } + } + UnlockPipeRequest::SubmitKey { wallet_key } => { + // The service only accepts a wallet key while it is still in the locked + // waiting state, and it validates the key before allowing normal startup. + let current_state = *service_state.read().await; + if !matches!(current_state, ServiceWaitState::WaitingForUnlock) { + return UnlockPipeResponse::Error { + message: "Service is not waiting for a wallet key.".to_string(), + }; + } + + match Wallet::try_obtain_wallet(wallet_key.clone(), None).await { + Ok(_) => { + // Mark unlocked before sending the key so status checks immediately reflect progress. + { + let mut state = service_state.write().await; + *state = ServiceWaitState::Unlocked; + } + + // If the runtime loop is gone, restore the waiting state so another attempt can be made. + if unlock_sender.send(wallet_key).is_err() { + let mut state = service_state.write().await; + *state = ServiceWaitState::WaitingForUnlock; + return UnlockPipeResponse::Error { + message: "Service failed to accept the unlock request.".to_string(), + }; + } + + UnlockPipeResponse::KeyAccepted + } + Err(err) => UnlockPipeResponse::Error { message: err }, + } + } + } +} + +#[cfg(not(windows))] +pub fn pipe_name() -> String { + String::new() +} diff --git a/src/startup/unlock_structs.rs b/src/startup/unlock_structs.rs new file mode 100644 index 0000000..0a9185d --- /dev/null +++ b/src/startup/unlock_structs.rs @@ -0,0 +1,27 @@ +use crate::{Deserialize, Serialize}; + +// ServiceWaitState tracks whether the Windows service is still waiting for a wallet key +// or has already moved into normal unlocked operation. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub enum ServiceWaitState { + WaitingForUnlock, + Unlocked, + Stopping, +} + +// UnlockPipeRequest defines the small local-only command set accepted by the unlock pipe. +#[derive(Debug, Serialize, Deserialize)] +pub enum UnlockPipeRequest { + Ping, + Status, + SubmitKey { wallet_key: String }, +} + +// UnlockPipeResponse keeps the pipe replies explicit for status checks and key submission. +#[derive(Debug, Serialize, Deserialize)] +pub enum UnlockPipeResponse { + Pong, + Status { state: ServiceWaitState }, + KeyAccepted, + Error { message: String }, +} diff --git a/src/startup/windows_service.rs b/src/startup/windows_service.rs new file mode 100644 index 0000000..c70c7e1 --- /dev/null +++ b/src/startup/windows_service.rs @@ -0,0 +1,588 @@ +#[cfg(windows)] +use crate::common::network_paths_and_settings::block_extension_and_paths; +#[cfg(windows)] +use crate::env; +#[cfg(windows)] +use crate::log::{error, info, warn}; +#[cfg(windows)] +use crate::startup::initialize_startup::prepare_pre_wallet_startup; +#[cfg(windows)] +use crate::startup::node_runtime::{ + initialize_node_logging, install_panic_cleanup, run_unlocked_node, +}; +#[cfg(windows)] +use crate::startup::unlock_pipe::{pipe_name, run_unlock_pipe_server}; +#[cfg(windows)] +use crate::startup::unlock_structs::ServiceWaitState; +#[cfg(windows)] +use crate::Arc; +#[cfg(windows)] +use crate::Duration; +#[cfg(windows)] +use crate::Error; +#[cfg(windows)] +use crate::Runtime; +#[cfg(windows)] +use crate::RwLock; +#[cfg(windows)] +use crate::{AtomicBool, AtomicOrdering}; +#[cfg(windows)] +use std::ffi::OsString; +#[cfg(windows)] +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::sync::Mutex as StdMutex; +#[cfg(windows)] +use std::thread::sleep as thread_sleep; +#[cfg(windows)] +use std::time::{Duration as StdDuration, Instant}; +#[cfg(windows)] +use tokio::sync::mpsc; +#[cfg(windows)] +use tokio::task::JoinHandle; +#[cfg(windows)] +use windows_service::service::{ + ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceType, +}; +#[cfg(windows)] +use windows_service::service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, +}; +#[cfg(windows)] +use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; +#[cfg(windows)] +use windows_service::service_dispatcher; +#[cfg(windows)] +use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{ERROR_SERVICE_ALREADY_RUNNING, ERROR_SERVICE_DOES_NOT_EXIST}; +#[cfg(windows)] +use winreg::enums::HKEY_LOCAL_MACHINE; +#[cfg(windows)] +use winreg::RegKey; + +#[cfg(windows)] +const WINDOWS_SERVICE_CONTROLLER_CONNECT_ERROR: i32 = 1063; + +#[cfg(windows)] +windows_service::define_windows_service!(ffi_service_main, contractless_service_main); + +#[cfg(windows)] +fn service_name() -> &'static str { + #[cfg(feature = "mainnet")] + { + "ContractlessMainnet" + } + #[cfg(feature = "testnet")] + { + "ContractlessTestnet" + } +} + +#[cfg(windows)] +fn service_display_name() -> &'static str { + #[cfg(feature = "mainnet")] + { + "Contractless Mainnet" + } + #[cfg(feature = "testnet")] + { + "Contractless Testnet" + } +} + +#[cfg(windows)] +fn uninstall_registry_key_name() -> String { + // The uninstall entry shares the service identity so Windows can present the + // running node as a normal removable application in Add/Remove Programs. + format!( + r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}", + service_name() + ) +} + +#[cfg(windows)] +fn submit_key_binary_path(service_binary_path: &Path) -> PathBuf { + // The helper binary is expected next to the service executable. + service_binary_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("contractless-submit-key.exe") +} + +#[cfg(windows)] +fn quoted_command(path: &Path, args: &[&str]) -> String { + // Windows registry uninstall commands need quoted paths because Program Files contains spaces. + let mut command = format!("\"{}\"", path.display()); + for arg in args { + command.push(' '); + command.push_str(arg); + } + command +} + +#[cfg(windows)] +fn register_add_remove_entry(service_binary_path: &Path) -> Result<(), Box> { + // The uninstall registry entry points back to the same binary so the install, + // service management, and uninstall logic all stay in one maintained path. + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let uninstall_path = uninstall_registry_key_name(); + let (key, _) = hklm.create_subkey(&uninstall_path)?; + + let install_location = service_binary_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_string_lossy() + .to_string(); + let uninstall_command = quoted_command(service_binary_path, &["--uninstall-service"]); + let submit_key_path = submit_key_binary_path(service_binary_path); + + key.set_value( + "DisplayName", + &format!("{} Service", service_display_name()), + )?; + key.set_value("DisplayVersion", &env!("CARGO_PKG_VERSION"))?; + key.set_value("Publisher", &"Contractless")?; + key.set_value("InstallLocation", &install_location)?; + key.set_value( + "DisplayIcon", + &service_binary_path.to_string_lossy().to_string(), + )?; + key.set_value("UninstallString", &uninstall_command)?; + key.set_value("QuietUninstallString", &uninstall_command)?; + key.set_value( + "Comments", + &format!( + "{} background node service. Use contractless-submit-key.exe to unlock the wallet after the service starts.", + service_display_name() + ), + )?; + key.set_value("NoModify", &1u32)?; + key.set_value("NoRepair", &1u32)?; + + if submit_key_path.exists() { + key.set_value( + "HelpLink", + &format!( + "Submit wallet key with {} after starting the service.", + submit_key_path.display() + ), + )?; + } + + Ok(()) +} + +#[cfg(windows)] +fn unregister_add_remove_entry() -> Result<(), Box> { + // Removing an already-missing uninstall entry should not make service uninstall fail. + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let uninstall_path = uninstall_registry_key_name(); + + match hklm.delete_subkey_all(&uninstall_path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } +} + +#[cfg(windows)] +pub fn try_run_as_windows_service() -> Result> { + // Console launches fall through to the normal startup path. Only SCM launches + // should enter the dedicated Windows service runtime. + match service_dispatcher::start(service_name(), ffi_service_main) { + Ok(()) => Ok(true), + Err(windows_service::Error::Winapi(err)) + if err.raw_os_error() == Some(WINDOWS_SERVICE_CONTROLLER_CONNECT_ERROR) => + { + Ok(false) + } + Err(err) => Err(err.into()), + } +} + +#[cfg(windows)] +pub fn handle_windows_service_command() -> Result> { + let args: Vec = env::args().skip(1).collect(); + + // Service install and lifecycle commands are handled before any wallet prompt + // or node startup so administrative setup never mixes with normal runtime flow. + if args.iter().any(|arg| arg == "--install-service") { + println!("{}", install_windows_service()?); + return Ok(true); + } + + if args.iter().any(|arg| arg == "--uninstall-service") { + println!("{}", uninstall_windows_service()?); + return Ok(true); + } + + if args.iter().any(|arg| arg == "--start-service") { + println!("{}", start_windows_service()?); + return Ok(true); + } + + if args.iter().any(|arg| arg == "--stop-service") { + println!("{}", stop_windows_service()?); + return Ok(true); + } + + Ok(false) +} + +#[cfg(windows)] +fn install_windows_service() -> Result> { + // Installing the service also registers the uninstall entry so Windows has a + // standard removal path in addition to the direct CLI commands. + let manager_access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE; + let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?; + + let service_binary_path = env::current_exe()?; + let service_info = ServiceInfo { + name: OsString::from(service_name()), + display_name: OsString::from(service_display_name()), + service_type: ServiceType::OWN_PROCESS, + start_type: ServiceStartType::OnDemand, + error_control: ServiceErrorControl::Normal, + executable_path: service_binary_path.clone(), + launch_arguments: vec![], + dependencies: vec![], + account_name: None, + account_password: None, + }; + + let service_access = ServiceAccess::QUERY_CONFIG + | ServiceAccess::CHANGE_CONFIG + | ServiceAccess::START + | ServiceAccess::DELETE; + + let service = match service_manager.create_service(&service_info, service_access) { + Ok(service) => { + let _ = service.set_description("Contractless blockchain node service"); + register_add_remove_entry(&service_binary_path)?; + return Ok(format!("Windows service {} installed.", service_name())); + } + Err(_) => service_manager.open_service(service_name(), service_access)?, + }; + + let _ = service.set_description("Contractless blockchain node service"); + register_add_remove_entry(&service_binary_path)?; + Ok(format!( + "Windows service {} is already installed.", + service_name() + )) +} + +#[cfg(windows)] +fn uninstall_windows_service() -> Result> { + // Uninstall first stops the service if needed, then removes both the SCM entry + // and the Add/Remove Programs registration. + let service_manager = + ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?; + + let service_access = ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE; + let service = match service_manager.open_service(service_name(), service_access) { + Ok(service) => service, + Err(windows_service::Error::Winapi(err)) + if err.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) => + { + unregister_add_remove_entry()?; + return Ok(format!( + "Windows service {} is not installed.", + service_name() + )); + } + Err(err) => return Err(err.into()), + }; + + stop_service_if_running(&service)?; + service.delete()?; + drop(service); + + let start = Instant::now(); + let timeout = StdDuration::from_secs(5); + while start.elapsed() < timeout { + match service_manager.open_service(service_name(), ServiceAccess::QUERY_STATUS) { + Err(windows_service::Error::Winapi(err)) + if err.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) => + { + unregister_add_remove_entry()?; + return Ok(format!("Windows service {} uninstalled.", service_name())); + } + Ok(service) => { + drop(service); + } + Err(err) => return Err(err.into()), + } + + thread_sleep(StdDuration::from_millis(250)); + } + + unregister_add_remove_entry()?; + Ok(format!( + "Windows service {} is marked for deletion and will finish uninstalling once Windows releases the open handles.", + service_name() + )) +} + +#[cfg(windows)] +fn start_windows_service() -> Result> { + // Start is a management command; the service itself still waits for a wallet key over the pipe. + let service_manager = + ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?; + let service = service_manager.open_service( + service_name(), + ServiceAccess::START | ServiceAccess::QUERY_STATUS, + )?; + + match service.start(&[] as &[OsString]) { + Ok(()) => Ok(format!("Windows service {} started.", service_name())), + Err(windows_service::Error::Winapi(err)) + if err.raw_os_error() == Some(ERROR_SERVICE_ALREADY_RUNNING as i32) => + { + Ok(format!( + "Windows service {} is already running.", + service_name() + )) + } + Err(err) => Err(err.into()), + } +} + +#[cfg(windows)] +fn stop_windows_service() -> Result> { + // Stop requests go through SCM so service shutdown follows the normal control handler path. + let service_manager = + ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?; + let service = match service_manager.open_service( + service_name(), + ServiceAccess::QUERY_STATUS | ServiceAccess::STOP, + ) { + Ok(service) => service, + Err(windows_service::Error::Winapi(err)) + if err.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) => + { + return Ok(format!( + "Windows service {} is not installed.", + service_name() + )); + } + Err(err) => return Err(err.into()), + }; + + if stop_service_if_running(&service)? { + Ok(format!( + "Windows service {} stop requested.", + service_name() + )) + } else { + Ok(format!( + "Windows service {} is already stopped.", + service_name() + )) + } +} + +#[cfg(windows)] +fn stop_service_if_running( + service: &windows_service::service::Service, +) -> Result> { + // Service deletion and rebuilds both depend on the process actually exiting, so + // the stop path waits until Windows reports the service as fully stopped. + let status = service.query_status()?; + if status.current_state == ServiceState::Stopped { + return Ok(false); + } + + if status.current_state != ServiceState::StopPending { + let _ = service.stop()?; + } + + let start = Instant::now(); + let timeout = StdDuration::from_secs(15); + while start.elapsed() < timeout { + let status = service.query_status()?; + if status.current_state == ServiceState::Stopped { + return Ok(true); + } + + thread_sleep(StdDuration::from_millis(250)); + } + + Err(format!( + "Timed out waiting for Windows service {} to stop.", + service_name() + ) + .into()) +} + +#[cfg(windows)] +fn contractless_service_main(_arguments: Vec) { + // SCM enters here instead of the normal console main path. + if let Err(err) = run_service() { + eprintln!("Windows service error: {err}"); + } +} + +#[cfg(windows)] +fn run_service() -> windows_service::Result<()> { + // The Windows service starts in a locked state, completes only the pre-wallet + // startup work, and then waits for the helper tool to submit the wallet key. + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_handler = shutdown.clone(); + + let event_handler = move |control_event| -> ServiceControlHandlerResult { + match control_event { + ServiceControl::Stop => { + shutdown_handler.store(true, AtomicOrdering::SeqCst); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + } + }; + + // Register the SCM control handler before reporting StartPending. + let status_handle = service_control_handler::register(service_name(), event_handler)?; + + // Tell SCM that initialization has begun but the service is not ready yet. + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::StartPending, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 1, + wait_hint: Duration::from_secs(10), + process_id: None, + })?; + + // The service owns its own Tokio runtime because SCM entry is synchronous. + let runtime = Runtime::new().expect("Failed to create Windows service runtime"); + let _log_handle = runtime.block_on(async { + match initialize_node_logging().await { + Ok(handle) => Some(handle), + Err(err) => { + eprintln!("Failed to initialize logging: {err}"); + None + } + } + }); + install_panic_cleanup(); + + let service_state = Arc::new(RwLock::new(ServiceWaitState::WaitingForUnlock)); + let service_state_for_pipe = service_state.clone(); + let shutdown_for_pipe = shutdown.clone(); + let (unlock_tx, mut unlock_rx) = mpsc::unbounded_channel::(); + let unlocked_node_task: Arc>>> = Arc::new(StdMutex::new(None)); + let unlocked_node_task_for_loop = unlocked_node_task.clone(); + + runtime.block_on(async { + // Run only the wallet-independent startup work before accepting unlock requests. + prepare_pre_wallet_startup().await; + + // The named pipe stays alive while the service waits for a valid wallet key. + tokio::spawn(async move { + run_unlock_pipe_server(service_state_for_pipe, shutdown_for_pipe, unlock_tx).await; + }); + }); + + info!( + "Windows service {} for {} is running and waiting for wallet unlock on {}.", + service_name(), + block_extension_and_paths().0, + pipe_name() + ); + + // At this point the service is healthy, but still locked until the helper submits a key. + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + let shutdown_for_service = shutdown.clone(); + runtime.block_on(async move { + loop { + tokio::select! { + maybe_wallet_key = unlock_rx.recv() => { + if let Some(wallet_key) = maybe_wallet_key { + // Once the wallet key is accepted, the shared unlocked-node + // runtime is launched inside the service process itself. + let handle = tokio::spawn(async move { + if let Err(err) = run_unlocked_node(wallet_key, false).await { + error!("Unlocked Windows service node failed during startup: {err}"); + } + }); + + if let Ok(mut task_slot) = unlocked_node_task_for_loop.lock() { + // Keep the handle so service shutdown can abort the node task cleanly. + *task_slot = Some(handle); + } + } + } + _ = crate::sleep(Duration::from_millis(500)) => { + if shutdown_for_service.load(AtomicOrdering::SeqCst) { + break; + } + } + } + } + }); + + runtime.block_on(async { + // Publish the stopping state so pipe status checks see shutdown in progress. + let mut state = service_state.write().await; + *state = ServiceWaitState::Stopping; + }); + + warn!("Windows service received stop request."); + + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::StopPending, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 1, + wait_hint: Duration::from_secs(10), + process_id: None, + })?; + + // Stop the unlocked node task before the runtime is torn down. + if let Ok(mut task_slot) = unlocked_node_task.lock() { + if let Some(handle) = task_slot.take() { + handle.abort(); + } + } + + // Shutting down the runtime releases the remaining async work so the service + // process can exit cleanly and the binary can be rebuilt or uninstalled. + runtime.shutdown_timeout(StdDuration::from_secs(2)); + + info!("Windows service {} stopped.", service_name()); + + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + Ok(()) +} + +#[cfg(not(windows))] +pub fn try_run_as_windows_service() -> Result> { + Ok(false) +} + +#[cfg(not(windows))] +pub fn handle_windows_service_command() -> Result> { + Ok(false) +} diff --git a/src/torrent/create_metadata.rs b/src/torrent/create_metadata.rs new file mode 100644 index 0000000..6eabc91 --- /dev/null +++ b/src/torrent/create_metadata.rs @@ -0,0 +1,168 @@ +use crate::blocks::block::{NONCE_OFFSET, VRF_OFFSET}; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::skein::skein_128_hash_bytes; +use crate::log::error; +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::rpc::command_maps::RPC_SUBMIT_TORRENT; +use crate::rpc::responses::RpcResponse; +use crate::torrent::structs::{Info, Torrent}; +use crate::torrent::torrenting_system::get_nodes::get_nodes_from_memory; +use crate::torrent::torrenting_system::torrent_cache::should_broadcast_torrent; +use crate::Arc; +use crate::File; +use crate::HashMap; +use crate::Mutex; +use crate::{AsyncReadExt, AsyncWriteExt}; + +pub async fn metadata_from_file( + file_path: &str, + block_height: u32, + difficulty: u64, + timestamp: u32, + block_hash: &str, + miner_wallet: String, + _map: Arc>, +) -> Result, String> { + // The torrent is built from the mined block file and saved under the network torrent path. + let ( + _network_name, + _padded_base_coin, + _block_ext, + out_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let mut file = File::open(file_path) + .await + .map_err(|err| format!("Failed to open saved block file for torrent metadata: {err}"))?; + let mut content = Vec::new(); + if let Err(err) = file.read_to_end(&mut content).await { + error!("Failed to read file content: {err}"); + return Err(format!("Failed to read saved block file for torrent metadata: {err}")); + } + + // Pick larger piece sizes for larger blocks so torrents do not exceed the u8 piece index limit. + let mut piece_length: u32; + if !content.is_empty() && content.len() < 1000 { + piece_length = 500_u32; + } else if content.len() >= 1000 && content.len() < 10000 { + piece_length = 1000_u32; + } else if content.len() >= 10000 && content.len() < 100000 { + piece_length = 10000_u32; + } else if content.len() >= 100000 && content.len() < 1000000 { + piece_length = 100000_u32; + } else if content.len() >= 1000000 && content.len() < 10000000 { + piece_length = 1000000_u32; + } else { + piece_length = 2000000_u32; + } + + while !content.is_empty() && content.chunks(piece_length as usize).len() > u8::MAX as usize { + // If the rough size bucket still creates too many pieces, keep doubling until it fits. + piece_length = piece_length.saturating_mul(2); + } + + // The info hash is the 128-bit hash of the full block bytes. + let block_hashed = skein_128_hash_bytes(&content); + + // Nonce and VRF are copied from the serialized block header at fixed offsets. + let nonce = content[NONCE_OFFSET]; + + let vrf_bytes = &content[VRF_OFFSET..VRF_OFFSET + 16]; + let vrf = u128::from_le_bytes( + vrf_bytes + .try_into() + .expect("Slice must be exactly 16 bytes"), + ); + + // Hash each piece separately so peers can verify downloaded chunks before assembly. + let mut piece_hashes: Vec> = Vec::new(); + for (index, piece) in content.chunks(piece_length as usize).enumerate() { + let hash = skein_128_hash_bytes(piece); + let piece_number = u8::try_from(index + 1) + .map_err(|_| "Piece count exceeds u8 limit while creating torrent metadata")?; + let mut map = HashMap::new(); + map.insert(piece_number, hash); + piece_hashes.push(map); + } + + // Torrent info mirrors the data needed to verify the downloaded block later. + let info = Info { + length: content.len() as u64, + this_block_difficulty: difficulty, + timestamp, + nonce, + vrf, + block_hash: block_hash.to_string(), + piece_length, + info_hash: block_hashed, + pieces: piece_hashes, + }; + + let torrent = Torrent { + info, + mined_by: miner_wallet, + }; + + // Save the compact binary torrent metadata so the save path can + // broadcast it only after the block height has committed. + let torrent_bytes = torrent.to_bytes().await; + create_torrent_file(out_path, block_height, &torrent_bytes) + .await + .map_err(|err| format!("Failed to create torrent metadata file: {err}"))?; + Ok(torrent_bytes) +} + +async fn create_torrent_file( + out_path: String, + block_height: u32, + metadata: &[u8], +) -> Result<(), Box> { + // The torrent filename follows the committed block height. + let torrent_file_path = crate::Path::new(&out_path).join(format!("{block_height}.torrent")); + + // Write the canonical local .torrent file through a temporary path + // so peers never see partial metadata for a committed block. + let temp_torrent_file_path = torrent_file_path.with_extension("torrent.tmp"); + let mut torrent_file = File::create(&temp_torrent_file_path).await?; + torrent_file.write_all(metadata).await?; + torrent_file.flush().await?; + crate::fs::rename(&temp_torrent_file_path, &torrent_file_path)?; + + Ok(()) +} + +pub async fn broadcast_new_torrent_to_peers( + block_height: u32, + torrent_bytes: &[u8], + map: Arc>, +) { + // Command byte for "submit torrent". + let command: u8 = RPC_SUBMIT_TORRENT; + + // The cache suppresses repeated broadcasts of identical torrent bytes. + let torrent_hash = skein_128_hash_bytes(torrent_bytes); + if !should_broadcast_torrent(&torrent_hash, block_height).await { + return; + } + let torrent_len = 4 + torrent_bytes.len() as u32; + + // Send the torrent to all currently connected miner peers. + let peers = get_nodes_from_memory().await; + for (connections_key, stream) in peers { + // Each peer needs its own reply mapping entry and UID. + let (uid_bytes, _tx, _rx) = reserve_entry(map.clone()).await; + + let mut message = Vec::with_capacity(1 + 3 + 4 + 4 + torrent_bytes.len()); + message.push(command); // Command byte + message.extend_from_slice(&uid_bytes); // UID + message.extend_from_slice(&torrent_len.to_le_bytes()); // Torrent byte size + message.extend_from_slice(&block_height.to_le_bytes()); // Block height + message.extend_from_slice(torrent_bytes); // Torrent file contents + + RpcResponse::send_raw(&stream, Some(&connections_key), &message).await; + } +} diff --git a/src/torrent/mod.rs b/src/torrent/mod.rs new file mode 100644 index 0000000..024c0de --- /dev/null +++ b/src/torrent/mod.rs @@ -0,0 +1,6 @@ +// The torrent module handles block torrent metadata, local unpacking, and download orchestration. +pub mod create_metadata; +pub mod structs; +pub mod torrenting_system; +pub mod unpack_local_torrent; +pub mod unpack_remote_torrent; diff --git a/src/torrent/structs.rs b/src/torrent/structs.rs new file mode 100644 index 0000000..8ed312b --- /dev/null +++ b/src/torrent/structs.rs @@ -0,0 +1,302 @@ +use crate::records::memory::response_channels::Command; +use crate::records::memory::torrentmap::TorrentMap; +use crate::verifications::verification_service::VerificationService; +use crate::wallets::structures::Wallet; +use crate::sled::Db; +use crate::Arc; +use crate::{decode, encode}; +use crate::HashMap; +use crate::Mutex; +use crate::Serialize; +use crate::TcpStream; + +// PieceDownloadJob keeps the network request and the post-download save context together +// while still allowing the request bytes to be consumed by request_piece_from_node. +pub struct PieceDownloadJob { + pub request: RequestPiece, + pub downloaded_piece: DownloadedPieceJob, +} + +// DownloadedPieceJob carries the state needed after a peer returns piece bytes: +// validate the hash, update piece status, and stage the bytes in sled. +pub struct DownloadedPieceJob { + pub db: Db, + pub torrent: Torrent, + pub torrent_map: Arc>, + pub block_number: u32, + pub piece: u8, + pub ip: String, +} + +// RequestPiece groups the state needed to track one in-flight piece request. +pub struct RequestPiece { + pub unlocked_torrent_map: Arc>, + pub ip: String, + pub stream: Arc>, + pub block_number: u32, + pub piece_number: u8, + pub info_hash: u128, + pub piece_length: usize, + pub total_length: usize, + pub map: Arc>, + pub connections_key: String, +} + +// DownloadSave bundles the data needed once all pieces have been collected and the +// finished block is ready to verify and save. +#[derive(Clone)] +pub struct DownloadSave { + pub torrent_map: Arc>, + pub torrent: Torrent, + pub staged_path: String, + pub block_number: u32, + pub allow_during_reorg: bool, + pub db: Db, + pub verification_service: Arc, + pub map: Arc>, +} + +// PieceStatus keeps torrent piece state explicit instead of relying on magic numeric values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PieceStatus { + Pending, + Failed, + Downloading, + Complete, +} + +// The recent torrent cache prevents the same torrent from being rebroadcast repeatedly +// while still allowing the cache to age out old entries by block height. +#[derive(Clone, Copy)] +pub struct TorrentCacheEntry { + pub block_height: u32, + pub broadcasted: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Torrent { + // Torrent is the compact metadata file used to fetch and verify one mined block. + pub info: Info, // fixed header is 89 bytes plus 17 bytes per piece entry + pub mined_by: String, // 22-byte short address when serialized +} + +#[derive(Clone, Debug, Serialize)] +pub struct Info { + // Info is the serialized block metadata header followed by one hash entry per block piece. + // The fixed header is 89 bytes before piece entries. + pub length: u64, // 8 bytes + pub this_block_difficulty: u64, // 8 bytes + pub timestamp: u32, // 4 bytes + pub nonce: u8, // 1 byte + pub vrf: u128, // 16 bytes + pub block_hash: String, // 32 bytes + pub piece_length: u32, // 4 bytes + pub info_hash: String, // 16 bytes + pub pieces: Vec>, // 17 bytes per piece entry +} + +impl Torrent { + pub async fn to_bytes(self) -> Vec { + // Serialize the fixed metadata header first. + let mut bytes = Vec::new(); + + let length_bytes = self.info.length.to_le_bytes(); + bytes.extend(length_bytes); + + let difficulty_bytes = self.info.this_block_difficulty.to_le_bytes(); + bytes.extend(difficulty_bytes); + + let timestamp_bytes = self.info.timestamp.to_le_bytes(); + bytes.extend(timestamp_bytes); + + let nonce_bytes = self.info.nonce.to_le_bytes(); + bytes.extend(nonce_bytes); + + let vrf_bytes = self.info.vrf.to_le_bytes(); + bytes.extend(vrf_bytes); + + if let Ok(block_hash_bytes) = decode(&self.info.block_hash) { + bytes.extend(block_hash_bytes); + } + + let piece_length_bytes = self.info.piece_length.to_le_bytes(); + bytes.extend(piece_length_bytes); + + if let Ok(info_hash_bytes) = decode(&self.info.info_hash) { + bytes.extend(info_hash_bytes); + } + + // Each piece entry is the 1-byte piece number plus the 16-byte piece hash. + for piece_map in &self.info.pieces { + for (key, value) in piece_map { + bytes.push(*key); + if let Ok(decoded_value) = decode(value) { + bytes.extend(decoded_value); + } + } + } + + // The miner address closes the file and proves which wallet mined the advertised block. + if let Some(mined_by_bytes) = Wallet::short_address_to_bytes(&self.mined_by) { + bytes.extend(mined_by_bytes); + } + bytes + } + + pub async fn from_bytes(torrent_bytes: &[u8]) -> tokio::io::Result { + const FIXED_HEADER_BYTES: usize = 89; + const PIECE_ENTRY_BYTES: usize = 17; + const WALLET_BYTES: usize = Wallet::SHORT_ADDRESS_BYTES_LENGTH; + const MIN_TORRENT_BYTES: usize = FIXED_HEADER_BYTES + WALLET_BYTES; + + if torrent_bytes.len() < MIN_TORRENT_BYTES { + return Err(tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent Byte Count", + )); + } + + // Walk the byte slice forward as each fixed-width field is decoded. + let mut cursor = torrent_bytes; + + let length = u64::from_le_bytes(cursor[0..8].try_into().unwrap()); + cursor = &cursor[8..]; + + let difficulty = u64::from_le_bytes(cursor[0..8].try_into().unwrap()); + cursor = &cursor[8..]; + + let timestamp = u32::from_le_bytes(cursor[0..4].try_into().unwrap()); + cursor = &cursor[4..]; + + let nonce = cursor[0]; + cursor = &cursor[1..]; + + let vrf = u128::from_le_bytes(cursor[0..16].try_into().unwrap()); + cursor = &cursor[16..]; + + let block_hash_bytes = &cursor[0..32]; + let block_hash = encode(block_hash_bytes); + cursor = &cursor[32..]; + + let piece_length = u32::from_le_bytes(cursor[0..4].try_into().unwrap()); + cursor = &cursor[4..]; + + if piece_length == 0 { + return Err(tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent piece length", + )); + } + + let info_hash_bytes = &cursor[0..16]; + let info_hash = encode(info_hash_bytes); + cursor = &cursor[16..]; + + // Everything after the fixed header is piece entries plus the final miner address. + if cursor.len() < WALLET_BYTES { + return Err(tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent miner bytes", + )); + } + + let piece_bytes = cursor.len() - WALLET_BYTES; + if piece_bytes % PIECE_ENTRY_BYTES != 0 { + return Err(tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent piece section length", + )); + } + + // The byte count itself must agree with the block length and piece length. + let actual_piece_count = piece_bytes / PIECE_ENTRY_BYTES; + let piece_length_u64 = piece_length as u64; + let expected_piece_count_u64 = + length.checked_add(piece_length_u64 - 1).ok_or_else(|| { + tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent piece count overflow", + ) + })? / piece_length_u64; + let expected_piece_count = usize::try_from(expected_piece_count_u64).map_err(|_| { + tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent piece count", + ) + })?; + + if expected_piece_count > u8::MAX as usize { + return Err(tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent piece count exceeds u8 limit", + )); + } + + if actual_piece_count != expected_piece_count { + return Err(tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid number of pieces", + )); + } + + // Decode every piece hash entry before reading the trailing miner address. + let mut pieces = Vec::with_capacity(actual_piece_count); + for expected_piece in 1..=actual_piece_count { + if cursor.len() < 17 { + // 1 byte index + 16 bytes hash + return Err(tokio::io::Error::other( + "Insufficient data for pieces", + )); + } + let index = cursor[0]; + let expected_piece = u8::try_from(expected_piece).map_err(|_| { + tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent piece index", + ) + })?; + if index != expected_piece { + return Err(tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent piece index order", + )); + } + let hash_bytes = &cursor[1..17]; + let hash = encode(hash_bytes); + let mut map = HashMap::new(); + map.insert(index, hash); + pieces.push(map); + cursor = &cursor[17..]; + } + + if cursor.len() != WALLET_BYTES { + return Err(tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent miner section length", + )); + } + + let miner_bin = &cursor[0..WALLET_BYTES]; + let mined_by = Wallet::bytes_to_short_address(miner_bin).ok_or_else(|| { + tokio::io::Error::new( + tokio::io::ErrorKind::InvalidData, + "Invalid Torrent miner address", + ) + })?; + + // Build the in-memory Torrent once all byte-level validation has passed. + let info = Info { + length, + this_block_difficulty: difficulty, + timestamp, + nonce, + vrf, + block_hash, + piece_length, + info_hash, + pieces, + }; + + Ok(Torrent { info, mined_by }) + } +} diff --git a/src/torrent/torrenting_system/create_file.rs b/src/torrent/torrenting_system/create_file.rs new file mode 100644 index 0000000..1724bd2 --- /dev/null +++ b/src/torrent/torrenting_system/create_file.rs @@ -0,0 +1,34 @@ +use crate::common::binary_conversions::binary_to_string; +use crate::sled::Db; + +pub async fn combine_pieces( + db: &Db, + block_number: u32, + info_hash: &str, +) -> Result, String> { + // Pieces are staged in sled using block-height and info-hash prefixes. + let tree = db.open_tree("block_pieces").map_err(|e| e.to_string())?; + let mut combined_data = Vec::new(); + let prefix = format!("{block_number}-{info_hash}-"); + let mut iter = tree.scan_prefix(prefix.as_bytes()); + let mut pieces = Vec::new(); + + // Pull every staged piece for this block/hash pair and recover the piece number from the key. + while let Some(Ok((key, value))) = iter.next() { + let key_str = binary_to_string(key.to_vec()); + let parts: Vec<&str> = key_str.split('-').collect(); + if parts.len() == 3 { + let piece_number: u8 = parts[2] + .parse() + .map_err(|e: std::num::ParseIntError| e.to_string())?; + pieces.push((piece_number, value)); + } + } + + // Reassemble the block in piece-number order. + pieces.sort_by_key(|&(piece_number, _)| piece_number); + for (_, data) in pieces { + combined_data.extend(data.to_vec()); + } + Ok(combined_data) +} diff --git a/src/torrent/torrenting_system/download_pieces.rs b/src/torrent/torrenting_system/download_pieces.rs new file mode 100644 index 0000000..b9d8fda --- /dev/null +++ b/src/torrent/torrenting_system/download_pieces.rs @@ -0,0 +1,267 @@ +use crate::common::check_genesis::genesis_checkup; +use crate::common::skein::skein_128_hash_bytes; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::torrentmap::{PieceReservation, TorrentMap}; +use crate::torrent::structs::PieceStatus; +use crate::torrent::structs::{PieceDownloadJob, DownloadedPieceJob, DownloadSave, RequestPiece}; +use crate::torrent::torrenting_system::get_nodes::get_nodes_from_memory; +use crate::torrent::torrenting_system::request_piece::request_piece_from_node; +use crate::torrent::torrenting_system::temp_database_storage::remove_block_pieces_from_db; +use crate::torrent::torrenting_system::temp_database_storage::save_piece_to_db; +use crate::torrent::torrenting_system::torrent_map::file_download_status; +use crate::log::{error, warn}; +use crate::Arc; +use crate::Duration; +use crate::decode; +use crate::Mutex; +use crate::sleep; + +fn expected_piece_hash(torrent: &crate::torrent::structs::Torrent, piece: u8) -> Result { + // Torrent piece hashes are stored as one-entry maps, indexed by the piece number. + let piece_map = torrent + .info + .pieces + .get((piece - 1) as usize) + .ok_or_else(|| format!("No hashmap found at index {piece}"))?; + + piece_map + .get(&piece) + .cloned() + .ok_or_else(|| format!("No hash found for piece {piece}")) +} + +async fn mark_piece_failed( + torrent_map: Arc>, + piece: u8, + ip: &str, +) { + // Marking the peer as failed prevents the scheduler from retrying this piece on the same IP. + let mut torrent_map = torrent_map.lock().await; + let _ = torrent_map.mark_piece_failed(piece, ip); +} + +async fn handle_downloaded_piece(job: DownloadedPieceJob, data: Vec) { + // Validate the returned piece against the torrent metadata before marking it + // complete and writing it into temporary storage. + let received_hash = skein_128_hash_bytes(&data); + let expected_hash = match expected_piece_hash(&job.torrent, job.piece) { + Ok(hash) => hash, + Err(err) => { + error!("{err}"); + mark_piece_failed(job.torrent_map, job.piece, &job.ip).await; + return; + } + }; + + if received_hash == expected_hash { + // A hash match makes the piece available for final block assembly. + { + let mut torrent_map = job.torrent_map.lock().await; + let _ = torrent_map.mark_piece_complete(job.piece); + } + if let Err(err) = save_piece_to_db( + job.db, + job.block_number, + &job.torrent.info.info_hash, + job.piece, + &data, + ) + .await + { + error!( + "[download] failed to stage piece data: block_number={} piece={} err={}", + job.block_number, + job.piece, + err + ); + } + } else { + // A bad hash is treated like a failed peer response so another node can be tried. + mark_piece_failed(job.torrent_map, job.piece, &job.ip).await; + warn!( + "[download] piece hash mismatch: block_number={} piece={} peer={} expected_hash={} received_hash={}", + job.block_number, + job.piece, + job.ip, + expected_hash, + received_hash + ); + } +} + +fn spawn_piece_download(job: PieceDownloadJob) { + // Each reserved piece runs independently so slow peers do not block other piece requests. + tokio::spawn(async move { + let piece = job.request.piece_number; + let ip = job.request.ip.clone(); + let block_number = job.request.block_number; + let torrent_map = job.downloaded_piece.torrent_map.clone(); + let downloaded_piece = job.downloaded_piece; + + match request_piece_from_node(job.request).await { + Ok(data) => { + handle_downloaded_piece(downloaded_piece, data).await; + } + Err(err) => { + // Failed requests return the piece to the retry pool so another peer can be tried. + mark_piece_failed(torrent_map, piece, &ip).await; + warn!( + "[download] piece request failed: block_number={block_number} piece={piece} peer={ip} err={err}" + ); + } + } + }); +} + +pub async fn download_block_pieces(params: DownloadSave) -> Result<(), String> { + // Piece numbers start at 1 because that is how they are serialized in the torrent file. + let piece_count = params.torrent.info.pieces.len(); + if piece_count > u8::MAX as usize { + return Err("Torrent piece count exceeds u8 limit".to_string()); + } + let pieces: Vec = (1..=piece_count) + .map(|i| u8::try_from(i).map_err(|_| "Torrent piece index exceeds u8 limit".to_string())) + .collect::, _>>()?; + // Piece requests send the 128-bit info hash as little-endian bytes. + let info_hash_bytes = decode(¶ms.torrent.info.info_hash) + .map_err(|e| format!("Invalid torrent info_hash hex: {e}"))?; + let info_hash = u128::from_le_bytes( + info_hash_bytes + .as_slice() + .try_into() + .map_err(|_| "Invalid torrent info_hash length".to_string())?, + ); + + // Keep polling until every piece has either been downloaded or the + // target block becomes obsolete because the chain tip advanced. + loop { + let current_height = get_height(¶ms.db); + let expected_height = if current_height > 0 || genesis_checkup().await { + current_height + 1 + } else { + current_height + }; + + if params.block_number < current_height { + warn!( + "[download] aborting obsolete download: block_number={} current_height={} expected_height={}", + params.block_number, + current_height, + expected_height + ); + let _ = remove_block_pieces_from_db( + ¶ms.db, + params.block_number, + ¶ms.torrent.info.info_hash, + ) + .await; + return Ok(()); + } + + if file_download_status(params.torrent_map.clone(), pieces.clone()).await { + break; + } + + // Re-scan connected peers for any piece that is still pending or + // has failed and needs another download attempt. + let connected_nodes = get_nodes_from_memory().await; + if connected_nodes.is_empty() { + return Err(format!( + "No connected miner peers available for block {}", + params.block_number + )); + } + + let mut reserved_any = false; + for piece in &pieces { + for (connections_key, stream) in &connected_nodes { + // Use the full connection key for scheduling and failure + // tracking so localhost peers on different ports remain distinct. + let peer_key = connections_key.to_string(); + // Reservation ensures a piece is assigned to one peer at a time, and each peer + // only works on one active piece at a time. + let reservation = { + let mut torrent_map = params.torrent_map.lock().await; + torrent_map.try_reserve_piece_for_ip(*piece, &peer_key) + }; + + match reservation { + Ok(PieceReservation::Reserved) => { + reserved_any = true; + // Build the network request and the local save/validation context together. + let piece_length = params.torrent.info.piece_length as usize; + let length = params.torrent.info.length as usize; + let db = params.db.clone(); + let torrent = params.torrent.clone(); + let torrent_map = params.torrent_map.clone(); + let piece = *piece; + let peer_key = peer_key.clone(); + let block_number = params.block_number; + + let request_piece_params = RequestPiece { + unlocked_torrent_map: torrent_map.clone(), + ip: peer_key.clone(), + stream: stream.clone(), + block_number, + piece_number: piece, + info_hash, + piece_length, + total_length: length, + map: params.map.clone(), + connections_key: connections_key.to_string(), + }; + spawn_piece_download(PieceDownloadJob { + request: request_piece_params, + downloaded_piece: DownloadedPieceJob { + db, + torrent, + torrent_map, + block_number, + piece, + ip: peer_key, + }, + }); + break; + } + Ok(PieceReservation::PieceUnavailable) => { + // This piece is already downloading or complete, so move to the next piece. + break; + } + Ok(PieceReservation::PeerUnavailable) => { + // This peer is busy or already failed this piece; try the next peer. + continue; + } + Err(err) => { + error!( + "[download] failed to reserve piece: block_number={} piece={} peer={} err={}", + params.block_number, + piece, + peer_key, + err + ); + break; + } + } + } + } + if !reserved_any { + let has_active_download = { + let torrent_map = params.torrent_map.lock().await; + torrent_map + .pieces + .values() + .any(|piece| piece.status == PieceStatus::Downloading) + }; + if !has_active_download { + return Err(format!( + "No available peer could provide remaining pieces for block {}", + params.block_number + )); + } + } + // Poll more frequently so a fully downloaded block moves into + // combine/verify/save without an extra 1-second stall. + sleep(Duration::from_millis(50)).await; + } + Ok(()) +} diff --git a/src/torrent/torrenting_system/get_nodes.rs b/src/torrent/torrenting_system/get_nodes.rs new file mode 100644 index 0000000..c5963cf --- /dev/null +++ b/src/torrent/torrenting_system/get_nodes.rs @@ -0,0 +1,29 @@ +use crate::common::binary_conversions::binary_to_ip; +use crate::records::memory::connections::CONNECTIONS; +use crate::records::memory::enums::ClientType; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; + +pub async fn get_nodes_from_memory() -> Vec<(String, Arc>)> { + // Snapshot the current connection manager so piece requests can iterate without holding the lock. + let connection_storage = CONNECTIONS.read().await; + let mut nodes = Vec::new(); + if let Some(connection) = &*connection_storage { + for connection_info in connection.connection_map.values() { + // Only miner peers are expected to serve block pieces. + if ClientType::from_bytes(&connection_info.client_type) != Some(ClientType::Miner) { + continue; + } + // Use ip:port as the scheduler key and clone the shared stream handle for requests. + let ip = binary_to_ip(connection_info.ip.clone()); + let port = connection_info.port; + let key = format!("{ip}:{port}"); + let stream_arc = Arc::clone(&connection_info.stream); + nodes.push((key, stream_arc)); + } + } + // Release the connection map before returning the cloned stream handles. + drop(connection_storage); + nodes +} diff --git a/src/torrent/torrenting_system/mod.rs b/src/torrent/torrenting_system/mod.rs new file mode 100644 index 0000000..499be71 --- /dev/null +++ b/src/torrent/torrenting_system/mod.rs @@ -0,0 +1,12 @@ +// The torrenting_system module coordinates peer discovery, piece downloads, staging, and save flow. +pub mod create_file; +pub mod download_pieces; +pub mod get_nodes; +pub mod request_piece; +pub mod save_block; +pub mod save_torrent; +pub mod setup_block_download; +pub mod temp_database_storage; +pub mod torrent_cache; +pub mod torrent_map; +pub mod torrent_requests; diff --git a/src/torrent/torrenting_system/request_piece.rs b/src/torrent/torrenting_system/request_piece.rs new file mode 100644 index 0000000..157f380 --- /dev/null +++ b/src/torrent/torrenting_system/request_piece.rs @@ -0,0 +1,92 @@ +use crate::records::memory::response_channels::{delete_entry, reserve_entry}; +use crate::rpc::command_maps::RPC_BLOCK_PIECE; +use crate::rpc::responses::RpcResponse; +use crate::torrent::structs::RequestPiece; +use crate::{timeout, Duration}; + +// Request a single block piece from a peer and wait for the matching +// response bytes to arrive through the shared RPC reply map. +pub async fn request_piece_from_node(params: RequestPiece) -> Result, String> { + // Reserve a response slot in the shared hashmap so the incoming + // piece bytes can be routed back to this request. + let (hashmap_key, _block_tx, block_rx) = reserve_entry(params.map.clone()).await; + + // Compute the exact piece length, including the shortened final + // piece when the block length is not an exact multiple. + let exact_piece_size = + if (params.piece_number as usize) * params.piece_length < params.total_length { + params.piece_length + } else { + let remainder = params.total_length % params.piece_length; + if remainder == 0 { + params.piece_length + } else { + remainder + } + }; + + // Send the raw piece request to the selected peer. + let mut request = Vec::new(); + request.push(RPC_BLOCK_PIECE); + request.extend_from_slice(&hashmap_key); + // The peer identifies the piece by block height, piece number, and torrent info hash. + request.extend_from_slice(¶ms.block_number.to_le_bytes()); + request.push(params.piece_number); + request.extend_from_slice(¶ms.info_hash.to_le_bytes()); + RpcResponse::send_raw(¶ms.stream, Some(¶ms.connections_key), &request).await; + + // Collect piece chunks until the expected piece size has been + // reached or the request times out. + let mut rx = block_rx.lock().await; + let mut response = Vec::new(); + loop { + match timeout(Duration::from_secs(10), rx.recv()).await { + Ok(Some(data)) => { + if let Ok(message) = String::from_utf8(data.clone()) { + if message.starts_with("error:") { + delete_entry(params.map.clone(), hashmap_key).await; + return Err(format!( + "Peer could not provide piece: block_number={} piece={} peer={} err={}", + params.block_number, params.piece_number, params.ip, message + )); + } + } + // Replies may arrive in chunks, so keep appending until the exact piece size is reached. + response.extend_from_slice(&data); + if response.len() == exact_piece_size { + response.truncate(exact_piece_size); + delete_entry(params.map.clone(), hashmap_key).await; + break; + } + if response.len() > exact_piece_size { + delete_entry(params.map.clone(), hashmap_key).await; + return Err(format!( + "Piece reply was larger than expected: block_number={} piece={} peer={} expected={} received={}", + params.block_number, + params.piece_number, + params.ip, + exact_piece_size, + response.len() + )); + } + } + Ok(None) => { + // A closed reply channel means the connection/request path disappeared. + delete_entry(params.map.clone(), hashmap_key).await; + return Err(format!( + "Piece reply channel closed: block_number={} piece={} peer={}", + params.block_number, params.piece_number, params.ip + )); + } + Err(_) => { + // Always remove the reserved reply slot when the request times out. + delete_entry(params.map.clone(), hashmap_key).await; + return Err(format!( + "Timed out waiting for piece reply: block_number={} piece={} peer={}", + params.block_number, params.piece_number, params.ip + )); + } + } + } + Ok(response) +} diff --git a/src/torrent/torrenting_system/save_block.rs b/src/torrent/torrenting_system/save_block.rs new file mode 100644 index 0000000..243250c --- /dev/null +++ b/src/torrent/torrenting_system/save_block.rs @@ -0,0 +1,137 @@ +use crate::common::check_genesis::genesis_checkup; +use crate::common::skein::skein_128_hash_bytes; +use crate::miner::flag::is_reorganizing_mode; +use crate::records::block_height::get_block_height::get_height; +use crate::records::record_chain::save::save_block; +use crate::records::record_chain::structs::{SaveBlockParams, SaveType}; +use crate::records::unpack_block::load_by_binary_data::load_block_from_binary; +use crate::torrent::structs::DownloadSave; +use crate::torrent::torrenting_system::create_file::combine_pieces; +use crate::torrent::torrenting_system::save_torrent::promote_staged_torrent; +use crate::torrent::torrenting_system::temp_database_storage::remove_block_pieces_from_db; +use crate::log::{error, warn}; +use crate::Path; + +async fn cleanup_download_pieces(params: &DownloadSave) { + // Failed, obsolete, or deferred downloads should not leave staged piece rows behind. + let _ = remove_block_pieces_from_db( + ¶ms.db, + params.block_number, + ¶ms.torrent.info.info_hash, + ) + .await; +} + +pub async fn verify_and_save_block(params: DownloadSave) -> Result<(), String> { + if is_reorganizing_mode() && !params.allow_during_reorg { + // Normal torrent downloads pause during reorg unless the caller is the reorg path itself. + warn!( + "[download] deferring verify/save during reorg: block_number={}", + params.block_number + ); + cleanup_download_pieces(¶ms).await; + return Err("Block download/save deferred while reorganizing.".to_string()); + } + + // Stop early if the chain has already advanced past this block while + // the download was still in progress. + let current_height = get_height(¶ms.db); + let expected_height = if current_height > 0 || genesis_checkup().await { + current_height + 1 + } else { + current_height + }; + + if params.block_number < expected_height { + warn!( + "[download] block is no longer next expected height: block_number={} current_height={} expected_height={}", + params.block_number, + current_height, + expected_height + ); + cleanup_download_pieces(¶ms).await; + return Err("Incoming block is no longer the next expected height.".to_string()); + } + + // Reassemble the downloaded pieces and verify the combined payload + // against the info hash advertised in the torrent metadata. + let result = combine_pieces( + ¶ms.db, + params.block_number, + ¶ms.torrent.info.info_hash, + ) + .await?; + + // The combined block bytes must hash to the torrent's advertised info hash. + if skein_128_hash_bytes(&result) != params.torrent.info.info_hash { + let err_msg = "Hash validation failed for complete block".to_string(); + error!( + "[download] combined hash mismatch: block_number={}", + params.block_number + ); + cleanup_download_pieces(¶ms).await; + return Err(err_msg); + } + + // Decode the block only after the torrent hash check proves the pieces fit together. + let loaded_block = match load_block_from_binary(&result).await { + Ok(block) => block, + Err(err) => { + let err_msg = format!("Failed to load block from binary: {err}"); + error!( + "[download] failed to load block from binary: block_number={} err={}", + params.block_number, err_msg + ); + cleanup_download_pieces(¶ms).await; + return Err(err_msg); + } + }; + + // Run full block verification before allowing the chain save path to persist the downloaded block. + let signatures = match loaded_block + .verify(¶ms.db, params.verification_service.clone()) + .await + { + Ok(signatures) => signatures, + Err(err) => { + error!( + "[download] block verification failed: block_number={} err={}", + params.block_number, err + ); + cleanup_download_pieces(¶ms).await; + return Err(err); + } + }; + + if let Err(err) = save_block(SaveBlockParams { + // SaveType::Updating means this block is being accepted from the network, not mined locally. + block: loaded_block, + db: params.db.clone(), + header_hash: params.torrent.info.block_hash.clone(), + timestamp: params.torrent.info.timestamp, + signatures, + save_type: SaveType::Updating, + allow_during_reorg: params.allow_during_reorg, + map: params.map.clone(), + }) + .await + { + error!( + "[download] save_block failed: block_number={} err={}", + params.block_number, err + ); + cleanup_download_pieces(¶ms).await; + return Err(err); + } + + // Once the block is saved successfully, move the staged torrent into its canonical path. + if let Err(err) = promote_staged_torrent(Path::new(¶ms.staged_path), params.block_number) { + error!( + "[download] failed to promote staged torrent: block_number={} err={}", + params.block_number, + err + ); + } + + Ok(()) +} diff --git a/src/torrent/torrenting_system/save_torrent.rs b/src/torrent/torrenting_system/save_torrent.rs new file mode 100644 index 0000000..79c175e --- /dev/null +++ b/src/torrent/torrenting_system/save_torrent.rs @@ -0,0 +1,240 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::{create_dir_all, fs, read, read_dir, remove_file, AsyncWriteExt, File, Path}; + +fn staged_torrent_dir() -> Result { + // Keep staged torrents under a dedicated subdirectory so incoming + // network data is separated from canonical saved torrent files. + let ( + _network_name, + _padded_base_coin, + _block_ext, + out_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + let staging_dir = Path::new(&out_path).join("staging"); + Ok(staging_dir) +} + +fn staged_torrent_path(height: u32, suffix: u32) -> std::path::PathBuf { + // Multiple torrents may temporarily exist for the same height during + // sync and orphan handling, so each staged file gets a numeric suffix. + let ( + _network_name, + _padded_base_coin, + _block_ext, + out_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + Path::new(&out_path) + .join("staging") + .join(format!("{height}.torrent-{suffix}")) +} + +fn canonical_torrent_path(height: u32) -> std::path::PathBuf { + let ( + _network_name, + _padded_base_coin, + _block_ext, + out_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + Path::new(&out_path).join(format!("{height}.torrent")) +} + +async fn torrent_bytes_match(path: &Path, torrent_bytes: &[u8]) -> Result { + if !path.exists() { + return Ok(false); + } + + let existing = read(path) + .await + .map_err(|e| format!("Failed to read torrent {}: {}", path.display(), e))?; + Ok(existing == torrent_bytes) +} + +fn parse_staged_torrent_file_name(file_name: &str) -> Option<(u32, u32)> { + // Staged torrent names encode both the height and the local suffix + // so replay logic can sort and process them deterministically. + let (height_str, suffix_str) = file_name.split_once(".torrent-")?; + let height = height_str.parse::().ok()?; + let suffix = suffix_str.parse::().ok()?; + Some((height, suffix)) +} + +pub async fn list_staged_torrents() -> Result, String> { + // Enumerate staged torrents in height/suffix order so replay and + // cleanup logic handle them in a stable sequence. + let staging_dir = staged_torrent_dir()?; + + if !staging_dir.exists() { + return Ok(Vec::new()); + } + + let mut entries = read_dir(&staging_dir) + .await + .map_err(|e| format!("Failed to read staging directory: {e}"))?; + let mut staged = Vec::new(); + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| format!("Failed to iterate staging directory: {e}"))? + { + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if let Some((height, suffix)) = parse_staged_torrent_file_name(file_name) { + staged.push((height, suffix, path)); + } + } + + staged.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); + Ok(staged + .into_iter() + .map(|(height, _suffix, path)| (height, path)) + .collect()) +} + +pub async fn list_staged_torrents_for_height( + height: u32, +) -> Result, String> { + let staged = list_staged_torrents().await?; + Ok(staged + .into_iter() + .filter_map(|(staged_height, path)| { + if staged_height == height { + Some(path) + } else { + None + } + }) + .collect()) +} + +pub async fn save_staged_torrent(height: u32, torrent_bytes: &[u8]) -> Result { + // Find the next available suffix for this height before writing the + // incoming torrent into the staging directory. Exact duplicates are + // suppressed if the same torrent is already staged or canonical. + let staging_path = staged_torrent_dir()?; + + create_dir_all(&staging_path) + .await + .map_err(|e| format!("Failed to create staging directory: {e}"))?; + + let canonical_path = canonical_torrent_path(height); + if torrent_bytes_match(&canonical_path, torrent_bytes).await? { + return Ok(canonical_path.to_string_lossy().to_string()); + } + + let mut next_suffix = 1_u32; + let mut entries = read_dir(&staging_path) + .await + .map_err(|e| format!("Failed to read staging directory: {e}"))?; + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| format!("Failed to iterate staging directory: {e}"))? + { + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if let Some((entry_height, suffix)) = parse_staged_torrent_file_name(file_name) { + if entry_height == height { + if torrent_bytes_match(&path, torrent_bytes).await? { + return Ok(path.to_string_lossy().to_string()); + } + if suffix >= next_suffix { + next_suffix = suffix.saturating_add(1); + } + } + } + } + + let torrent_file_path = staged_torrent_path(height, next_suffix); + let mut torrent_file = File::create(&torrent_file_path) + .await + .map_err(|e| format!("Failed to create staged torrent file: {e}"))?; + + // Keep torrent staging separate from the canonical torrent file so + // validation and orphan logic can decide when promotion is safe. + torrent_file + .write_all(torrent_bytes) + .await + .map_err(|e| format!("Failed to write staged torrent file: {e}"))?; + + Ok(torrent_file_path.to_string_lossy().to_string()) +} + +pub async fn read_staged_torrent(path: &Path) -> Result, String> { + read(path) + .await + .map_err(|e| format!("Failed to read staged torrent {}: {}", path.display(), e)) +} + +pub async fn remove_staged_torrent(path: &Path) -> Result<(), String> { + if path.exists() { + remove_file(path) + .await + .map_err(|e| format!("Failed to remove staged torrent {}: {}", path.display(), e))?; + } + Ok(()) +} + +pub async fn remove_staged_torrents_for_height(height: u32) -> Result<(), String> { + // Remove every staged copy for the requested height so stale + // torrents do not interfere with later replay or promotion. + let staged = list_staged_torrents().await?; + for (staged_height, path) in staged { + if staged_height == height { + remove_staged_torrent(&path).await?; + } + } + Ok(()) +} + +pub async fn prune_staged_torrents(current_height: u32) -> Result<(), String> { + // Snapshot-based pruning keeps recent orphan evidence available + // until the trusted rollback floor advances past it. + let cutoff = current_height; + let staged = list_staged_torrents().await?; + for (staged_height, path) in staged { + if staged_height <= cutoff { + remove_staged_torrent(&path).await?; + } + } + Ok(()) +} + +pub fn promote_staged_torrent(staged_path: &Path, height: u32) -> Result { + // Promotion moves the selected staged torrent into the canonical + // filename once the corresponding block has been saved. + let canonical_path = canonical_torrent_path(height); + + if !staged_path.exists() { + return Err(format!( + "Staged torrent does not exist for block {} at {}", + height, + staged_path.display() + )); + } + + fs::rename(staged_path, &canonical_path) + .map_err(|e| format!("Failed to promote staged torrent: {e}"))?; + + Ok(canonical_path.to_string_lossy().to_string()) +} diff --git a/src/torrent/torrenting_system/setup_block_download.rs b/src/torrent/torrenting_system/setup_block_download.rs new file mode 100644 index 0000000..a73f187 --- /dev/null +++ b/src/torrent/torrenting_system/setup_block_download.rs @@ -0,0 +1,41 @@ +use crate::records::memory::response_channels::Command; +use crate::torrent::structs::{DownloadSave, Torrent}; +use crate::torrent::torrenting_system::download_pieces::download_block_pieces; +use crate::torrent::torrenting_system::save_block::verify_and_save_block; +use crate::torrent::torrenting_system::torrent_map::create_torrent_map; +use crate::verifications::verification_service::VerificationService; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +// Build the per-piece download state, fetch the block pieces, and then +// hand the assembled block to the verify-and-save path. +pub async fn setup_download( + block_number: u32, + torrent: Torrent, + staged_path: String, + allow_during_reorg: bool, + db: Db, + verification_service: Arc, + map: Arc>, +) -> Result<(), String> { + // Initialize in-memory status tracking for each piece in the torrent. + let torrent_map = create_torrent_map(&torrent).await?; + + // Carry the shared download context through the piece download and + // final block verification stages. + let download_save_params = DownloadSave { + torrent_map, + torrent, + staged_path, + block_number, + allow_during_reorg, + db: db.clone(), + verification_service, + map, + }; + // Piece download must finish before the block can be combined and verified. + download_block_pieces(download_save_params.clone()).await?; + // The same context is reused to assemble, verify, save, and promote the staged torrent. + verify_and_save_block(download_save_params).await +} diff --git a/src/torrent/torrenting_system/temp_database_storage.rs b/src/torrent/torrenting_system/temp_database_storage.rs new file mode 100644 index 0000000..7af9434 --- /dev/null +++ b/src/torrent/torrenting_system/temp_database_storage.rs @@ -0,0 +1,54 @@ +use crate::sled::Db; + +pub async fn save_piece_to_db( + db: Db, + block_number: u32, + info_hash: &str, + piece_number: u8, + data: &[u8], +) -> Result<(), String> { + // Torrent pieces are staged in sled so interrupted downloads can continue without + // requiring the whole block to remain in memory. + let tree = db + .open_tree("block_pieces") + .map_err(|e| format!("Failed to open block_pieces tree: {e}"))?; + // Key shape keeps pieces grouped by block and torrent info hash for later combine/cleanup. + let key = format!("{block_number}-{info_hash}-{piece_number}"); + tree.insert(key.as_bytes(), data).map_err(|e| { + format!( + "Failed to save block piece {block_number}-{info_hash}-{piece_number}: {e}" + ) + })?; + Ok(()) +} + +pub async fn remove_block_pieces_from_db( + db: &Db, + block_number: u32, + info_hash: &str, +) -> Result<(), String> { + // Finished or abandoned downloads clear their staged piece rows by block prefix. + let tree = db + .open_tree("block_pieces") + .map_err(|e| format!("Failed to open block_pieces tree: {e}"))?; + let prefix = format!("{block_number}-{info_hash}-"); + let iter = tree.range(prefix.as_bytes()..); + + // Stop once the sorted sled range moves past this block/hash prefix. + for result in iter { + let (key, _value) = result.map_err(|e| { + format!( + "Failed to iterate block pieces for {block_number}-{info_hash}: {e}" + ) + })?; + if !key.starts_with(prefix.as_bytes()) { + break; + } + tree.remove(key).map_err(|e| { + format!( + "Failed to remove block piece for {block_number}-{info_hash}: {e}" + ) + })?; + } + Ok(()) +} diff --git a/src/torrent/torrenting_system/torrent_cache.rs b/src/torrent/torrenting_system/torrent_cache.rs new file mode 100644 index 0000000..95ac486 --- /dev/null +++ b/src/torrent/torrenting_system/torrent_cache.rs @@ -0,0 +1,71 @@ +use crate::torrent::structs::TorrentCacheEntry; +use crate::HashMap; +use crate::lazy_static; +use crate::Mutex; + +lazy_static! { + static ref RECENT_TORRENTS: Mutex> = + Mutex::new(HashMap::new()); +} + +pub async fn has_recent_torrent(hash: &str, _block_height: u32) -> bool { + // The hash alone identifies duplicate torrent bytes in the recent in-memory cache. + let map = RECENT_TORRENTS.lock().await; + map.contains_key(hash) +} + +pub async fn remember_recent_torrent(hash: &str, block_height: u32) -> bool { + // Incoming torrents start as not-broadcasted so later outbound logic can decide what to forward. + let mut map = RECENT_TORRENTS.lock().await; + + match map.get_mut(hash) { + Some(entry) => { + entry.block_height = block_height; + true + } + None => { + map.insert( + hash.to_string(), + TorrentCacheEntry { + block_height, + broadcasted: false, + }, + ); + false + } + } +} + +pub async fn prune_recent_torrents(current_height: u32) { + // Drop cache entries at or below the trusted cleanup height. + let mut map = RECENT_TORRENTS.lock().await; + map.retain(|_, entry| entry.block_height > current_height); +} + +pub async fn should_broadcast_torrent(hash: &str, block_height: u32) -> bool { + // The first sighting of a torrent is broadcastable, but later repeats are suppressed + // once the entry has already been marked as broadcasted. + let mut map = RECENT_TORRENTS.lock().await; + + match map.get_mut(hash) { + Some(entry) => { + entry.block_height = block_height; + if entry.broadcasted { + false + } else { + entry.broadcasted = true; + true + } + } + None => { + map.insert( + hash.to_string(), + TorrentCacheEntry { + block_height, + broadcasted: true, + }, + ); + true + } + } +} diff --git a/src/torrent/torrenting_system/torrent_map.rs b/src/torrent/torrenting_system/torrent_map.rs new file mode 100644 index 0000000..559ecfe --- /dev/null +++ b/src/torrent/torrenting_system/torrent_map.rs @@ -0,0 +1,102 @@ +use crate::records::memory::torrentmap::TorrentMap; +use crate::torrent::structs::{PieceStatus, Torrent}; +use crate::Arc; +use crate::HashMap; +use crate::Mutex; + +pub async fn create_torrent_map(torrent: &Torrent) -> Result>, String> { + // Seed piece tracking for the full torrent so the downloader can + // coordinate which pieces are pending, in flight, or complete. + let piece_count = torrent.info.pieces.len(); + if piece_count > u8::MAX as usize { + return Err("Torrent piece count exceeds u8 limit".to_string()); + } + + let mut torrent_map = TorrentMap { + pieces: HashMap::new(), + }; + for piece_number in 1..=piece_count { + let piece_number = u8::try_from(piece_number) + .map_err(|_| "Torrent piece index exceeds u8 limit".to_string())?; + torrent_map.add_piece(piece_number, PieceStatus::Pending, None, Vec::new()); + } + Ok(Arc::new(Mutex::new(torrent_map))) +} + +pub async fn piece_download_status( + unlocked_torrent_map: Arc>, + piece: u8, +) -> bool { + // Treat pieces that are already downloading or complete as satisfied + // so the scheduler moves on to the next pending piece. + let torrent_map = unlocked_torrent_map.lock().await; + if let Some(file) = torrent_map.get_piece_data(piece) { + matches!( + file.status, + PieceStatus::Downloading | PieceStatus::Complete + ) + } else { + true + } +} + +pub async fn file_download_status( + unlocked_torrent_map: Arc>, + pieces: Vec, +) -> bool { + // The block is ready only when every tracked piece has reached the + // complete state. + let torrent_map = unlocked_torrent_map.lock().await; + for piece in pieces { + if let Some(file) = torrent_map.get_piece_data(piece) { + match file.status { + PieceStatus::Pending | PieceStatus::Failed | PieceStatus::Downloading => { + return false; + } + PieceStatus::Complete => { + continue; + } + } + } + } + // Every tracked piece is complete. + true +} + +pub async fn ip_status( + unlocked_torrent_map: Arc>, + ip: String, + pieces: Vec, + single_piece: u8, +) -> bool { + let torrent_map = unlocked_torrent_map.lock().await; + + // Skip peers that have already failed this specific piece so retries + // prefer a different source. + if let Some(file) = torrent_map.get_piece_data(single_piece) { + if file.failed_ips.contains(&ip) { + return false; + } + } + + // Allow only one active piece per peer at a time to keep the + // downloader from over-assigning the same connection. + for piece in pieces { + if let Some(file) = torrent_map.get_piece_data(piece) { + if let Some(piece_ip) = &file.ip { + if ip == *piece_ip { + // A peer with an in-flight piece should not receive a second assignment. + match file.status { + PieceStatus::Downloading => { + return false; + } + _ => { + continue; + } + } + } + } + } + } + true +} diff --git a/src/torrent/torrenting_system/torrent_requests.rs b/src/torrent/torrenting_system/torrent_requests.rs new file mode 100644 index 0000000..b4efaf2 --- /dev/null +++ b/src/torrent/torrenting_system/torrent_requests.rs @@ -0,0 +1,165 @@ +use crate::io; +use crate::log::warn; +use crate::records::memory::response_channels::{Byte3, Command}; +use crate::records::memory::torrent_status::{set_torrent_status, TorrentStatus}; +use crate::rpc::command_maps::RPC_TORRENT_BY_HEIGHT; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::torrent::create_metadata::broadcast_new_torrent_to_peers; +use crate::torrent::structs::Torrent; +use crate::torrent::torrenting_system::save_torrent::save_staged_torrent; +use crate::torrent::torrenting_system::setup_block_download::setup_download; +use crate::verifications::verification_service::global_verification_service; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; + +pub async fn send_request_torrent_message( + stream: Arc>, + local_height: u32, + hashmap_key: Byte3, + connections_key: String, +) -> io::Result<()> { + // Ask the remote node for the torrent metadata for the requested + // block height using the shared response hashmap key. + let request_torrent: u8 = RPC_TORRENT_BY_HEIGHT; + let request_torrent_binary = request_torrent.to_le_bytes(); + let get_height_binary = local_height.to_le_bytes(); + let mut message = Vec::new(); + message.extend(request_torrent_binary); + message.extend(hashmap_key); + message.extend(get_height_binary); + RpcResponse::send_raw(&stream, Some(&connections_key), &message).await; + Ok(()) +} + +pub async fn handle_response_and_save_torrent( + height: u32, + db: &Db, + torrent: Torrent, + wallet_key: &str, + map: Arc>, + allow_during_reorg: bool, +) -> Result<(), String> { + let Some((torrent, staged_path)) = + stage_and_verify_torrent(height, db, torrent, wallet_key, true).await? + else { + return Ok(()); + }; + let torrent_bytes = torrent.clone().to_bytes().await; + + setup_download_for_torrent( + height, + torrent, + staged_path, + allow_during_reorg, + db.clone(), + map.clone(), + ) + .await?; + + // A requested torrent is only forwarded after this node has the + // complete validated block available for piece requests. + broadcast_new_torrent_to_peers(height, &torrent_bytes, map).await; + Ok(()) +} + +#[derive(Clone)] +pub struct ProcessTorrentResponse { + pub height: u32, + pub db: Db, + pub torrent: Torrent, + pub wallet_key: String, + pub map: Arc>, + pub allow_during_reorg: bool, + pub process_now: bool, +} + +pub async fn process_torrent_response(params: ProcessTorrentResponse) -> Result<(), String> { + let Some((torrent, staged_path)) = stage_and_verify_torrent( + params.height, + ¶ms.db, + params.torrent, + ¶ms.wallet_key, + params.process_now, + ) + .await? + else { + return Ok(()); + }; + let torrent_bytes = torrent.clone().to_bytes().await; + + setup_download_for_torrent( + params.height, + torrent, + staged_path, + params.allow_during_reorg, + params.db, + params.map.clone(), + ) + .await?; + + // Successful replay/download means this node can now seed the block + // behind this torrent, so rebroadcasting is safe. + broadcast_new_torrent_to_peers(params.height, &torrent_bytes, params.map).await; + Ok(()) +} + +pub async fn stage_and_verify_torrent( + height: u32, + db: &Db, + torrent: Torrent, + wallet_key: &str, + process_now: bool, +) -> Result, String> { + // Stage the torrent first so a parseable candidate is never lost just + // because the current chain state is in the middle of syncing or + // orphan correction. Immediate validation/download only happens when + // this torrent is actionable right now. + let torrent_bytes = torrent.clone().to_bytes().await; + let staged_path = save_staged_torrent(height, &torrent_bytes) + .await + .map_err(|err| format!("Failed to save staged torrent: {err}"))?; + set_torrent_status( + height, + &torrent.info.info_hash, + TorrentStatus::Pending, + ) + .await; + + if !process_now { + return Ok(None); + } + + if let Err(error) = torrent.verify(height, db, wallet_key).await { + warn!("[torrent] validation failed: height={height} err={error}"); + return Err(error); + } + + Ok(Some((torrent, staged_path))) +} + +pub async fn setup_download_for_torrent( + height: u32, + torrent: Torrent, + staged_path: String, + allow_during_reorg: bool, + db: Db, + map: Arc>, +) -> Result<(), String> { + let verification_service = global_verification_service() + .ok_or_else(|| "Verification service not initialized".to_string())?; + + // Hand the staged torrent off to the download pipeline so the + // full block can be assembled, verified, and saved. + setup_download( + height, + torrent, + staged_path, + allow_during_reorg, + db, + Arc::new(verification_service), + map, + ) + .await +} diff --git a/src/torrent/unpack_local_torrent.rs b/src/torrent/unpack_local_torrent.rs new file mode 100644 index 0000000..2537c7d --- /dev/null +++ b/src/torrent/unpack_local_torrent.rs @@ -0,0 +1,68 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::records::block_height::decrease_block_height::decrease_height; +use crate::records::block_height::get_block_height::get_height; +use crate::sled::Db; +use crate::torrent::structs::Torrent; +use crate::AsyncReadExt; +use crate::File; +use crate::Path; +use crate::PathBuf; + +async fn get_or_correct_local_torrent(db: &Db, height: u32) -> Result { + // Look for the canonical torrent file for this network and height. + let ( + _network_name, + _padded_base_coin, + _file_ext, + torrent_path, + _wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + + let torrent_file = PathBuf::from(&torrent_path) + .join(format!("{height}.torrent")) + .to_string_lossy() + .into_owned(); + let file_exists = Path::new(&torrent_file).exists(); + if file_exists { + // Existing canonical torrent path can be loaded by the caller. + Ok(torrent_file) + } else { + // If the chain height points past available torrent data, step the recorded height back. + let check_height = get_height(db); + if check_height > 0 { + let torrent_file = PathBuf::from(torrent_path) + .join(format!("{height}.torrent")) + .to_string_lossy() + .into_owned(); + + let file_exists = Path::new(&torrent_file).exists(); + if !file_exists { + decrease_height(check_height - 1, db); + } + } + Err("Failed to load torrent".to_string()) + } +} + +pub async fn load_torrent(db: &Db, height: u32) -> Result { + let torrent = get_or_correct_local_torrent(db, height).await?; + + if let Ok(mut torrent_file) = File::open(&torrent).await { + // Torrent files are stored in the compact binary format from Torrent::to_bytes. + let mut torrent_contents = Vec::new(); + torrent_file + .read_to_end(&mut torrent_contents) + .await + .map_err(|e| e.to_string())?; + // Convert the saved bytes back into the in-memory metadata struct. + Torrent::from_bytes(&torrent_contents) + .await + .map_err(|e| e.to_string()) + } else { + Err("Could not open torrent".to_string()) + } +} diff --git a/src/torrent/unpack_remote_torrent.rs b/src/torrent/unpack_remote_torrent.rs new file mode 100644 index 0000000..204b255 --- /dev/null +++ b/src/torrent/unpack_remote_torrent.rs @@ -0,0 +1,72 @@ +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::rpc::command_maps::RPC_TORRENT_BY_HEIGHT; +use crate::rpc::responses::RpcResponse; +use crate::torrent::structs::Torrent; +use crate::Arc; +use crate::Mutex; +use crate::TcpStream; +use crate::{timeout, Duration}; + +async fn send_torrent_request( + stream: Arc>, + local_height: u32, + map: Arc>, + connections_key: &str, +) -> Result, String> { + // Remote torrent requests use the normal command map so the reply can be matched + // back to the correct in-flight request. + let (hashmap_key, _torrent_checkup_tx, torrent_checkup_rx) = reserve_entry(map.clone()).await; + + let request_torrent: u8 = RPC_TORRENT_BY_HEIGHT; + let request_torrent_binary = request_torrent.to_le_bytes(); + let get_height_binary = local_height.to_le_bytes(); + + // This command requests the torrent bytes for one block height. + let mut message = Vec::new(); + message.extend(request_torrent_binary); + message.extend(hashmap_key); + message.extend_from_slice(&get_height_binary); + + RpcResponse::send_raw(&stream, Some(connections_key), &message).await; + + let mut rx = torrent_checkup_rx.lock().await; + if let Some(torrent_contents) = + timeout(Duration::from_secs(30), rx.recv()) + .await + .map_err(|_| { + format!( + "Timed out waiting for torrent response at height {local_height}" + ) + })? + { + // Torrent errors can come back as text instead of binary metadata. + if let Ok(torrent_str) = String::from_utf8(torrent_contents.clone()) { + let trimmed = torrent_str.trim(); + if trimmed.starts_with("error:") { + return Err(trimmed.to_string()); + } + if !trimmed.is_empty() { + return Err(format!("Unexpected textual torrent response: {trimmed}")); + } + } + Ok(torrent_contents) + } else { + Err("Failed to receive torrent response".to_string()) + } +} + +pub async fn request_torrent( + stream: Arc>, + local_height: u32, + map: Arc>, + connections_key: &str, +) -> Result { + // The remote request path only converts the returned bytes into a Torrent after + // the raw request/response exchange succeeds. + match send_torrent_request(stream, local_height, map, connections_key).await { + Ok(torrent_contents) => Torrent::from_bytes(&torrent_contents) + .await + .map_err(|e| e.to_string()), + Err(err) => Err(err), + } +} diff --git a/src/verifications/async_funcs/asset_rules.rs b/src/verifications/async_funcs/asset_rules.rs new file mode 100644 index 0000000..e1bea38 --- /dev/null +++ b/src/verifications/async_funcs/asset_rules.rs @@ -0,0 +1,17 @@ +pub fn reserved_base_coins() -> [&'static str; 11] { + // Base-coin tickers are reserved and cannot be reused by token or + // NFT creation paths elsewhere in verification. + [ + "CLC ", + "CLTC ", + "BTC ", + "ETH ", + "SOL ", + "BNB ", + "XRP ", + "ADA ", + "XMR ", + "TRX ", + "BCH ", + ] +} diff --git a/src/verifications/async_funcs/checks/balance_check.rs b/src/verifications/async_funcs/checks/balance_check.rs new file mode 100644 index 0000000..0e0e7fa --- /dev/null +++ b/src/verifications/async_funcs/checks/balance_check.rs @@ -0,0 +1,66 @@ +use crate::records::balance_sheet::get_wallet_balance::get_balance_with_db; +use crate::records::memory::mempool::{get_basecoin_balance, get_coin_balance, BASECOIN}; +use crate::sled::Db; + +pub async fn balance_checkup(db: &Db, value: u64, txfee: u64, coin: String, address: &str) -> bool { + // Compare saved balances against already-pending mempool usage so + // a new transaction cannot overspend funds that are in flight. + let mempool_basecoin_balance = match get_basecoin_balance(db, address).await { + Ok(balance) => balance, + Err(_) => { + return false; + } + }; + let wallet_basecoin_balance = match get_balance_with_db(db, address, &BASECOIN).await { + Ok(balance) => balance, + Err(_) => { + return false; + } + }; + let basecoin_balance = match wallet_basecoin_balance.checked_sub(mempool_basecoin_balance) { + Some(balance) => balance, + None => { + return false; + } + }; + + // Base coin spends consume both the transfer amount and the fee, + // while other assets still pay the fee from the base-coin balance. + if coin.trim().to_lowercase() == BASECOIN.trim().to_lowercase() { + let transaction_value = match txfee.checked_add(value) { + Some(total) => total, + None => { + return false; + } + }; + if transaction_value > basecoin_balance { + return false; + } + } else { + if txfee > basecoin_balance { + return false; + } + let mempool_coin_balance = match get_coin_balance(db, address, &coin).await { + Ok(balance) => balance, + Err(_) => { + return false; + } + }; + let wallet_coin_balance = match get_balance_with_db(db, address, &coin).await { + Ok(balance) => balance, + Err(_) => { + return false; + } + }; + let coin_balance = match wallet_coin_balance.checked_sub(mempool_coin_balance) { + Some(balance) => balance, + None => { + return false; + } + }; + if value > coin_balance { + return false; + } + } + true +} diff --git a/src/verifications/async_funcs/checks/block_balance.rs b/src/verifications/async_funcs/checks/block_balance.rs new file mode 100644 index 0000000..4c7a0fd --- /dev/null +++ b/src/verifications/async_funcs/checks/block_balance.rs @@ -0,0 +1,441 @@ +use crate::blocks::loans::LoanContractTransaction; +use crate::common::nft_assets::nft_asset_name; +use crate::common::types::Transaction; +use crate::records::balance_sheet::get_wallet_balance::get_balance_with_db; +use crate::records::memory::mempool::{ + get_basecoin_balance, get_coin_balance, signature_exists, BASECOIN, +}; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::decode; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone)] +struct BalanceDebit { + address: String, + coin: String, + amount: u64, +} + +struct TransactionBalanceView { + hash: String, + signatures: Vec, + debits: Vec, + check_saved_txid: bool, +} + +pub(crate) struct TransactionBalanceReservation { + hash: String, + signatures: Vec, + debits: HashMap<(String, String), u64>, + available: HashMap<(String, String), u64>, +} + +pub struct BlockBalanceTracker { + transaction_hashes: HashSet, + transaction_signatures: HashSet, + reserved: HashMap<(String, String), u64>, +} + +impl BlockBalanceTracker { + pub fn new() -> Self { + Self { + transaction_hashes: HashSet::new(), + transaction_signatures: HashSet::new(), + reserved: HashMap::new(), + } + } + + pub(crate) fn reserve( + &mut self, + reservation: TransactionBalanceReservation, + ) -> Result<(), String> { + if !self.transaction_hashes.insert(reservation.hash) { + return Err("Duplicate transaction hash inside block.".to_string()); + } + + for signature in reservation.signatures { + if !signature.is_empty() && !self.transaction_signatures.insert(signature) { + return Err("Duplicate transaction signature inside block.".to_string()); + } + } + + let mut updates = Vec::new(); + for (key, transaction_debit) in reservation.debits { + let already_reserved = self.reserved.get(&key).copied().unwrap_or(0); + let required = already_reserved + .checked_add(transaction_debit) + .ok_or_else(|| "Block balance reservation overflowed.".to_string())?; + let available = reservation.available.get(&key).copied().unwrap_or(0); + + if required > available { + let (address, coin) = key; + return Err(format!( + "Insufficient block-local funds: address={address} coin={} available={available} required={required}", + coin.trim() + )); + } + + updates.push((key, required)); + } + + for (key, required) in updates { + self.reserved.insert(key, required); + } + + Ok(()) + } +} + +impl Default for BlockBalanceTracker { + fn default() -> Self { + Self::new() + } +} + +fn add_debit(debits: &mut Vec, address: &str, coin: String, amount: u64) { + if amount == 0 { + return; + } + + debits.push(BalanceDebit { + address: address.to_string(), + coin, + amount, + }); +} + +fn add_to_total( + totals: &mut HashMap<(String, String), u64>, + debit: &BalanceDebit, +) -> Result<(), String> { + let key = (debit.address.clone(), debit.coin.clone()); + let current = totals.get(&key).copied().unwrap_or(0); + let updated = current + .checked_add(debit.amount) + .ok_or_else(|| "Block balance reservation overflowed.".to_string())?; + totals.insert(key, updated); + Ok(()) +} + +fn add_amount_to_total( + totals: &mut HashMap<(String, String), u64>, + key: (String, String), + amount: u64, +) -> Result<(), String> { + let current = totals.get(&key).copied().unwrap_or(0); + let updated = current + .checked_add(amount) + .ok_or_else(|| "Block balance reservation overflowed.".to_string())?; + totals.insert(key, updated); + Ok(()) +} + +async fn transaction_is_pending(view: &TransactionBalanceView) -> Result { + for signature in &view.signatures { + if signature.is_empty() { + continue; + } + + if signature_exists(signature, &view.hash) + .await + .map_err(|err| err.to_string())? + { + return Ok(true); + } + } + + Ok(false) +} + +async fn loan_contract_for_hash( + db: &Db, + contract_hash: &str, +) -> Result { + let contract_key = + decode(contract_hash).map_err(|err| format!("Invalid loan contract hash: {err}"))?; + let RpcResponse::Binary(bytes) = request_transaction_by_txid(db, contract_key).await; + + if bytes.is_empty() || bytes[0] != 7 { + return Err("Invalid loan contract: referenced transaction is not a loan contract".into()); + } + + LoanContractTransaction::from_bytes(7, &bytes[1..]) + .await + .map_err(|err| format!("Invalid loan contract: {err}")) +} + +fn saved_txid_exists(db: &Db, hash: &str) -> Result { + let tree = db + .open_tree("txid") + .map_err(|err| format!("Failed to open txid tree: {err}"))?; + let key = decode(hash).map_err(|err| format!("Invalid transaction hash: {err}"))?; + + tree.contains_key(key) + .map_err(|err| format!("Failed to check txid tree: {err}")) +} + +async fn transaction_balance_view( + db: &Db, + transaction: &Transaction, +) -> Result { + match transaction { + Transaction::Genesis(tx) => Ok(TransactionBalanceView { + hash: tx.unsigned.hash().await, + signatures: Vec::new(), + debits: Vec::new(), + check_saved_txid: false, + }), + Transaction::Rewards(tx) => Ok(TransactionBalanceView { + hash: tx.unsigned.hash().await, + signatures: Vec::new(), + debits: Vec::new(), + check_saved_txid: true, + }), + Transaction::Transfer(tx) => { + let transfer = &tx.unsigned_transfer; + let mut debits = Vec::new(); + let asset = nft_asset_name(&transfer.coin, transfer.nft_series); + + // The sender pays the transferred asset plus the base-coin fee. + add_debit(&mut debits, &transfer.sender, asset, transfer.value); + add_debit(&mut debits, &transfer.sender, BASECOIN.clone(), transfer.txfee); + + Ok(TransactionBalanceView { + hash: transfer.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::Burn(tx) => { + let burn = &tx.unsigned_burn; + let mut debits = Vec::new(); + let asset = nft_asset_name(&burn.coin, burn.nft_series); + + add_debit(&mut debits, &burn.address, asset, burn.value); + add_debit(&mut debits, &burn.address, BASECOIN.clone(), burn.txfee); + + Ok(TransactionBalanceView { + hash: burn.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::Token(tx) => { + let token = &tx.unsigned_create_token; + let mut debits = Vec::new(); + + add_debit(&mut debits, &token.creator, BASECOIN.clone(), token.txfee); + + Ok(TransactionBalanceView { + hash: token.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::IssueToken(tx) => { + let token = &tx.unsigned_issue_token; + let mut debits = Vec::new(); + + add_debit(&mut debits, &token.creator, BASECOIN.clone(), token.txfee); + + Ok(TransactionBalanceView { + hash: token.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::Nft(tx) => { + let nft = &tx.unsigned_create_nft; + let mut debits = Vec::new(); + + add_debit(&mut debits, &nft.creator, BASECOIN.clone(), nft.txfee); + + Ok(TransactionBalanceView { + hash: nft.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::Marketing(tx) => { + let marketing = &tx.unsigned_marketing; + let mut debits = Vec::new(); + + add_debit( + &mut debits, + &marketing.advertiser, + BASECOIN.clone(), + marketing.txfee, + ); + + Ok(TransactionBalanceView { + hash: marketing.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::Swap(tx) => { + let swap = &tx.unsigned_swap; + let mut debits = Vec::new(); + let asset1 = nft_asset_name(&swap.ticker1, swap.nft_series1); + let asset2 = nft_asset_name(&swap.ticker2, swap.nft_series2); + let sender1_asset_total = swap + .value1 + .checked_add(swap.tip1) + .ok_or_else(|| "Swap sender1 debit overflowed.".to_string())?; + let sender2_asset_total = swap + .value2 + .checked_add(swap.tip2) + .ok_or_else(|| "Swap sender2 debit overflowed.".to_string())?; + + add_debit(&mut debits, &swap.sender1, asset1, sender1_asset_total); + add_debit(&mut debits, &swap.sender1, BASECOIN.clone(), swap.txfee1); + add_debit(&mut debits, &swap.sender2, asset2, sender2_asset_total); + add_debit(&mut debits, &swap.sender2, BASECOIN.clone(), swap.txfee2); + + Ok(TransactionBalanceView { + hash: swap.hash().await, + signatures: vec![tx.signature1.clone(), tx.signature2.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::Lender(tx) => { + let loan = &tx.unsigned_loan_contract; + let mut debits = Vec::new(); + + add_debit(&mut debits, &loan.lender, loan.loan_coin.clone(), loan.loan_amount); + add_debit(&mut debits, &loan.lender, BASECOIN.clone(), loan.txfee); + add_debit( + &mut debits, + &loan.borrower, + loan.collateral.clone(), + loan.collateral_amount, + ); + + Ok(TransactionBalanceView { + hash: loan.hash().await, + signatures: vec![tx.signature1.clone(), tx.signature2.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::Borrower(tx) => { + let payment = &tx.unsigned_contract_payment; + let contract = loan_contract_for_hash(db, &payment.contract_hash).await?; + let mut debits = Vec::new(); + let payment_total = payment + .payback_amount + .checked_add(payment.tip) + .ok_or_else(|| "Loan payment debit overflowed.".to_string())?; + + add_debit( + &mut debits, + &payment.address, + contract.unsigned_loan_contract.loan_coin, + payment_total, + ); + add_debit(&mut debits, &payment.address, BASECOIN.clone(), payment.txfee); + + Ok(TransactionBalanceView { + hash: payment.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::Collateral(tx) => { + let collateral = &tx.unsigned_collateral_claim; + let mut debits = Vec::new(); + + add_debit( + &mut debits, + &collateral.address, + BASECOIN.clone(), + collateral.txfee, + ); + + Ok(TransactionBalanceView { + hash: collateral.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::Vanity(tx) => { + let vanity = &tx.unsigned_vanity_address; + let mut debits = Vec::new(); + + add_debit(&mut debits, &vanity.address, BASECOIN.clone(), vanity.txfee); + + Ok(TransactionBalanceView { + hash: vanity.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + } +} + +async fn pending_reserved_amount(db: &Db, address: &str, coin: &str) -> Result { + if coin.trim().eq_ignore_ascii_case(BASECOIN.trim()) { + get_basecoin_balance(db, address) + .await + .map_err(|err| err.to_string()) + } else { + get_coin_balance(db, address, coin) + .await + .map_err(|err| err.to_string()) + } +} + +pub(crate) async fn prepare_transaction_balance_reservation( + db: &Db, + transaction: &Transaction, +) -> Result { + let view = transaction_balance_view(db, transaction).await?; + if view.check_saved_txid && saved_txid_exists(db, &view.hash)? { + return Err("This transaction already exists.".to_string()); + } + + let already_reserved_in_mempool = transaction_is_pending(&view).await?; + let mut debits = HashMap::new(); + let mut available = HashMap::new(); + + // Aggregate each transaction by address/asset first. Base-coin transfers, + // for example, can debit the same balance for both value and fee. + for debit in &view.debits { + add_to_total(&mut debits, debit)?; + } + + for ((address, coin), transaction_debit) in &debits { + let confirmed_balance = get_balance_with_db(db, address, coin) + .await + .map_err(|err| err.to_string())?; + let mempool_reserved = pending_reserved_amount(db, address, coin).await?; + let included_reserved = if already_reserved_in_mempool { + *transaction_debit + } else { + 0 + }; + let external_mempool_reserved = mempool_reserved.saturating_sub(included_reserved); + let spendable = confirmed_balance.saturating_sub(external_mempool_reserved); + + add_amount_to_total(&mut available, (address.clone(), coin.clone()), spendable)?; + } + + Ok(TransactionBalanceReservation { + hash: view.hash, + signatures: view.signatures, + debits, + available, + }) +} diff --git a/src/verifications/async_funcs/checks/mempool_check.rs b/src/verifications/async_funcs/checks/mempool_check.rs new file mode 100644 index 0000000..23f9424 --- /dev/null +++ b/src/verifications/async_funcs/checks/mempool_check.rs @@ -0,0 +1,7 @@ +use crate::records::memory::mempool::signature_exists; + +pub async fn memcheck(signature: &str, hash: &str) -> bool { + // Mempool uniqueness is checked by signature and hash so duplicate + // pending transactions can be rejected before deeper verification. + signature_exists(signature, hash).await.unwrap_or(false) +} diff --git a/src/verifications/async_funcs/checks/mod.rs b/src/verifications/async_funcs/checks/mod.rs new file mode 100644 index 0000000..0fa89be --- /dev/null +++ b/src/verifications/async_funcs/checks/mod.rs @@ -0,0 +1,6 @@ +// These lower-level checks are reused by the higher-level async verification routines. +pub mod balance_check; +pub mod block_balance; +pub mod mempool_check; +pub mod time_checks; +pub mod verify_db; diff --git a/src/verifications/async_funcs/checks/time_checks.rs b/src/verifications/async_funcs/checks/time_checks.rs new file mode 100644 index 0000000..47305b2 --- /dev/null +++ b/src/verifications/async_funcs/checks/time_checks.rs @@ -0,0 +1,17 @@ +use crate::DateTime; +use crate::Utc; + +pub async fn is_within_30_days(timestamp: u32) -> bool { + // Convert the on-chain timestamp into UTC and reject values that fall + // more than 30 days in the past or fail to decode as valid datetimes. + let transaction_time = DateTime::::from_timestamp(timestamp as i64, 0); + + match transaction_time { + Some(transaction_time) => { + let current_time = Utc::now(); + let duration = current_time.signed_duration_since(transaction_time); + duration.num_days() <= 30 + } + None => false, + } +} diff --git a/src/verifications/async_funcs/checks/verify_db.rs b/src/verifications/async_funcs/checks/verify_db.rs new file mode 100644 index 0000000..c6b4048 --- /dev/null +++ b/src/verifications/async_funcs/checks/verify_db.rs @@ -0,0 +1,26 @@ +use crate::decode; +use crate::sled::Db; + +pub async fn db_hex_verification(db: &Db, dbtree: &str, dbkey: &str) -> bool { + // Hex-encoded keys are used for hashes and txids, so decode first and + // then return true only when the key does not already exist. + let tree = db.open_tree(dbtree).unwrap(); + let key = decode(dbkey).unwrap(); + let key_exists = tree.contains_key(key).unwrap(); + if key_exists { + return false; + } + true +} + +pub async fn db_bytes_verification(db: &Db, dbtree: &str, dbkey: &str) -> bool { + // Raw byte-string lookups use the opposite convention here and return + // true only when the provided key already exists in the target tree. + let tree = db.open_tree(dbtree).unwrap(); + let key = dbkey.as_bytes(); + let key_exists = tree.contains_key(key).unwrap(); + if key_exists { + return true; + } + false +} diff --git a/src/verifications/async_funcs/mod.rs b/src/verifications/async_funcs/mod.rs new file mode 100644 index 0000000..d09645f --- /dev/null +++ b/src/verifications/async_funcs/mod.rs @@ -0,0 +1,21 @@ +// The async verification functions cover the transaction families and block-level checks +// that may require database access or other asynchronous work. +pub mod asset_rules; +pub mod checks; +pub mod total_payments; +pub mod transactions; +pub mod validate_torrent_data; +pub mod verify_block; +pub mod verify_borrower; +pub mod verify_burn; +pub mod verify_collateral; +pub mod verify_create_nft; +pub mod verify_create_token; +pub mod verify_genesis; +pub mod verify_issue_token; +pub mod verify_lender; +pub mod verify_marketing; +pub mod verify_rewards; +pub mod verify_swap; +pub mod verify_transfer; +pub mod verify_vanity; diff --git a/src/verifications/async_funcs/total_payments.rs b/src/verifications/async_funcs/total_payments.rs new file mode 100644 index 0000000..17d5ab6 --- /dev/null +++ b/src/verifications/async_funcs/total_payments.rs @@ -0,0 +1,54 @@ +use crate::decode; +use crate::records::memory::mempool::get_pending_payments_for_contract; +use crate::sled::Db; + +// Contract payment totals are stored as a packed list of u64 values +// under the loan contract key. +fn deserialize_value(bytes: &[u8]) -> Option> { + let mut vec = vec![]; + let mut offset = 0; + while offset + 8 <= bytes.len() { + vec.push(u64::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ])); + offset += 8; + } + if offset == bytes.len() { + Some(vec) + } else { + None + } +} + +pub async fn get_total_payments(db: &Db, contract_id: &str) -> u64 { + // Combine confirmed on-chain payments with pending mempool loan + // payments so contract checks see the most current total. + let tree = db.open_tree("contract_payments").unwrap(); + let contract_key = match decode(contract_id) { + Ok(bytes) => bytes, + Err(_) => return 0, + }; + + let payments: Option> = match tree.get(contract_key) { + Ok(Some(p)) => deserialize_value(&p), + _ => None, + }; + + let chain_total = match payments { + Some(p) => p.iter().sum(), + None => 0, + }; + + let pending_total = get_pending_payments_for_contract(contract_id) + .await + .unwrap_or(0); + + chain_total + pending_total +} diff --git a/src/verifications/async_funcs/transactions.rs b/src/verifications/async_funcs/transactions.rs new file mode 100644 index 0000000..2edc5ce --- /dev/null +++ b/src/verifications/async_funcs/transactions.rs @@ -0,0 +1,263 @@ +use crate::common::types::Transaction; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::block_balance::{ + prepare_transaction_balance_reservation, BlockBalanceTracker, +}; +use crate::verifications::sync_funcs::transaction_verify_loop::COUNTER; +use crate::Arc; +use crate::AtomicBool; +use crate::AtomicOrdering; +use crate::Mutex; + +pub async fn verify_transactions( + miner: String, + transactions: Vec, + db: &Db, + stop_flag: Arc, + balance_tracker: Arc>, +) -> Result, String> { + let mut results: Vec = Vec::new(); + + // A single-transaction fast path avoids the extra loop scaffolding + // while still respecting the shared stop flag. + if transactions.len() == 1 { + let transaction = &transactions[0]; + if stop_flag.load(AtomicOrdering::SeqCst) { + return Err("Stop signal received.".to_string()); + } + + match verify_transaction(miner, transaction.clone(), db, balance_tracker.clone()).await { + Ok(result) => { + results.push(result); + } + Err(error) => { + stop_flag.store(true, AtomicOrdering::SeqCst); + return Err(error); + } + } + } else { + // In the multi-transaction path, stop as soon as any verification + // fails so parallel mining work can abandon the candidate block. + for transaction in &transactions { + if stop_flag.load(AtomicOrdering::SeqCst) { + return Err("Stop signal received.".to_string()); + } + + match verify_transaction( + miner.clone(), + transaction.clone(), + db, + balance_tracker.clone(), + ) + .await + { + Ok(result) => { + results.push(result); + } + Err(error) => { + stop_flag.store(true, AtomicOrdering::SeqCst); + return Err(error); + } + } + } + } + Ok(results) +} + +// Dispatch each transaction into its type-specific verification path and +// normalize failures into a consistent error string. +async fn verify_transaction( + miner: String, + transaction: Transaction, + db: &Db, + balance_tracker: Arc>, +) -> Result { + if let Transaction::Genesis(genesis_tx) = &transaction { + match genesis_tx.verify(miner, db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for create genesis transaction: {err}" + )); + } + } + } + if let Transaction::Rewards(rewards_tx) = &transaction { + match rewards_tx.verify(miner, db).await { + Ok(value) => { + let counter = COUNTER.fetch_add(1, AtomicOrdering::SeqCst); + if counter > 0 { + return Err( + "You cannot include more than 1 reward in a mined block.".to_string() + ); + } else { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + } + Err(err) => { + return Err(format!( + "Validation failed for rewards transaction: {err}" + )); + } + } + } + if let Transaction::Transfer(transfer_tx) = &transaction { + match transfer_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for transfer transaction: {err}" + )); + } + } + } + if let Transaction::Burn(burn_tx) = &transaction { + match burn_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!("Validation failed for burn transaction: {err}")); + } + } + } + if let Transaction::Token(create_token_tx) = &transaction { + match create_token_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for create token transaction: {err}" + )); + } + } + } + if let Transaction::IssueToken(issue_token_tx) = &transaction { + match issue_token_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for issue token transaction: {err}" + )); + } + } + } + if let Transaction::Nft(create_nft_tx) = &transaction { + match create_nft_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for create nft transaction: {err}" + )); + } + } + } + if let Transaction::Marketing(marketing_tx) = &transaction { + match marketing_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for marketing transaction: {err}" + )); + } + } + } + if let Transaction::Swap(swap_tx) = &transaction { + match swap_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!("Validation failed for swap transaction: {err}")); + } + } + } + if let Transaction::Lender(loan_creation_tx) = &transaction { + match loan_creation_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for loan creation transaction: {err}" + )); + } + } + } + if let Transaction::Borrower(loan_payment_tx) = &transaction { + match loan_payment_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for loan payment transaction: {err}" + )); + } + } + } + if let Transaction::Collateral(collateral_claim_tx) = &transaction { + match collateral_claim_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for claim collateral transaction: {err}" + )); + } + } + } + if let Transaction::Vanity(vanity_tx) = &transaction { + match vanity_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker).await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for vanity address transaction: {err}" + )); + } + } + } + Err( + "Validation failed: unsupported transaction variant reached verification dispatch." + .to_string(), + ) +} + +async fn reserve_verified_transaction( + db: &Db, + transaction: &Transaction, + balance_tracker: Arc>, +) -> Result<(), String> { + // Build the balance reservation while this worker still has the + // transaction in hand, then lock only the shared in-block totals. + let reservation = prepare_transaction_balance_reservation(db, transaction).await?; + let mut tracker = balance_tracker.lock().await; + tracker.reserve(reservation) +} diff --git a/src/verifications/async_funcs/validate_torrent_data.rs b/src/verifications/async_funcs/validate_torrent_data.rs new file mode 100644 index 0000000..26adee1 --- /dev/null +++ b/src/verifications/async_funcs/validate_torrent_data.rs @@ -0,0 +1,129 @@ +use crate::common::check_genesis::genesis_checkup; +use crate::records::ip_score::enums::InfractionType; +use crate::records::ip_score::score::update_ip_score; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::unpack_block::unpack_header::load_block_header; +use crate::records::wallet_registry::is_registered_short_address; +use crate::sled::Db; +use crate::torrent::structs::Torrent; +use crate::wallets::structures::Wallet; +use crate::Utc; + +impl Torrent { + // validate the torrent metadata before any block pieces are + // downloaded so invalid broadcasts can be rejected early + pub async fn verify(&self, height: u32, db: &Db, wallet_key: &str) -> Result<(), String> { + let address = &self.mined_by; + let ip = NodeInfo::find_ip_by_address(address) + .await + .unwrap_or_else(|| "0.0.0.0".to_string()); + let now = Utc::now().timestamp() as u32; + + if let Err(e) = Self::validate_piece_count(self).await { + let _ = + update_ip_score(&ip, "miner", InfractionType::BadTorrent, now, db, wallet_key).await; + return Err(e); + } + + if let Err(e) = Self::validate_mined_by(self, db).await { + let _ = + update_ip_score(&ip, "miner", InfractionType::BadTorrent, now, db, wallet_key).await; + return Err(e); + } + + Self::validate_difficulty_matching(self, height).await?; + Ok(()) + } + + // validate the correct number of + // pieces exist in the torrent + // as expected + async fn validate_piece_count(&self) -> Result<(), String> { + // get the piece length from the torrent file + let piece_length = self.info.piece_length; + if piece_length == 0 { + return Err("error: Invalid piece length".to_string()); + } + + // get the pieces from the torrent + let pieces = &self.info.pieces; + + // expected piece count is derived with the same checked integer + // math the parser uses so later validation matches the wire-format + // acceptance rules exactly. + let piece_length_u64 = piece_length as u64; + let expected_pieces_count_u64 = self + .info + .length + .checked_add(piece_length_u64 - 1) + .ok_or_else(|| "error: Piece count overflow".to_string())? + / piece_length_u64; + let expected_pieces_count = usize::try_from(expected_pieces_count_u64) + .map_err(|_| "error: Piece count too large".to_string())?; + + if expected_pieces_count > u8::MAX as usize { + return Err("error: Piece count exceeds u8 limit".to_string()); + } + + // set actual piece count to the number of pieces + let actual_pieces_count = pieces.len(); + if actual_pieces_count > u8::MAX as usize { + return Err("error: Piece count exceeds u8 limit".to_string()); + } + + // verify our values match or + // return an error + if actual_pieces_count != expected_pieces_count { + return Err("error Invalid number of pieces".to_string()); + } + + // Piece numbers are serialized as u8 values and must be exactly + // 1..=piece_count so download scheduling cannot wrap or skip indexes. + for (index, piece_map) in pieces.iter().enumerate() { + let expected_piece = u8::try_from(index + 1) + .map_err(|_| "error: Piece index exceeds u8 limit".to_string())?; + if piece_map.len() != 1 || !piece_map.contains_key(&expected_piece) { + return Err("error: Invalid piece index".to_string()); + } + } + + Ok(()) + } + + // ensure mined by is always a valid address on the correct chain + async fn validate_mined_by(&self, db: &Db) -> Result<(), String> { + let mined_by = &self.mined_by; + if !Wallet::short_address_validation(mined_by) { + return Err("error: Invalid mined by address".to_string()); + } + if !is_registered_short_address(db, mined_by) + .map_err(|_| "error: Could not verify mined by registration".to_string())? + { + return Err("error: Mined by address is not registered".to_string()); + } + Ok(()) + } + + async fn validate_difficulty_matching(&self, adjusted_block_number: u32) -> Result<(), String> { + // compare the torrent's stated block difficulty against + // the next difficulty recorded in the current local chain + let blockchain_difficulty = if genesis_checkup().await { + let previous_block_number = adjusted_block_number + .checked_sub(1) + .ok_or_else(|| "error: Invalid block number, cannot subtract.".to_string())?; + + // use the previous block header as the source of truth + // for the difficulty this torrent should be mining under + let blockchain_data = load_block_header(previous_block_number).await?; + blockchain_data.unmined_block.next_block_difficulty + } else { + 3000000000000000_u64 + }; + let torrent_difficulty = self.info.this_block_difficulty; + if blockchain_difficulty == torrent_difficulty { + Ok(()) + } else { + Err("error: Difficulty mismatch with the blockchain data.".to_string()) + } + } +} diff --git a/src/verifications/async_funcs/verify_block.rs b/src/verifications/async_funcs/verify_block.rs new file mode 100644 index 0000000..4bf7b23 --- /dev/null +++ b/src/verifications/async_funcs/verify_block.rs @@ -0,0 +1,207 @@ +use crate::blocks::block::{Block, UnminedBlock}; +use crate::common::binary_conversions::hex_to_u64; +use crate::common::check_genesis::genesis_checkup; +use crate::common::types::Transaction; +use crate::encode; +use crate::miner::fairness::fairness_difficulty; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::unpack_block::unpack_header::load_block_header; +use crate::records::wallet_registry::resolve_pubkey_from_short_address; +use crate::sled::Db; +use crate::verifications::verification_service::VerificationService; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::Utc; + +impl Block { + pub async fn verify( + &self, + db: &Db, + verification_service: Arc, + ) -> Result, String> { + // block verification checks header validity first, then + // delegates transaction verification to the shared service + // get transactionsfrom block + let transactions = &self.transactions; + + // verifiy the number of transactions is not more than 1000001 + let total_transactions = transactions.len() as u32; + if total_transactions > 15_000_001 { + return Err(format!( + "Too many transactions in the block: {total_transactions}" + )); + } + + // get current timestamp + let current_timestamp = Utc::now().timestamp() as u32; + + // get header from block + let header = &self.vrf_block; + + // validate miner + let miner = &header.unmined_block.miner; + let miner_pubkey = resolve_pubkey_from_short_address(db, miner) + .map_err(|e| e.to_string())? + .ok_or_else(|| "This miner address is not registered".to_string())?; + let miner_pubkey_hex = encode(&miner_pubkey); + let block_number = get_height(db) + 1; + + if !NodeInfo::address_checkup(miner, block_number).await { + return Err("This address is not eligable to mine".to_string()); + } + + // get variables from the block + let timestamp = header.unmined_block.timestamp; + let previous_hash = header.unmined_block.previous_hash.clone(); + let difficulty = header.unmined_block.next_block_difficulty; + let vrf = header.vrf; + let proof = &header.proof; + + // hash the header to validate the difficulty + let unmined_header_hash = header.unmined_block.hash().await; + + // validate vrf number + if !Wallet::vrf_verify_with_public_key(vrf, &unmined_header_hash, &miner_pubkey_hex, proof) + .await + { + return Err("Invalid vrf.".to_string()); + } + + // hash the header to validate the difficulty + let header_hash = header.hash().await; + + // get u64 value + let hash = hex_to_u64(&header_hash).await?; + + // validate hash + Self::validate_hash_difficulty(db, hash).await?; + + // verify timestamp is not in the future + if timestamp > current_timestamp { + return Err("Timestamp in the block is in the future.".to_string()); + } + + // We need to check if the genesis block exists to do + // any previous block validations or we will get an error + // trying to validate the genesis block as the block 0 + // won't load. + if genesis_checkup().await { + Self::not_genesis_block_checkup(db, miner, timestamp, previous_hash, difficulty) + .await?; + } + + let results = Self::send_transactions_to_validate( + miner.to_string(), + self.transactions.clone(), + db.clone(), + verification_service, + ) + .await?; + + Ok(results) + } + + async fn not_genesis_block_checkup( + db: &Db, + miner: &str, + timestamp: u32, + previous_hash: String, + difficulty: u64, + ) -> Result<(), String> { + // non-genesis blocks must reference the current tip, + // satisfy fairness rules, and carry the correct next difficulty + // load the last block this is the current height + // as we are always validating a height higher than + // what is recorded. + let previous_height = get_height(db); + let previous_block = load_block_header(previous_height).await?; + + // check if miner is eligible based on + // fairness difficulty checker + if !fairness_difficulty(previous_height, miner).await { + return Err( + "You have not passed the fairness difficulty, You cannot mine this block." + .to_string(), + ); + } + + // get previous block hash + let calculated_previous_hash = previous_block.hash().await; + + // Validate recorded previous_hash is equal to + // the previous block hash + if calculated_previous_hash != previous_hash { + return Err("Incorrect previous_block_hash.".to_string()); + } + + // validate that the current timestamp is greater the previous + // block timestamp plus 2 seconds + let current_timestamp = Utc::now().timestamp() as u32; + if current_timestamp < previous_block.unmined_block.timestamp + 2 { + return Err("Mining to quickly".to_string()); + } + + // validate that the timestamp of the current block we are + // validating is greater than the previous block timestamp + // plus 2 seconds + if timestamp < previous_block.unmined_block.timestamp + 2 { + return Err("Mining to quickly".to_string()); + } + + // get next block difficulty + let difficulty_adjustment = UnminedBlock::adjust_difficulty( + timestamp, + db, + previous_block.unmined_block.next_block_difficulty, + ) + .await; + + // validate the listed next block difficulty matches what + // we calculated it should be + if difficulty_adjustment != difficulty { + let e = "Incorrect value for next block difficulty".to_string(); + return Err(e); + } + Ok(()) + } + + async fn send_transactions_to_validate( + miner: String, + transactions: Vec, + db: crate::sled::Db, + verification_service: Arc, + ) -> Result, String> { + // transaction verification is centralized in the shared + // verification service so block assembly stays lightweight + verification_service + .verify_block_transactions(miner, transactions, db) + .await + } + + async fn validate_hash_difficulty(db: &Db, hash: u64) -> Result<(), String> { + // compare the reduced header hash against the previous + // block's recorded target difficulty threshold + if !genesis_checkup().await { + let difficulty_target = 3000000000000000_u64; + if hash <= difficulty_target { + return Ok(()); + } else { + return Err( + "Block hash does not satisfy the genesis difficulty threshold.".to_string(), + ); + } + } + let current_block_number = get_height(db) + 1; + let previous_block_height = current_block_number - 1; + let previous_block = load_block_header(previous_block_height).await?; + let difficulty_target = previous_block.unmined_block.next_block_difficulty; + if hash >= difficulty_target { + Err(format!( + "Block hash does not satisfy the required difficulty threshold: hash_value={hash} required_below={difficulty_target}" + )) + } else { + Ok(()) + } + } +} diff --git a/src/verifications/async_funcs/verify_borrower.rs b/src/verifications/async_funcs/verify_borrower.rs new file mode 100644 index 0000000..10da547 --- /dev/null +++ b/src/verifications/async_funcs/verify_borrower.rs @@ -0,0 +1,143 @@ +use crate::blocks::loan_payment::ContractPaymentTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::common::types::BORROWER_FEE; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; +use crate::verifications::async_funcs::total_payments::get_total_payments; +use crate::wallets::structures::Wallet; +use crate::{decode, encode}; + +impl ContractPaymentTransaction { + pub async fn verify(&self, db: &Db) -> Result { + let hash = self.unsigned_contract_payment.hash().await; + let signature = &self.signature; + + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + if memcheck(signature, &hash).await { + return Ok(signature.to_string()); + } + + // Loan payments must come from a valid wallet address and carry a + // valid signature from the payer. + let address = &self.unsigned_contract_payment.address; + if !Wallet::short_address_validation(address) { + return Err("Lender Wallet Address is Invalid.").map_err(|s| s.to_string())?; + } + require_canonical_registered_short_address(db, address, "Lender Wallet Address")?; + let payer_pubkey = resolve_pubkey_from_short_address(db, address) + .map_err(|_| "Lender Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Lender Wallet Address is not registered.".to_string())?; + let payer_pubkey_hex = encode(&payer_pubkey); + if !Wallet::verify_transaction_with_public_key(&hash, signature, &payer_pubkey_hex).await { + return Err("Invalid signature the ContractPaymentTransaction.") + .map_err(|s| s.to_string())?; + } + + // Resolve the referenced loan contract from the saved chain so the + // payment can be validated against its repayment terms. + let contract_hash = &self.unsigned_contract_payment.contract_hash; + let contract_key = + decode(contract_hash).map_err(|e| format!("Invalid contract hash: {e}"))?; + let contract_bytes = request_transaction_by_txid(db, contract_key.clone()).await; + let RpcResponse::Binary(bytes) = contract_bytes; + + if bytes.is_empty() { + return Err("Invalid loan contract: empty transaction bytes".to_string()); + } + + let txtype = bytes[0]; + if txtype != 7 { + return Err( + "Invalid loan contract: referenced transaction is not a loan contract".to_string(), + ); + } + + let body = &bytes[1..]; + + let contract = LoanContractTransaction::from_bytes(txtype, body) + .await + .map_err(|e| format!("Invalid loan contract: {e}"))?; + + // Payments can only be made against an active contract whose + // collateral has not already been claimed or closed out. + let loantree = db.open_tree("loan").unwrap(); + match loantree.get(contract_key) { + Ok(Some(loan_value)) => { + if loan_value == "false" { + return Err( + "The collateral has already been claimed on this contract".to_string() + ); + } + } + Ok(None) => { + return Err("This contract is not valid".to_string()); + } + Err(_) => { + return Err("This contract is not valid".to_string()); + } + } + + // Use the saved payment history to prevent overpayment and to + // determine how much of the contract remains outstanding. + let total_paidback_amount = get_total_payments(db, contract_hash).await; + let total_to_be_paidback = contract + .unsigned_loan_contract + .payment_amount + .checked_mul(contract.unsigned_loan_contract.payment_number as u64) + .ok_or_else(|| "Loan repayment total overflowed.".to_string())?; + if total_paidback_amount >= total_to_be_paidback { + return Err("All payments have been made".to_string()); + } + + // Reject payments that would overshoot the remaining balance. + let payback_amount = self.unsigned_contract_payment.payback_amount; + let remaining_balance = total_to_be_paidback.saturating_sub(total_paidback_amount); + if payback_amount > remaining_balance { + return Err("Payment exceeds the remaining contract balance".to_string()); + } + + // Borrower payments must cover the fixed base-coin fee and an + // asset-denominated tip of at least 1% of the payment amount. + let txfee = self.unsigned_contract_payment.txfee; + if txfee < BORROWER_FEE { + return Err(format!( + "Loan payment transaction fee is below the minimum required fee of {BORROWER_FEE}." + )); + } + let tip = self.unsigned_contract_payment.tip; + let loaned_coin = contract.unsigned_loan_contract.loan_coin; + let payback_amount = self.unsigned_contract_payment.payback_amount; + let minimum_tip = payback_amount.div_ceil(100); + if tip < minimum_tip { + return Err("Loan payment tip must be at least 1% of the payment amount".to_string()); + } + + // Balance checks combine confirmed wallet state with pending + // mempool usage so in-flight loan payments cannot overspend. + let value = tip + .checked_add(payback_amount) + .ok_or_else(|| "Loan payment total overflowed.".to_string())?; + if !balance_checkup(db, value, txfee, loaned_coin, address).await { + return Err("Insuficient funds for this Transfer Transaction!".to_string()); + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, &hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + let sign = ""; + Ok(sign.to_string()) + } +} diff --git a/src/verifications/async_funcs/verify_burn.rs b/src/verifications/async_funcs/verify_burn.rs new file mode 100644 index 0000000..faa2be8 --- /dev/null +++ b/src/verifications/async_funcs/verify_burn.rs @@ -0,0 +1,110 @@ +use crate::blocks::burn::BurnTransaction; +use crate::common::nft_assets::nft_asset_name; +use crate::common::types::{BURN_FEE, COIN_LENGTH}; +use crate::encode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::verify_db::{ + db_bytes_verification, db_hex_verification, +}; +use crate::wallets::structures::Wallet; + +const NFT_UNIT: u64 = 100_000_000; + +impl BurnTransaction { + pub async fn verify(&self, db: &Db) -> Result { + let hash = self.unsigned_burn.hash().await; + + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + if memcheck(&self.signature, &hash).await { + return Ok(self.signature.clone()); + } + + // Burn transactions are anchored to the sender wallet and the + // signed burn payload before any asset-specific rules apply. + if !Wallet::short_address_validation(&self.unsigned_burn.address) { + return Err("Burn wallet address is invalid.".to_string()); + } + require_canonical_registered_short_address( + db, + &self.unsigned_burn.address, + "Burn wallet address", + )?; + let burner_pubkey = resolve_pubkey_from_short_address(db, &self.unsigned_burn.address) + .map_err(|_| "Burn wallet address is not registered.".to_string())? + .ok_or_else(|| "Burn wallet address is not registered.".to_string())?; + let burner_pubkey_hex = encode(&burner_pubkey); + if !Wallet::verify_transaction_with_public_key(&hash, &self.signature, &burner_pubkey_hex) + .await + { + return Err("Invalid signature for the burn transaction.".to_string()); + } + + // Burn identifiers use the same fixed padded asset-name format as + // transfers and token creation. + if self.unsigned_burn.coin.len() != COIN_LENGTH { + return Err("Coin length is invalid.".to_string()); + } + + // The base coin can never be destroyed through the burn path. + if self.unsigned_burn.coin.trim().to_lowercase() == BASECOIN.trim().to_lowercase() { + return Err("Base coin cannot be burned.".to_string()); + } + + let burn_asset = nft_asset_name(&self.unsigned_burn.coin, self.unsigned_burn.nft_series); + let nft_exists = db_bytes_verification(db, "nfts", &burn_asset).await; + let token_exists = self.unsigned_burn.nft_series == 0 + && db_bytes_verification(db, "tokens", &self.unsigned_burn.coin).await; + + // The burn path only accepts known live token or NFT assets. + if !nft_exists && !token_exists { + return Err("This asset does not exist.".to_string()); + } + + // Live NFTs use a full NFT unit balance, while fungible tokens can + // burn any positive integer amount. + if nft_exists { + if self.unsigned_burn.value != NFT_UNIT { + return Err(format!( + "NFT burns must destroy exactly {NFT_UNIT} units." + )); + } + } else if self.unsigned_burn.value == 0 { + return Err("Burn value must be greater than 0.".to_string()); + } + + if self.unsigned_burn.txfee < BURN_FEE { + return Err(format!( + "Burn transaction fee is below the minimum required fee of {BURN_FEE}." + )); + } + + // Balance checks combine confirmed wallet state with pending mempool + // usage so in-flight burns cannot overspend the same asset twice. + if !balance_checkup( + db, + self.unsigned_burn.value, + self.unsigned_burn.txfee, + burn_asset, + &self.unsigned_burn.address, + ) + .await + { + return Err("Insuficient funds for this burn transaction!".to_string()); + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + if !db_hex_verification(db, "txid", &hash).await { + return Err("This transaction already exists.".to_string()); + } + + Ok(String::new()) + } +} diff --git a/src/verifications/async_funcs/verify_collateral.rs b/src/verifications/async_funcs/verify_collateral.rs new file mode 100644 index 0000000..42f6984 --- /dev/null +++ b/src/verifications/async_funcs/verify_collateral.rs @@ -0,0 +1,214 @@ +use crate::blocks::collateral::CollateralClaimTransaction; +use crate::blocks::loans::LoanContractTransaction; +use crate::common::types::COLLATERAL_FEE; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; +use crate::verifications::async_funcs::total_payments::get_total_payments; +use crate::wallets::structures::Wallet; +use crate::{decode, encode}; +use crate::{DateTime, Datelike, TimeZone, Utc}; + +impl CollateralClaimTransaction { + pub async fn verify(&self, db: &Db) -> Result { + let hash = self.unsigned_collateral_claim.hash().await; + let signature = &self.signature; + + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + if memcheck(signature, &hash).await { + return Ok(signature.to_string()); + } + + // Collateral claims are valid only when the claimant signs the + // claim transaction with the same wallet in the payload. + if !Wallet::short_address_validation(&self.unsigned_collateral_claim.address) { + return Err("Collateral claimant wallet address is invalid.".to_string()); + } + require_canonical_registered_short_address( + db, + &self.unsigned_collateral_claim.address, + "Collateral claimant wallet address", + )?; + let claimant_pubkey = + resolve_pubkey_from_short_address(db, &self.unsigned_collateral_claim.address) + .map_err(|_| "Collateral claimant wallet address is not registered.".to_string())? + .ok_or_else(|| { + "Collateral claimant wallet address is not registered.".to_string() + })?; + let claimant_pubkey_hex = encode(&claimant_pubkey); + if !Wallet::verify_transaction_with_public_key(&hash, signature, &claimant_pubkey_hex).await + { + return Err("Invalid signature the RewardsTransaction.".to_string()); + } + + // Resolve the referenced loan contract from the saved chain so the + // collateral ownership and repayment state can be checked. + let contract_hash = &self.unsigned_collateral_claim.contract_hash; + let contract_key = + decode(contract_hash).map_err(|e| format!("Invalid contract hash: {e}"))?; + let contract_bytes = request_transaction_by_txid(db, contract_key.clone()).await; + let RpcResponse::Binary(bytes) = contract_bytes; + + if bytes.is_empty() { + return Err("Invalid loan contract: empty transaction bytes".to_string()); + } + + let txtype = bytes[0]; + if txtype != 7 { + return Err( + "Invalid loan contract: referenced transaction is not a loan contract".to_string(), + ); + } + + let body = &bytes[1..]; + + let contract = LoanContractTransaction::from_bytes(txtype, body) + .await + .map_err(|e| format!("Invalid loan contract: {e}"))?; + + // Claims can only be made against active contracts whose collateral + // has not already been marked as claimed or closed. + let loantree = db.open_tree("loan").unwrap(); + match loantree.get(contract_key) { + Ok(Some(loan_value)) => { + if loan_value == "false" { + return Err( + "The collateral has already been claimed on this contract".to_string() + ); + } + } + Ok(None) => { + return Err("This contract is not valid".to_string()); + } + Err(_) => { + return Err("This contract is not valid".to_string()); + } + } + + // Payment history determines whether the borrower can reclaim or + // whether the lender can claim for delinquency. + let total_paidback_amount = get_total_payments(db, contract_hash).await; + let total_to_be_paidback = contract + .unsigned_loan_contract + .payment_amount + .checked_mul(contract.unsigned_loan_contract.payment_number as u64) + .ok_or_else(|| "Loan repayment total overflowed.".to_string())?; + let address = self.unsigned_collateral_claim.address.clone(); + if address != contract.unsigned_loan_contract.borrower + && address != contract.unsigned_loan_contract.lender + { + return Err("Only the lender or the borrower can claim collateral".to_string()); + } + + // Collateral claims always pay the fixed base-coin fee, regardless + // of whether the claimant is the borrower or the lender. + let txfee = self.unsigned_collateral_claim.txfee; + if txfee < COLLATERAL_FEE { + return Err(format!( + "Collateral claim transaction fee is below the minimum required fee of {COLLATERAL_FEE}." + )); + } + + // Both possible claimant roles must be able to cover the base-coin fee. + if address == contract.unsigned_loan_contract.lender + && !balance_checkup(db, 0, txfee, BASECOIN.clone(), &address).await + { + return Err("Insuficient funds for this Transfer Transaction!".to_string()); + } + if address == contract.unsigned_loan_contract.borrower + && !balance_checkup(db, 0, txfee, BASECOIN.clone(), &address).await + { + return Err("Insuficient funds for this Transfer Transaction!".to_string()); + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, &hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Borrowers may reclaim only after the contract is fully paid off. + if address == contract.unsigned_loan_contract.borrower { + if total_paidback_amount < total_to_be_paidback { + return Err( + "The loan must be paid in full before reclaiming your collateral.".to_string(), + ); + } else { + let sign = ""; + return Ok(sign.to_string()); + } + } + + // Lender claims depend on how many scheduled periods have elapsed, + // excluding the initial funding period. + let periods_elapsed = Self::time_passed( + contract.unsigned_loan_contract.timestamp, + &contract.unsigned_loan_contract.payment_period, + ); + let payments_due = periods_elapsed.saturating_sub(1); + + // The lender must wait until the grace period has expired. + if payments_due <= contract.unsigned_loan_contract.grace_period as usize { + return Err("To grace period has not yet passed.".to_string()); + } + + // Compare scheduled payments due against actual payments to + // determine whether the contract is late enough to seize collateral. + let should_have_paid = (payments_due as u64) + .checked_mul(contract.unsigned_loan_contract.payment_amount) + .ok_or_else(|| "Loan delinquency calculation overflowed.".to_string())?; + if should_have_paid.saturating_sub(total_paidback_amount) + <= contract.unsigned_loan_contract.max_late_value + { + return Err("Borrow is within the max late fee constrants of the loan".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + let sign = ""; + Ok(sign.to_string()) + } + + fn time_passed(start_time: u32, period: &str) -> usize { + // Convert the start timestamp into elapsed payment periods using + // the contract's declared schedule unit. + let start_datetime = Utc + .timestamp_opt(start_time as i64, 0) + .single() + .expect("Invalid start timestamp"); + let current_timestamp = Utc::now().timestamp() as u32; + let current_datetime = Utc + .timestamp_opt(current_timestamp as i64, 0) + .single() + .expect("Invalid current timestamp"); + + let duration = current_datetime - start_datetime; + + match period { + "d" => duration.num_days() as usize, + "w" => (duration.num_days() / 7) as usize, + "m" => Self::calculate_months_between(start_datetime, current_datetime), + _ => 0, + } + } + + fn calculate_months_between(start: DateTime, end: DateTime) -> usize { + // Monthly contracts count whole calendar-month boundaries between + // the contract start date and the current date. + let start_date = start.date_naive(); + let end_date = end.date_naive(); + + let years_diff = end_date.year() - start_date.year(); + let months_diff = end_date.month() as i32 - start_date.month() as i32; + + (years_diff * 12 + months_diff) as usize + } +} diff --git a/src/verifications/async_funcs/verify_create_nft.rs b/src/verifications/async_funcs/verify_create_nft.rs new file mode 100644 index 0000000..5f91760 --- /dev/null +++ b/src/verifications/async_funcs/verify_create_nft.rs @@ -0,0 +1,144 @@ +use crate::blocks::nft::CreateNftTransaction; +use crate::common::nft_assets::nft_asset_name; +use crate::common::types::{COIN_LENGTH, CREATE_NFT_FEE}; +use crate::encode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::asset_rules::reserved_base_coins; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; +use crate::wallets::structures::Wallet; +use crate::Cid; + +impl CreateNftTransaction { + pub async fn verify(&self, db: &Db) -> Result { + let signature = &self.signature; + let hash = &self.unsigned_create_nft.hash().await; + + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + if memcheck(signature, hash).await { + return Ok(signature.to_string()); + } + + // NFT creation is anchored to a valid creator wallet and a valid + // signature over the unsigned NFT payload. + let creator = &self.unsigned_create_nft.creator; + if !Wallet::short_address_validation(creator) { + return Err("Creator Wallet Address is Invalid.").map_err(|s| s.to_string())?; + } + require_canonical_registered_short_address(db, creator, "Creator Wallet Address")?; + let creator_pubkey = resolve_pubkey_from_short_address(db, creator) + .map_err(|_| "Creator Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Creator Wallet Address is not registered.".to_string())?; + let creator_pubkey_hex = encode(&creator_pubkey); + if !Wallet::verify_transaction_with_public_key(hash, signature, &creator_pubkey_hex).await { + return Err("Invalid signature the RewardsTransaction.").map_err(|s| s.to_string())?; + } + + // NFT metadata is pinned by the fixed-length IPFS CID payload. + let item_ipfs = &self.unsigned_create_nft.item_ipfs; + if !Self::is_valid_ipfs_hash(item_ipfs).await { + return Err("This is not a valid ipfs hash.").map_err(|s| s.to_string())?; + } + + // Series controls whether the mint creates one standalone NFT or + // a numbered sequence of NFT items. + let series = self.unsigned_create_nft.series; + if series != 0 && series != 1 { + return Err("A series must be either 0 for no series or 1 for a series") + .map_err(|s| s.to_string())?; + } + + let count = self.unsigned_create_nft.count; + if series == 0 && count != 1 { + return Err("This is not a series so it must have a count of 1") + .map_err(|s| s.to_string())?; + } + if series == 1 && count < 2 { + return Err("A series must contain at least 2 items").map_err(|s| s.to_string())?; + } + + // NFT names use the same fixed padded asset-name length as tokens. + let nft_name_length = self.unsigned_create_nft.nft_name.len(); + if nft_name_length != COIN_LENGTH { + return Err( + "Nft Name length must be 15 characters. Consider padding with empty spaces", + ) + .map_err(|s| s.to_string())?; + } + + if reserved_base_coins().iter().any(|reserved| { + self.unsigned_create_nft.nft_name.trim().to_lowercase() + == reserved.trim().to_lowercase() + }) { + return Err("NFT name is reserved for the base coin.".to_string()); + } + + // Series NFTs reserve each numbered item name separately, while + // standalone NFTs reserve the base name directly. + let tree = db.open_tree("nfts").unwrap(); + if series == 1 { + for item_number in 1..=count { + let nft_save_name = nft_asset_name(&self.unsigned_create_nft.nft_name, item_number); + if tree.get(nft_save_name).unwrap().is_some() { + return Err("This Nft series item name is already used.") + .map_err(|s| s.to_string())?; + } + } + } else if tree + .get(&self.unsigned_create_nft.nft_name) + .unwrap() + .is_some() + { + return Err("This Nft name is already used.").map_err(|s| s.to_string())?; + } + + // NFT descriptions are stored as fixed-length padded fields. + let desc = &self.unsigned_create_nft.desc; + let desc_length = desc.len(); + if desc_length != 100 { + return Err( + "Description length must be 100 characters. Consider padding with empty spaces", + ) + .map_err(|s| s.to_string())?; + } + + // NFT creation is fee-only, so the creator just needs enough + // base coin to cover the fixed creation fee. + let txfee = self.unsigned_create_nft.txfee; + if txfee < CREATE_NFT_FEE { + return Err(format!( + "Create NFT transaction fee is below the minimum required fee of {CREATE_NFT_FEE}." + )); + } + if !balance_checkup(db, 0, txfee, BASECOIN.clone(), creator).await { + return Err("Insuficient funds for this Transfer Transaction!".to_string()); + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + let sign = ""; + Ok(sign.to_string()) + } + + async fn is_valid_ipfs_hash(hash: &str) -> bool { + // The stored IPFS field is fixed-width, so trim padding before + // validating it as a real CID. + if hash.len() != 100 { + return false; + } + let trimmed = hash.trim(); + Cid::try_from(trimmed).is_ok() + } +} diff --git a/src/verifications/async_funcs/verify_create_token.rs b/src/verifications/async_funcs/verify_create_token.rs new file mode 100644 index 0000000..0081a16 --- /dev/null +++ b/src/verifications/async_funcs/verify_create_token.rs @@ -0,0 +1,103 @@ +use crate::blocks::token::CreateTokenTransaction; +use crate::common::types::{COIN_LENGTH, CREATE_TOKEN_FEE}; +use crate::encode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::asset_rules::reserved_base_coins; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::verify_db::{ + db_bytes_verification, db_hex_verification, +}; +use crate::wallets::structures::Wallet; + +impl CreateTokenTransaction { + pub async fn verify(&self, db: &Db) -> Result { + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + let hash = self.unsigned_create_token.hash().await; + let signature = &self.signature; + if memcheck(signature, &hash).await { + return Ok(self.signature.clone()); + } + + // Token creation is anchored to the creator's wallet signature. + if !Wallet::short_address_validation(&self.unsigned_create_token.creator) { + return Err("Sender Wallet Address is Invalid.".to_string()); + } + require_canonical_registered_short_address( + db, + &self.unsigned_create_token.creator, + "Sender Wallet Address", + )?; + let creator_pubkey = + resolve_pubkey_from_short_address(db, &self.unsigned_create_token.creator) + .map_err(|_| "Sender Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Sender Wallet Address is not registered.".to_string())?; + let creator_pubkey_hex = encode(&creator_pubkey); + + // validate signature + if !Wallet::verify_transaction_with_public_key(&hash, &self.signature, &creator_pubkey_hex) + .await + { + return Err("Invalid signature the Transfer Transaction.".to_string()); + } + + // Tickers must be globally unique across the token registry. + let tree = "tokens"; + if db_bytes_verification(db, tree, &self.unsigned_create_token.ticker).await { + return Err("Ticker already exists exist.".to_string()); + } + + // Token tickers use the fixed padded coin-length format. + let ticker_length = self.unsigned_create_token.ticker.len(); + if ticker_length != COIN_LENGTH { + return Err("Ticker length must be 15 characters. Consider padding with empty spaces") + .map_err(|s| s.to_string())?; + } + + if reserved_base_coins().iter().any(|reserved| { + self.unsigned_create_token.ticker.trim().to_lowercase() + == reserved.trim().to_lowercase() + }) { + return Err("Ticker is reserved for the base coin.".to_string()); + } + + // Token hard-limit metadata is stored as a single byte so later + // issuance checks can enforce either capped or uncapped supply. + if self.unsigned_create_token.hard_limit > 1 { + return Err("hard_limit must be 0 or 1.".to_string()); + } + + // Only valid wallet addresses can mint new token definitions. + let creator = &self.unsigned_create_token.creator; + + // Token creation is fee-only, so the creator just needs enough + // base coin to cover the fixed creation fee. + let txfee = self.unsigned_create_token.txfee; + if txfee < CREATE_TOKEN_FEE { + return Err(format!( + "Create token transaction fee is below the minimum required fee of {CREATE_TOKEN_FEE}." + )); + } + + // Validate double spend + if !balance_checkup(db, 0, txfee, BASECOIN.clone(), creator).await { + return Err("Insuficient funds for this Transfer Transaction!".to_string()); + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, &hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + let sign = ""; + Ok(sign.to_string()) + } +} diff --git a/src/verifications/async_funcs/verify_genesis.rs b/src/verifications/async_funcs/verify_genesis.rs new file mode 100644 index 0000000..4ab580b --- /dev/null +++ b/src/verifications/async_funcs/verify_genesis.rs @@ -0,0 +1,23 @@ +use crate::blocks::genesis::GenesisTransaction; +use crate::common::check_genesis::genesis_checkup; +use crate::sled::Db; + +impl GenesisTransaction { + pub async fn verify(&self, _miner: String, db: &Db) -> Result { + // Genesis is pinned to a fixed message and only valid before the + // chain already has a committed genesis block. + let msg = &self.unsigned.message; + if msg != "We are all Satoshi. In 15 years nothing changed." { + return Err("Not a valid Genesis block").map_err(|s| s.to_string())?; + } + + let _ = db; + if genesis_checkup().await { + return Err("Genesis block already exists.".to_string()); + } + + // Genesis verification does not return an auxiliary cleanup marker. + let sign = ""; + Ok(sign.to_string()) + } +} diff --git a/src/verifications/async_funcs/verify_issue_token.rs b/src/verifications/async_funcs/verify_issue_token.rs new file mode 100644 index 0000000..2a11e21 --- /dev/null +++ b/src/verifications/async_funcs/verify_issue_token.rs @@ -0,0 +1,150 @@ +use crate::blocks::issue_token::IssueTokenTransaction; +use crate::blocks::token::CreateTokenTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::types::{COIN_LENGTH, ISSUE_TOKEN_FEE}; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::verifications::async_funcs::asset_rules::reserved_base_coins; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::verify_db::{ + db_bytes_verification, db_hex_verification, +}; +use crate::wallets::structures::Wallet; +use crate::{decode, encode}; + +async fn token_creator_from_origin(db: &Db, ticker: &str) -> Option { + // Token issuance authorization falls back to the origin transaction + // so creator identity is resolved from saved chain state. + let origin_tree = db.open_tree("token_origins").ok()?; + let txid_bytes = origin_tree.get(ticker.as_bytes()).ok().flatten()?; + let txid_hex = String::from_utf8(txid_bytes.to_vec()).ok()?; + let txid = decode(&txid_hex).ok()?; + let RpcResponse::Binary(tx_bytes) = request_transaction_by_txid(db, txid).await; + + if tx_bytes.is_empty() || tx_bytes[0] != 3 { + return None; + } + + let create_token = CreateTokenTransaction::from_bytes(tx_bytes[0], &tx_bytes[1..]) + .await + .ok()?; + Some(create_token.unsigned_create_token.creator) +} + +fn token_hard_limit(db: &Db, ticker: &str) -> Option { + // The stored token limit flag decides whether additional issuance is allowed. + let tree = db.open_tree("token_limits").ok()?; + tree.get(ticker.as_bytes()) + .ok() + .flatten() + .and_then(|bytes| bytes.first().copied()) +} + +impl IssueTokenTransaction { + pub async fn verify(&self, db: &Db) -> Result { + let hash = self.unsigned_issue_token.hash().await; + let signature = &self.signature; + + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + if memcheck(signature, &hash).await { + return Ok(self.signature.clone()); + } + + // Issuance is anchored to the creator's wallet signature. + if !Wallet::short_address_validation(&self.unsigned_issue_token.creator) { + return Err("Sender Wallet Address is Invalid.".to_string()); + } + require_canonical_registered_short_address( + db, + &self.unsigned_issue_token.creator, + "Sender Wallet Address", + )?; + let creator_pubkey = + resolve_pubkey_from_short_address(db, &self.unsigned_issue_token.creator) + .map_err(|_| "Sender Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Sender Wallet Address is not registered.".to_string())?; + let creator_pubkey_hex = encode(&creator_pubkey); + if !Wallet::verify_transaction_with_public_key(&hash, &self.signature, &creator_pubkey_hex) + .await + { + return Err("Invalid signature the Issue Token Transaction.".to_string()); + } + + // Only valid wallet addresses can issue additional token supply. + let creator = &self.unsigned_issue_token.creator; + + // Token tickers use the fixed padded coin-length format. + let ticker = &self.unsigned_issue_token.ticker; + if ticker.len() != COIN_LENGTH { + return Err( + "Ticker length must be 15 characters. Consider padding with empty spaces" + .to_string(), + ); + } + + // Check reserved keywords not used + if reserved_base_coins() + .iter() + .any(|reserved| ticker.trim().to_lowercase() == reserved.trim().to_lowercase()) + || ticker.trim().to_lowercase() == block_extension_and_paths().1.trim().to_lowercase() + { + return Err("Ticker is reserved for the base coin.".to_string()); + } + + // Issuance only applies to existing fungible tokens that still + // have a historical origin entry in chain state. + let tree = "token_origins"; + if !db_bytes_verification(db, tree, ticker).await { + return Err("Token does not exist.".to_string()); + } + + if self.unsigned_issue_token.number == 0 { + return Err("Issue amount must be greater than 0.".to_string()); + } + + // The original token creator is the only address allowed to issue more. + let Some(token_creator) = token_creator_from_origin(db, ticker).await else { + return Err("Token creator could not be resolved.".to_string()); + }; + if token_creator != *creator { + return Err("Only the original token creator can issue more tokens.".to_string()); + } + + // Hard-limited tokens are permanently capped after creation. + let Some(hard_limit) = token_hard_limit(db, ticker) else { + return Err("Token limit metadata not found.".to_string()); + }; + if hard_limit == 1 { + return Err("This token has a hard cap and cannot issue more tokens.".to_string()); + } + + // validate fee + let txfee = self.unsigned_issue_token.txfee; + if txfee < ISSUE_TOKEN_FEE { + return Err(format!( + "Issue token transaction fee is below the minimum required fee of {ISSUE_TOKEN_FEE}." + )); + } + + // validate no double spend + if !balance_checkup(db, 0, txfee, BASECOIN.clone(), creator).await { + return Err("Insuficient funds for this Issue Token Transaction!".to_string()); + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, &hash).await { + return Err("This transaction already exists.".to_string()); + } + + Ok(String::new()) + } +} diff --git a/src/verifications/async_funcs/verify_lender.rs b/src/verifications/async_funcs/verify_lender.rs new file mode 100644 index 0000000..5375499 --- /dev/null +++ b/src/verifications/async_funcs/verify_lender.rs @@ -0,0 +1,173 @@ +use crate::blocks::loans::LoanContractTransaction; +use crate::common::types::{COIN_LENGTH, LENDER_FEE}; +use crate::encode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::time_checks::is_within_30_days; +use crate::verifications::async_funcs::checks::verify_db::{ + db_bytes_verification, db_hex_verification, +}; +use crate::wallets::structures::Wallet; + +impl LoanContractTransaction { + pub async fn verify(&self, db: &Db) -> Result { + let calculated_hash = &self.unsigned_loan_contract.hash().await; + let signature2 = &self.signature2; + + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + if memcheck(signature2, calculated_hash).await { + return Ok(signature2.to_string()); + }; + + // Loan contracts require valid lender and borrower wallet addresses. + let lender = &self.unsigned_loan_contract.lender; + let borrower = &self.unsigned_loan_contract.borrower; + if !(Wallet::short_address_validation(lender) && Wallet::short_address_validation(borrower)) + { + return Err("Lender or Borrower Wallet Address is Invalid.") + .map_err(|s| s.to_string())?; + } + require_canonical_registered_short_address(db, lender, "Lender Wallet Address")?; + require_canonical_registered_short_address(db, borrower, "Borrower Wallet Address")?; + + // The stored transaction hash must match the locally recomputed + // hash before either participant signature is trusted. + let hash = &self.hash; + if calculated_hash != hash { + return Err("Invalid hash calculation for this TransferTransaction.") + .map_err(|s| s.to_string())?; + } + + // Both lender and borrower must sign the same contract payload. + let signature1 = &self.signature1; + let lender_pubkey = resolve_pubkey_from_short_address(db, lender) + .map_err(|_| "Lender Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Lender Wallet Address is not registered.".to_string())?; + let lender_pubkey_hex = encode(&lender_pubkey); + if !Wallet::verify_transaction_with_public_key(hash, signature1, &lender_pubkey_hex).await { + return Err("Invalid signature1 the RewardsTransaction.").map_err(|s| s.to_string())?; + } + let signature2 = &self.signature2; + let borrower_pubkey = resolve_pubkey_from_short_address(db, borrower) + .map_err(|_| "Borrower Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Borrower Wallet Address is not registered.".to_string())?; + let borrower_pubkey_hex = encode(&borrower_pubkey); + if !Wallet::verify_transaction_with_public_key(hash, signature2, &borrower_pubkey_hex).await + { + return Err("Invalid signature2 the RewardsTransaction.").map_err(|s| s.to_string())?; + } + + // Loan offers expire if they are broadcast too long after signing. + let timestamp = self.unsigned_loan_contract.timestamp; + if !is_within_30_days(timestamp).await { + return Err("Timestamp is to old. LoanContractTransactions must be broadcast within 30 days of signing.").map_err(|s| s.to_string())?; + } + + let loan_coin = self.unsigned_loan_contract.loan_coin.clone(); + let loan_amount = self.unsigned_loan_contract.loan_amount; + let txfee = self.unsigned_loan_contract.txfee; + let loan_coin_length = &loan_coin.to_string().len(); + let collateral = self.unsigned_loan_contract.collateral.clone(); + let collateral_length = collateral.to_string().len(); + let collateral_amount = self.unsigned_loan_contract.collateral_amount; + + // Creating the loan contract has a fixed minimum lender fee. + if txfee < LENDER_FEE { + return Err(format!( + "Loan contract transaction fee is below the minimum required fee of {LENDER_FEE}." + )); + } + + // Asset names use the fixed padded coin-length format. + if loan_coin_length != &COIN_LENGTH || collateral_length != COIN_LENGTH { + return Err( + "Coin/Token lengths must be 15 characters each. Consider padding with empty spaces", + ) + .map_err(|s| s.to_string())?; + } + + // Loaned assets must already exist as either the network base coin + // or a previously created token. + let tree = "tokens"; + if !(loan_coin.trim().to_lowercase() == BASECOIN.trim().to_lowercase() + || db_bytes_verification(db, tree, &loan_coin).await) + { + return Err("Coin/Token does not exist.".to_string()); + } + + // Collateral can be a token or an NFT asset, but it must already + // exist before the contract can be created. + let nft_tree = "nfts"; + if !(db_bytes_verification(db, tree, &collateral).await + || db_bytes_verification(db, nft_tree, &collateral).await) + { + return Err("Coin/Token/Nft does not exist.".to_string()); + } + + // The lender must be able to fund the loan and fee, while the + // borrower must already own the pledged collateral. + if !balance_checkup(db, loan_amount, txfee, loan_coin, lender).await { + return Err("Insuficient funds for this Lender Transaction!".to_string()); + } + if !balance_checkup(db, collateral_amount, 0, collateral, borrower).await { + return Err("Insuficient funds for this Transfer Transaction!".to_string()); + } + + // Payment schedules are limited to day, week, or month intervals. + let payment_period = &self.unsigned_loan_contract.payment_period; + if payment_period != "d" && payment_period != "w" && payment_period != "m" { + return Err( + "The payment period must be either 'd' for days, 'w' for weeks, or 'm' for months.", + ) + .map_err(|s| s.to_string())?; + } + + // Repayment structure must make sense: no single payment can exceed + // the loan, and the full schedule must cover at least the principal. + let payment_amount = self.unsigned_loan_contract.payment_amount; + if payment_amount > loan_amount { + return Err("The payment amount must be less than the loan amount.") + .map_err(|s| s.to_string())?; + } + + let payment_number_u64 = self.unsigned_loan_contract.payment_number as u64; + let scheduled_total = payment_amount + .checked_mul(payment_number_u64) + .ok_or_else(|| "Loan repayment schedule overflowed.".to_string())?; + if scheduled_total < loan_amount { + return Err("The total of all scheduled payments must be at least the loan amount.") + .map_err(|s| s.to_string())?; + } + + // The late-value threshold and grace-period settings are bounded + // so they cannot exceed the contract's own repayment schedule. + let max_late_value = self.unsigned_loan_contract.max_late_value; + if max_late_value > loan_amount { + return Err("The max late value cannot exceed the loan amount.") + .map_err(|s| s.to_string())?; + } + let payment_number = &self.unsigned_loan_contract.payment_number; + let grace_period = &self.unsigned_loan_contract.grace_period; + if grace_period > payment_number { + return Err("The grace period cannot exceed the number of payments.") + .map_err(|s| s.to_string())?; + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + let sign = ""; + Ok(sign.to_string()) + } +} diff --git a/src/verifications/async_funcs/verify_marketing.rs b/src/verifications/async_funcs/verify_marketing.rs new file mode 100644 index 0000000..6c9bd1d --- /dev/null +++ b/src/verifications/async_funcs/verify_marketing.rs @@ -0,0 +1,83 @@ +use crate::blocks::marketing::MarketingTransaction; +use crate::common::types::MARKETING_FEE; +use crate::encode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; +use crate::wallets::structures::Wallet; + +impl MarketingTransaction { + pub async fn verify(&self, db: &Db) -> Result { + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + let hash = &self.unsigned_marketing.hash().await; + let signature = &self.signature; + if memcheck(signature, hash).await { + return Ok(signature.to_string()); + } + + // Marketing transactions must come from a valid advertiser wallet + // and carry a valid signature from that same wallet. + let advertiser = &self.unsigned_marketing.advertiser; + if !Wallet::short_address_validation(advertiser) { + return Err("Advertiser Wallet Address is Invalid.").map_err(|s| s.to_string())?; + } + require_canonical_registered_short_address(db, advertiser, "Advertiser Wallet Address")?; + let advertiser_pubkey = resolve_pubkey_from_short_address(db, advertiser) + .map_err(|_| "Advertiser Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Advertiser Wallet Address is not registered.".to_string())?; + let advertiser_pubkey_hex = encode(&advertiser_pubkey); + if !Wallet::verify_transaction_with_public_key(hash, signature, &advertiser_pubkey_hex) + .await + { + return Err("Invalid signature the RewardsTransaction.").map_err(|s| s.to_string())?; + } + + // Marketing transactions are fee-only, so the advertiser just + // needs enough base coin to cover the fixed marketing fee. + let txfee = self.unsigned_marketing.txfee; + if txfee < MARKETING_FEE { + return Err(format!( + "Marketing transaction fee is below the minimum required fee of {MARKETING_FEE}." + )); + } + if !balance_checkup(db, 0, txfee, BASECOIN.clone(), advertiser).await { + return Err("Insuficient funds for this Marketing Transaction!".to_string()); + } + + // Ad payloads are fixed-format records with a constrained ad type + // and fixed-length keyword and displayed-link fields. + let ad = &self.unsigned_marketing.ad_type; + if ad != "social" && ad != "banner" && ad != "text" { + return Err("Wrong ad type. An ad must be type social, banner or text. If text you must add 2 spaces at the end.").map_err(|s| s.to_string())?; + } + + // check keywork length + let keyword = self.unsigned_marketing.keyword.len(); + if keyword != 40 { + return Err("A keyword length must be 40 characters. Pad with spaces to make it fit this size if need be.").map_err(|s| s.to_string())?; + } + + // check link length + let displayed = self.unsigned_marketing.displayed.len(); + if displayed != 100 { + return Err("Displayed should be a link of exactly 100 characters. If it is more, please reduce it. If it is less please add empty spaces as padding.").map_err(|s| s.to_string())?; + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + let sign = ""; + Ok(sign.to_string()) + } +} diff --git a/src/verifications/async_funcs/verify_rewards.rs b/src/verifications/async_funcs/verify_rewards.rs new file mode 100644 index 0000000..49084a2 --- /dev/null +++ b/src/verifications/async_funcs/verify_rewards.rs @@ -0,0 +1,53 @@ +use crate::blocks::rewards::RewardsTransaction; +use crate::miner::block_rewards::calculate_block_reward; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::wallet_registry::is_registered_short_address; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; + +impl RewardsTransaction { + pub async fn verify(&self, miner: String, db: &Db) -> Result { + // Rewards are tied to the next block height and only begin after + // a miner has accumulated the minimum mined-count history. + let previous_height = get_height(db); + let value = self.unsigned.value; + + // Rewards can only be credited to a wallet that has already been + // registered in the short-address registry. + if !is_registered_short_address(db, &miner) + .map_err(|_| "Miner wallet registration could not be verified.".to_string())? + { + return Err("Miner wallet address is not registered.".to_string()); + } + + // New miners receive zero reward until their mined-count history + // reaches the maturity threshold. + let reward_value = if NodeInfo::get_mined_count(&miner).await < 100 { + 0_u64 + } else { + calculate_block_reward(previous_height + 1).await + }; + + // The unsigned reward value must exactly match the deterministic + // reward calculation for the block being created. + if value != reward_value { + return Err("Invalid reward for the Rewards Transaction.".to_string()); + } + + // Recompute the reward txid after the value check so duplicate + // detection uses the same serialized reward payload. + let hash = self.unsigned.hash().await; + + // Reject duplicate reward transactions by ensuring the txid has + // not already been committed to the chain database. + let tree = "txid"; + if !db_hex_verification(db, tree, &hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + let sign = ""; + Ok(sign.to_string()) + } +} diff --git a/src/verifications/async_funcs/verify_swap.rs b/src/verifications/async_funcs/verify_swap.rs new file mode 100644 index 0000000..50a2d37 --- /dev/null +++ b/src/verifications/async_funcs/verify_swap.rs @@ -0,0 +1,200 @@ +use crate::blocks::swap::SwapTransaction; +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::common::nft_assets::nft_asset_name; +use crate::common::types::{COIN_LENGTH, SWAP_FEE}; +use crate::encode; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::time_checks::is_within_30_days; +use crate::verifications::async_funcs::checks::verify_db::{ + db_bytes_verification, db_hex_verification, +}; +use crate::wallets::structures::Wallet; +use crate::Utc; + +impl SwapTransaction { + pub async fn verify(&self, db: &Db) -> Result { + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + let hash = self.unsigned_swap.hash().await; + if memcheck(&self.signature2, &hash).await { + return Ok(self.signature2.clone()); + } + + // Both swap participants must provide valid wallet addresses and + // matching signatures over the shared unsigned swap payload. + if !(Wallet::short_address_validation(&self.unsigned_swap.sender1) + && Wallet::short_address_validation(&self.unsigned_swap.sender2)) + { + return Err("Sender1 or Sender2 Wallet Address is Invalid.".to_string()); + } + require_canonical_registered_short_address( + db, + &self.unsigned_swap.sender1, + "Sender1 Wallet Address", + )?; + require_canonical_registered_short_address( + db, + &self.unsigned_swap.sender2, + "Sender2 Wallet Address", + )?; + + // Sender1 must sign the same unsigned swap payload that sender2 + // signs, proving agreement to the offered side of the swap. + let sender1_pubkey = resolve_pubkey_from_short_address(db, &self.unsigned_swap.sender1) + .map_err(|_| "Sender1 Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Sender1 Wallet Address is not registered.".to_string())?; + let sender1_pubkey_hex = encode(&sender1_pubkey); + if !Wallet::verify_transaction_with_public_key(&hash, &self.signature1, &sender1_pubkey_hex) + .await + { + return Err("Invalid signature1 the RewardsTransaction.".to_string()); + } + + // Sender2's registered public key verifies the counterparty + // signature on the same swap hash. + let sender2_pubkey = resolve_pubkey_from_short_address(db, &self.unsigned_swap.sender2) + .map_err(|_| "Sender2 Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Sender2 Wallet Address is not registered.".to_string())?; + let sender2_pubkey_hex = encode(&sender2_pubkey); + if !Wallet::verify_transaction_with_public_key(&hash, &self.signature2, &sender2_pubkey_hex) + .await + { + return Err("Invalid signature2 the RewardsTransaction.".to_string()); + } + + // Asset names use the fixed padded coin-length format. + if self.unsigned_swap.ticker1.len() != COIN_LENGTH + || self.unsigned_swap.ticker2.len() != COIN_LENGTH + { + return Err( + "Ticker lengths are invalid. Consider padding with empty spaces".to_string(), + ); + } + + // Build concrete asset names for NFT series swaps before checking + // balances and registry existence. + let asset1 = nft_asset_name(&self.unsigned_swap.ticker1, self.unsigned_swap.nft_series1); + let asset2 = nft_asset_name(&self.unsigned_swap.ticker2, self.unsigned_swap.nft_series2); + + // The base coin name comes from the active network settings so + // mainnet and testnet validate against their own ticker. + let network_base_coin = block_extension_and_paths().1.trim().to_lowercase(); + + // Numbered NFT swaps are single-item transfers, while non-series + // assets must already exist as a token, NFT, or base coin. + if self.unsigned_swap.nft_series1 > 0 { + if self.unsigned_swap.value1 != 1 { + return Err("Series NFTs must swap exactly 1 item.".to_string()); + } + if !db_bytes_verification(db, "nfts", &asset1).await { + return Err("Ticker1 NFT item does not exist.".to_string()); + } + } else if !(self.unsigned_swap.ticker1.trim().to_lowercase() == network_base_coin + || db_bytes_verification(db, "tokens", &self.unsigned_swap.ticker1).await + || db_bytes_verification(db, "nfts", &self.unsigned_swap.ticker1).await) + { + return Err("Ticker1 does not exist.".to_string()); + } + + if self.unsigned_swap.nft_series2 > 0 { + if self.unsigned_swap.value2 != 1 { + return Err("Series NFTs must swap exactly 1 item.".to_string()); + } + if !db_bytes_verification(db, "nfts", &asset2).await { + return Err("Ticker2 NFT item does not exist.".to_string()); + } + } else if !(self.unsigned_swap.ticker2.trim().to_lowercase() == network_base_coin + || db_bytes_verification(db, "tokens", &self.unsigned_swap.ticker2).await + || db_bytes_verification(db, "nfts", &self.unsigned_swap.ticker2).await) + { + return Err("Ticker2 does not exist.".to_string()); + } + + // Each signer pays the fixed swap fee for their side of the trade. + if self.unsigned_swap.txfee1 < SWAP_FEE { + return Err(format!( + "Swap sender1 transaction fee is below the minimum required fee of {SWAP_FEE}." + )); + } + + if self.unsigned_swap.txfee2 < SWAP_FEE { + return Err(format!( + "Swap sender2 transaction fee is below the minimum required fee of {SWAP_FEE}." + )); + } + + // Each side must be able to cover the offered amount plus any + // asset-denominated tip and the base-coin transaction fee. + let full_value1 = self.unsigned_swap.value1 + self.unsigned_swap.tip1; + if !balance_checkup( + db, + full_value1, + self.unsigned_swap.txfee1, + asset1.clone(), + &self.unsigned_swap.sender1, + ) + .await + { + return Err("Insuficient funds for this Swap Transaction!".to_string()); + } + + let full_value2 = self.unsigned_swap.value2 + self.unsigned_swap.tip2; + if !balance_checkup( + db, + full_value2, + self.unsigned_swap.txfee2, + asset2, + &self.unsigned_swap.sender2, + ) + .await + { + return Err("Insuficient funds for this Swap Transaction!".to_string()); + } + + // Swap offers are bounded both by signature age and by the + // explicit offer-expiration window carried in the transaction. + if !is_within_30_days(self.unsigned_swap.timestamp).await { + return Err( + "Timestamp is to old. Transactions must be broadcast within 30 days of signing." + .to_string(), + ); + } + + let now = Utc::now().timestamp() as u32; + let offer_expiration = self.unsigned_swap.offer_expiration; + let timestamp = self.unsigned_swap.timestamp; + + if offer_expiration < timestamp { + return Err( + "Offer expiration cannot be earlier than the transaction timestamp.".to_string(), + ); + } + + if offer_expiration > timestamp.saturating_add(30 * 24 * 60 * 60) { + return Err( + "Offer expiration cannot be more than 30 days after the transaction timestamp." + .to_string(), + ); + } + + if now > offer_expiration { + return Err("This swap offer has expired.".to_string()); + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, &hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + let sign = ""; + Ok(sign.to_string()) + } +} diff --git a/src/verifications/async_funcs/verify_transfer.rs b/src/verifications/async_funcs/verify_transfer.rs new file mode 100644 index 0000000..c5c4ad5 --- /dev/null +++ b/src/verifications/async_funcs/verify_transfer.rs @@ -0,0 +1,152 @@ +use crate::blocks::transfer::TransferTransaction; +use crate::common::nft_assets::nft_asset_name; +use crate::common::types::{minimum_transfer_fee, COIN_LENGTH}; +use crate::encode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + is_registered_short_address, require_canonical_registered_short_address, + resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::verify_db::{ + db_bytes_verification, db_hex_verification, +}; +use crate::wallets::structures::Wallet; + +fn is_zero_burn_address(address: &str) -> bool { + // Transfers to the canonical all-zero wallet forms are treated as + // invalid so asset destruction must go through the burn tx type. + if let Some(suffix) = address.strip_prefix("CLC") { + return suffix.len() == Wallet::ADDRESS_HEX_LENGTH + && suffix.chars().all(|character| character == '0'); + } + + if let Some(suffix) = address.strip_prefix("CLTC") { + return suffix.len() == Wallet::ADDRESS_HEX_LENGTH + && suffix.chars().all(|character| character == '0'); + } + + false +} + +impl TransferTransaction { + pub async fn verify(&self, db: &Db) -> Result { + let hash = self.unsigned_transfer.hash().await; + // Transactions that are already sitting in the mempool can skip + // the deeper verification work and return their stored signature. + if memcheck(&self.signature, &hash).await { + return Ok(self.signature.clone()); + } + + // Validate both wallet addresses and the signed transfer payload + // before checking balances and asset-specific rules. + if !(Wallet::short_address_validation(&self.unsigned_transfer.sender) + && Wallet::short_address_validation(&self.unsigned_transfer.receiver)) + { + return Err("Sender or Receiver Wallet Address is Invalid.".to_string()); + } + require_canonical_registered_short_address( + db, + &self.unsigned_transfer.sender, + "Sender Wallet Address", + )?; + + // The receiver must also use its canonical registered short address + // so transfers are stored against one stable address form. + require_canonical_registered_short_address( + db, + &self.unsigned_transfer.receiver, + "Receiver Wallet Address", + )?; + + // Burning assets requires the burn transaction path, not a normal + // transfer to an all-zero wallet-looking address. + if is_zero_burn_address(&self.unsigned_transfer.receiver) { + return Err("Transfers to the all-zero burn address are not allowed.".to_string()); + } + + // The receiver must already be registered so balance ownership can + // be resolved by the rest of the chain. + if !is_registered_short_address(db, &self.unsigned_transfer.receiver).map_err(|_| { + "Receiver Wallet Address registration could not be verified.".to_string() + })? { + return Err("Receiver Wallet Address is not registered.".to_string()); + } + + // The sender's registered public key is the authority for the + // transfer signature. + let sender_pubkey = resolve_pubkey_from_short_address(db, &self.unsigned_transfer.sender) + .map_err(|_| "Sender Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Sender Wallet Address is not registered.".to_string())?; + let sender_pubkey_hex = encode(&sender_pubkey); + if !Wallet::verify_transaction_with_public_key(&hash, &self.signature, &sender_pubkey_hex) + .await + { + return Err("Invalid signature the Transfer Transaction.".to_string()); + } + + // Convert NFT series transfers into their concrete asset key before + // balance checks so fungible coins and NFTs share one path below. + let transfer_asset = nft_asset_name( + &self.unsigned_transfer.coin, + self.unsigned_transfer.nft_series, + ); + + // Base-coin transfers use percentage fees; non-base assets use the + // fixed non-base minimum fee. + let is_base_coin = + self.unsigned_transfer.coin.trim().to_lowercase() == BASECOIN.trim().to_lowercase(); + + // Transfer fees depend on amount and whether the asset being sent + // is the network base coin or another asset class. + let minimum_fee = minimum_transfer_fee(self.unsigned_transfer.value, is_base_coin); + if self.unsigned_transfer.txfee < minimum_fee { + return Err(format!( + "Transfer transaction fee is below the minimum required fee of {minimum_fee}." + )); + } + + // Series NFTs are handled as numbered single-item assets and must + // already exist in the NFT registry before they can be transferred. + if self.unsigned_transfer.nft_series > 0 { + if self.unsigned_transfer.value != 1 { + return Err("Series NFTs must transfer exactly 1 item.".to_string()); + } + + if !db_bytes_verification(db, "nfts", &transfer_asset).await { + return Err("This NFT item does not exist.".to_string()); + } + } + + // Balance checks combine confirmed wallet state with pending + // mempool usage so in-flight transfers cannot overspend. + if !balance_checkup( + db, + self.unsigned_transfer.value, + self.unsigned_transfer.txfee, + transfer_asset, + &self.unsigned_transfer.sender, + ) + .await + { + return Err("Insuficient funds for this Transfer Transaction!".to_string()); + } + + // Asset identifiers use the fixed padded coin-length format. + if self.unsigned_transfer.coin.len() != COIN_LENGTH { + return Err("Coin length is invalid.".to_string()); + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, &hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + Ok(String::new()) + } +} diff --git a/src/verifications/async_funcs/verify_vanity.rs b/src/verifications/async_funcs/verify_vanity.rs new file mode 100644 index 0000000..5a11f0e --- /dev/null +++ b/src/verifications/async_funcs/verify_vanity.rs @@ -0,0 +1,102 @@ +use crate::blocks::vanity::VanityAddressTransaction; +use crate::common::types::VANITY_ADDRESS_FEE; +use crate::encode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + resolve_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::mempool_check::memcheck; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; +use crate::wallets::structures::Wallet; + +impl VanityAddressTransaction { + pub async fn verify(&self, db: &Db) -> Result { + // Vanity address transactions are signed over the unsigned + // address-registration payload, so recompute that hash first. + let hash = self.unsigned_vanity_address.hash().await; + + // Transactions already present in the mempool can short-circuit + // the deeper verification path and reuse their stored signature. + if memcheck(&self.signature, &hash).await { + return Ok(self.signature.clone()); + } + + // The paying wallet must already be a valid short address for the + // current network before registry ownership is checked. + let address = &self.unsigned_vanity_address.address; + if !Wallet::short_address_validation(address) { + return Err("Wallet Address is Invalid.".to_string()); + } + + // The requested vanity address uses the same short-address wire + // format, including payload size and network suffix. + let vanity_address = &self.unsigned_vanity_address.vanity_address; + if !Wallet::short_address_validation(vanity_address) { + return Err("Vanity Address is Invalid.".to_string()); + } + + // Vanity claims must be made from the wallet's canonical registered + // short address so aliases cannot sign on behalf of the same key. + let canonical_address = resolve_canonical_registered_short_address(db, address) + .map_err(|_| "Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Wallet Address is not registered.".to_string())?; + if &canonical_address != address { + return Err( + "Wallet Address must use its canonical registered short address.".to_string(), + ); + } + + // Normalize the vanity address before accepting it so the chain + // stores only the lowercase padded form that every node derives. + let normalized_vanity = Wallet::normalize_to_short_address(vanity_address) + .ok_or_else(|| "Vanity Address could not be normalized.".to_string())?; + if &normalized_vanity != vanity_address { + return Err( + "Vanity Address must use its normalized lowercase padded form.".to_string(), + ); + } + + // Resolve the registered public key from the short address and use + // it to verify that the wallet owner signed this vanity claim. + let sender_pubkey = resolve_pubkey_from_short_address(db, address) + .map_err(|_| "Wallet Address is not registered.".to_string())? + .ok_or_else(|| "Wallet Address is not registered.".to_string())?; + let sender_pubkey_hex = encode(&sender_pubkey); + if !Wallet::verify_transaction_with_public_key(&hash, &self.signature, &sender_pubkey_hex) + .await + { + return Err("Invalid signature for the Vanity Address Transaction.".to_string()); + } + + // Vanity registration has a fixed minimum fee paid in the base coin. + let txfee = self.unsigned_vanity_address.txfee; + if txfee < VANITY_ADDRESS_FEE { + return Err(format!( + "Vanity address transaction fee is below the minimum required fee of {VANITY_ADDRESS_FEE}." + )); + } + + // The payer must be able to cover the vanity registration fee. + if !balance_checkup(db, 0, txfee, BASECOIN.clone(), address).await { + return Err("Insuficient funds for this Vanity Address Transaction!".to_string()); + } + + // A vanity alias must point to a different short address than the + // wallet's canonical address, otherwise it registers no useful alias. + if vanity_address == address { + return Err("Vanity Address cannot be the same as the wallet address.".to_string()); + } + + // Saved-chain duplicates are rejected by txid even if the mempool + // did not already contain the transaction. + let tree = "txid"; + if !db_hex_verification(db, tree, &hash).await { + return Err("This transaction already exists.".to_string()); + } + + // Verification returns no auxiliary cleanup marker for this transaction type. + Ok(String::new()) + } +} diff --git a/src/verifications/mod.rs b/src/verifications/mod.rs new file mode 100644 index 0000000..e07a6fb --- /dev/null +++ b/src/verifications/mod.rs @@ -0,0 +1,5 @@ +// The verification module groups the sync and async transaction and block verification paths. +pub mod async_funcs; +pub mod sync_funcs; +pub mod verification_service; +pub mod verification_types; diff --git a/src/verifications/sync_funcs/mod.rs b/src/verifications/sync_funcs/mod.rs new file mode 100644 index 0000000..2b2db74 --- /dev/null +++ b/src/verifications/sync_funcs/mod.rs @@ -0,0 +1,2 @@ +// The sync verification helpers manage the outer transaction-verification loop state. +pub mod transaction_verify_loop; diff --git a/src/verifications/sync_funcs/transaction_verify_loop.rs b/src/verifications/sync_funcs/transaction_verify_loop.rs new file mode 100644 index 0000000..26f8926 --- /dev/null +++ b/src/verifications/sync_funcs/transaction_verify_loop.rs @@ -0,0 +1,8 @@ +use crate::lazy_static; +use std::sync::atomic::AtomicU32; + +// this counter ensures no one adds more +// than 1 reward transaction in a block +lazy_static! { + pub static ref COUNTER: AtomicU32 = AtomicU32::new(0); +} diff --git a/src/verifications/verification_service.rs b/src/verifications/verification_service.rs new file mode 100644 index 0000000..14eb514 --- /dev/null +++ b/src/verifications/verification_service.rs @@ -0,0 +1,193 @@ +use crate::common::types::Transaction; +use crate::rayon::ThreadPool; +use crate::rayon::ThreadPoolBuilder; +use crate::verifications::async_funcs::transactions::verify_transactions; +use crate::verifications::async_funcs::checks::block_balance::BlockBalanceTracker; +use crate::verifications::sync_funcs::transaction_verify_loop::COUNTER; +use crate::verifications::verification_types::{ + VerificationChunkJob, VerificationChunkResult, VerificationJob, +}; +use crate::Arc; +use crate::AtomicBool; +use crate::AtomicOrdering; +use crate::Builder; +use crate::IntoParallelIterator; +use crate::OnceLock; +use crate::ParallelIterator; +use crate::Runtime; +use crate::{mpsc, oneshot, Mutex}; + +static VERIFICATION_SERVICE: OnceLock = OnceLock::new(); + +// VerificationRequest wraps one verification job together with the reply channel that +// returns the combined results to the caller. +struct VerificationRequest { + job: VerificationJob, + reply_tx: oneshot::Sender, String>>, +} + +// VerificationService owns the async queue that feeds verification work into the shared worker pool. +#[derive(Clone)] +pub struct VerificationService { + job_tx: mpsc::Sender, +} + +impl VerificationService { + pub fn start() -> Self { + // The verification service keeps a dedicated rayon pool and Tokio runtime so + // transaction verification can scale without blocking the rest of the node. + let (job_tx, mut job_rx) = mpsc::channel::(1); + let worker_count = std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(4); + + let rayon_pool = Arc::new( + ThreadPoolBuilder::new() + .num_threads(worker_count) + .build() + .expect("failed to create verification rayon pool"), + ); + + let verify_runtime = Arc::new( + Builder::new_multi_thread() + .worker_threads(worker_count) + .enable_all() + .build() + .expect("failed to create verification runtime"), + ); + + tokio::spawn({ + let rayon_pool = rayon_pool.clone(); + let verify_runtime = verify_runtime.clone(); + async move { + while let Some(request) = job_rx.recv().await { + let result = + Self::run_job(rayon_pool.clone(), verify_runtime.clone(), request.job) + .await; + let _ = request.reply_tx.send(result); + } + } + }); + + Self { job_tx } + } + + pub async fn verify_block_transactions( + &self, + miner: String, + transactions: Vec, + db: crate::sled::Db, + ) -> Result, String> { + let (reply_tx, reply_rx) = oneshot::channel(); + let request = VerificationRequest { + job: VerificationJob { + miner, + transactions, + db, + }, + reply_tx, + }; + + self.job_tx + .send(request) + .await + .map_err(|_| "Verification service unavailable".to_string())?; + + reply_rx + .await + .map_err(|_| "Verification service response dropped".to_string())? + } + + async fn run_job( + rayon_pool: Arc, + verify_runtime: Arc, + job: VerificationJob, + ) -> Result, String> { + // Transaction verification is split into chunks and processed in parallel, then + // merged back into one result list for the caller. + let chunk_size = 1000usize; + let stop_flag = Arc::new(AtomicBool::new(false)); + let balance_tracker = Arc::new(Mutex::new(BlockBalanceTracker::new())); + + COUNTER.store(0, AtomicOrdering::SeqCst); + + let chunk_jobs = Self::split_transactions(job, chunk_size); + + let chunk_results: Vec = rayon_pool.install(|| { + chunk_jobs + .into_par_iter() + .map(|chunk_job| { + let stop_flag = stop_flag.clone(); + let balance_tracker = balance_tracker.clone(); + verify_runtime.block_on(async move { + match verify_transactions( + chunk_job.miner, + chunk_job.transactions, + &chunk_job.db, + stop_flag, + balance_tracker, + ) + .await + { + Ok(results) => VerificationChunkResult { + results, + had_error: false, + error: None, + }, + Err(error) => VerificationChunkResult { + results: Vec::new(), + had_error: true, + error: Some(error), + }, + } + }) + }) + .collect() + }); + + if let Some(error) = chunk_results + .iter() + .find(|result| result.had_error) + .and_then(|result| result.error.clone()) + { + return Err(error); + } + + let mut full_results = Vec::new(); + for chunk_result in chunk_results { + full_results.extend(chunk_result.results); + } + + Ok(full_results) + } + + fn split_transactions(job: VerificationJob, chunk_size: usize) -> Vec { + let mut chunk_jobs = Vec::new(); + let VerificationJob { + miner, + transactions, + db, + } = job; + + for chunk in transactions.chunks(chunk_size) { + chunk_jobs.push(VerificationChunkJob { + miner: miner.clone(), + transactions: chunk.to_vec(), + db: db.clone(), + }); + } + + chunk_jobs + } +} + +pub fn initialize_global_verification_service() -> VerificationService { + // The global service is initialized once and then cloned anywhere the node needs it. + let service = VerificationService::start(); + let _ = VERIFICATION_SERVICE.set(service.clone()); + service +} + +pub fn global_verification_service() -> Option { + VERIFICATION_SERVICE.get().cloned() +} diff --git a/src/verifications/verification_types.rs b/src/verifications/verification_types.rs new file mode 100644 index 0000000..fd3e6ab --- /dev/null +++ b/src/verifications/verification_types.rs @@ -0,0 +1,23 @@ +use crate::common::types::Transaction; +use crate::sled::Db; + +// VerificationJob represents one full transaction batch submitted to the verification service. +pub struct VerificationJob { + pub miner: String, + pub transactions: Vec, + pub db: Db, +} + +// VerificationChunkJob is the per-worker slice created from a larger verification job. +pub struct VerificationChunkJob { + pub miner: String, + pub transactions: Vec, + pub db: Db, +} + +// VerificationChunkResult carries one worker's results plus a flag for chunk-level failure. +pub struct VerificationChunkResult { + pub results: Vec, + pub had_error: bool, + pub error: Option, +} diff --git a/src/wallets/create_keys.rs b/src/wallets/create_keys.rs new file mode 100644 index 0000000..bbd58b4 --- /dev/null +++ b/src/wallets/create_keys.rs @@ -0,0 +1,58 @@ +use crate::wallets::structures::Wallet; +use crate::FnDsaKeyPair; +use crate::{decode, encode}; + +impl Wallet { + pub fn generate_keypair(network_byte: u8) -> (Vec, String) { + // Generate a new FN-DSA key pair using the wallet security parameter. + let keypair = + FnDsaKeyPair::generate(Self::FN_DSA_LOGN).expect("Failed to generate FN-DSA key pair"); + + // Keep the private key as raw bytes until it is hex-encoded for storage. + let secret_key_bytes = keypair.private_key().to_vec(); + + // Keep the public key bytes separate so the network byte can be prepended. + let public_key_bytes = keypair.public_key().to_vec(); + + // Store long-address bytes as network byte plus the raw public key. + let mut encoded_public_key = Vec::with_capacity(Self::ADDRESS_BYTES_LENGTH); + encoded_public_key.push(network_byte); + encoded_public_key.extend_from_slice(&public_key_bytes); + + // Private keys are persisted as hex before wallet-image encryption. + let private_key_hex = encode(secret_key_bytes); + + // Return the network-tagged public key bytes and hex private key. + (encoded_public_key, private_key_hex) + } + + pub fn regenerate_public_key(private_key_hex: &str) -> Result, String> { + // Regenerated public keys always use the network selected at compile time. + let network_byte = Self::current_network_byte(); + + // Decode the stored private key from its wallet hex representation. + let private_key_bytes = match decode(private_key_hex) { + Ok(bytes) => bytes, + Err(_) => return Err("Invalid hex string for private key".to_string()), + }; + + // Reject private keys that do not match the expected FN-DSA byte length. + if private_key_bytes.len() != Self::PRIVATE_KEY_LENGTH { + return Err(format!( + "Invalid FN-DSA signing key length. Expected {} bytes, got {} bytes.", + Self::PRIVATE_KEY_LENGTH, + private_key_bytes.len() + )); + } + + // Rebuild the FN-DSA key pair from the stored signing key bytes. + let keypair = FnDsaKeyPair::from_private_key(&private_key_bytes) + .map_err(|e| format!("Failed to decode the provided FN-DSA private key: {e}"))?; + + // Return the public key in the same network-prefixed byte layout used by long addresses. + let mut encoded_public_key = Vec::with_capacity(Self::ADDRESS_BYTES_LENGTH); + encoded_public_key.push(network_byte); + encoded_public_key.extend_from_slice(keypair.public_key()); + Ok(encoded_public_key) + } +} diff --git a/src/wallets/create_wallet.rs b/src/wallets/create_wallet.rs new file mode 100644 index 0000000..f9944af --- /dev/null +++ b/src/wallets/create_wallet.rs @@ -0,0 +1,127 @@ +use crate::wallets::structures::SavedWallet; +use crate::wallets::structures::Wallet; +use crate::Path; +use crate::{create_img, encrypts}; +use crate::{decode, encode}; + +impl Wallet { + // Derive the long wallet address from network-prefixed public key bytes. + pub fn generate_address(public_key: &str) -> String { + // Decode the hex public key into the long-address byte layout. + let public_key_bytes = decode(public_key).expect("Failed to decode public key hex"); + + // Long-address bytes must contain the network byte plus the public key. + if public_key_bytes.len() != Self::ADDRESS_BYTES_LENGTH { + panic!( + "Invalid FN-DSA public key byte count. Expected {} bytes, got {} bytes.", + Self::ADDRESS_BYTES_LENGTH, + public_key_bytes.len() + ); + } + + // The first byte selects the wallet prefix and XOR mask. + let network_byte = public_key_bytes[0]; + + // Convert the network byte back into the visible CLC/CLTC prefix. + let prefix_str = Self::map_byte_to_wallet(network_byte); + + // XOR the public key body so the visible long address is network-bound. + let xored_public_key: Vec = public_key_bytes[1..] + .iter() + .map(|&byte| byte ^ network_byte) + .collect(); + + // Return the text long address as prefix plus encoded key body. + format!("{}{}", prefix_str, encode(xored_public_key)) + } + + pub fn create_wallet(&self, network_byte: u8) -> Wallet { + // Generate a fresh FN-DSA key pair for the selected network. + let (public_key, private_key) = Self::generate_keypair(network_byte); + + // Build the visible long address from the network-prefixed public key. + let long_address = Self::generate_address(&encode(&public_key)); + + // Decode the long address so the canonical short address can be derived. + let long_address_bytes = Self::long_address_to_bytes(long_address.clone()); + + // Hash the public key body into the fixed 22-byte short-address format. + let short_address_bytes = + Self::long_address_bytes_to_short_address_bytes(&long_address_bytes) + .expect("Failed to derive short address from generated wallet"); + + // Convert the short-address bytes into the saved text form. + let short_address = Self::bytes_to_short_address(&short_address_bytes) + .expect("Failed to encode generated short address"); + + // Use deterministic default wallet-image color values. + let r = Some(120); + let g = Some(234); + let b = Some(100); + + // Encrypt the private key with the wallet encryption key before image encoding. + let encrypted_private_key = + encrypts(&private_key, Some(&self.encryption_key), None).unwrap(); + + // Store no visible watermark in generated wallet images. + let watermark = "empty"; + + // Encode the encrypted private key into the wallet image payload. + let image_data = create_img( + &encrypted_private_key, + "h", + watermark, + r, + g, + b, + None, + None, + None, + ) + .expect("Failed to create image"); + + // Build the wallet data saved to disk. + let saved = SavedWallet { + long_address, + short_address, + vanity_address: None, + public_key: encode(public_key), + private_key: image_data, + }; + + // Return the complete in-memory wallet with its encryption key attached. + Wallet { + saved, + encryption_key: self.encryption_key.clone(), + } + } + + pub async fn generate_saved_struct( + wallet_path: &Path, + wallet_key: String, + ) -> Result { + // Start with an empty saved wallet so create_wallet has an encryption key holder. + let new_wallet = SavedWallet { + long_address: "".to_string(), + short_address: "".to_string(), + vanity_address: None, + public_key: "".to_string(), + private_key: "".to_string(), + }; + + // Attach the requested encryption key before generating the real wallet data. + let wallet = Wallet { + saved: new_wallet, + encryption_key: wallet_key.clone(), + }; + + // Generate the wallet for the active network. + let wallet = Wallet::create_wallet(&wallet, Self::current_network_byte()); + + // Save the generated wallet file before loading and decrypting it through the normal path. + wallet.saved.save_the_wallet(wallet_path).await; + + // Return the loaded wallet with its decrypted private key available in memory. + Self::private_key_from_wallet(wallet_path, wallet_key).await + } +} diff --git a/src/wallets/load_wallets.rs b/src/wallets/load_wallets.rs new file mode 100644 index 0000000..75def39 --- /dev/null +++ b/src/wallets/load_wallets.rs @@ -0,0 +1,90 @@ +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode_image_and_extract_text; +use crate::decrypts; +use crate::from_slice; +use crate::log::error; +use crate::metadata; +use crate::read; +use crate::wallets::structures::{SavedWallet, Wallet}; +use crate::Path; + +impl Wallet { + pub async fn get_wallet_path() -> String { + // Load the active network paths and keep only the wallet file path. + let ( + _network_name, + _padded_base_coin, + _suffix, + _torrent_path, + wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + wallet_path + } + + pub async fn private_key_from_wallet( + wallet_path: &Path, + wallet_key: String, + ) -> Result { + // Read the wallet JSON file from disk. + if let Ok(wallet_content) = read(wallet_path).await { + // Deserialize the saved wallet before extracting the encrypted image payload. + let mut wallet: SavedWallet = from_slice(&wallet_content) + .map_err(|e| format!("Deserialization of wallet failed: {e}"))?; + + // Extract the encrypted private key text from the wallet image. + if let Some(encrypted_text) = decode_image_and_extract_text(&wallet.private_key) { + // Decrypt the private key with the user-provided wallet key. + if let Some(decrypted_private_key) = decrypts(&encrypted_text, Some(&wallet_key)) { + // Replace the saved encrypted image payload with the decrypted private key in memory. + wallet.private_key = decrypted_private_key; + + // Attach the encryption key so the full wallet can be reused by callers. + let full_wallet = Wallet { + saved: wallet, + encryption_key: wallet_key.clone(), + }; + + // Return the loaded wallet when image extraction and decryption both succeed. + Ok(full_wallet) + } else { + error!("Decryption of private key failed."); + Err("Decryption of private key failed.".into()) + } + } else { + error!("Decryption of image failed."); + Err("Decryption of image failed.".into()) + } + } else { + error!("Wallet path did not exist"); + Err("Wallet path did not exist".into()) + } + } + + async fn load_wallet(wallet_path: &Path, wallet_key: String) -> Result { + // Load an existing wallet when the file exists. + if metadata(wallet_path).await.is_ok() { + Self::private_key_from_wallet(wallet_path, wallet_key).await + } else { + // Create, save, and load a new wallet when no wallet file exists. + Self::generate_saved_struct(wallet_path, wallet_key).await + } + } + + pub async fn try_obtain_wallet( + wallet_key: String, + path: Option<&str>, + ) -> Result { + // Use a caller-provided path when supplied, otherwise use the active network wallet path. + let wallet_path = match path { + Some(p) => p.to_string(), + None => Wallet::get_wallet_path().await, + }; + + // Load or create the wallet and return any failure to the caller. + Self::load_wallet(Path::new(&wallet_path), wallet_key).await + } +} diff --git a/src/wallets/mod.rs b/src/wallets/mod.rs new file mode 100644 index 0000000..ab5ee9e --- /dev/null +++ b/src/wallets/mod.rs @@ -0,0 +1,11 @@ +pub mod create_keys; +pub mod create_wallet; +pub mod load_wallets; +pub mod network_byte; +pub mod save_wallet; +pub mod short_address; +pub mod signature; +pub mod structures; +pub mod verifications; +pub mod verify_address; +pub mod wallet_bytes; diff --git a/src/wallets/network_byte.rs b/src/wallets/network_byte.rs new file mode 100644 index 0000000..c48e48a --- /dev/null +++ b/src/wallets/network_byte.rs @@ -0,0 +1,36 @@ +use crate::wallets::structures::Wallet; + +impl Wallet { + pub fn map_byte_to_wallet(byte: u8) -> String { + // Convert the stored network byte into the visible wallet prefix or suffix. + match byte { + 0b0000_0001 => "CLC".to_string(), + 0b0000_0010 => "CLTC".to_string(), + _ => String::new(), + } + } + + pub fn map_wallet_to_byte(prefix: &str) -> u8 { + // Convert accepted mainnet/testnet text tags into their stored network bytes. + match prefix { + "clc" => 0b0000_0001, + "cltc" => 0b0000_0010, + "CLC" => 0b0000_0001, + "CLTC" => 0b0000_0010, + _ => 0, + } + } + + pub fn current_network_byte() -> u8 { + // Mainnet builds use the CLC network byte. + #[cfg(feature = "mainnet")] + { + 0b0000_0001 + } + // Testnet builds use the CLTC network byte. + #[cfg(feature = "testnet")] + { + 0b0000_0010 + } + } +} diff --git a/src/wallets/save_wallet.rs b/src/wallets/save_wallet.rs new file mode 100644 index 0000000..5a97487 --- /dev/null +++ b/src/wallets/save_wallet.rs @@ -0,0 +1,24 @@ +use crate::log::error; +use crate::to_string_pretty; +use crate::wallets::structures::SavedWallet; +use crate::AsyncWriteExt; +use crate::File; +use crate::Path; + +impl SavedWallet { + // Save the local wallet JSON file containing wallet keys and addresses. + pub async fn save_the_wallet(&self, wallet_path: &Path) { + // Serialize the wallet with stable pretty JSON formatting. + let json_wallet = to_string_pretty(self).expect("Failed to serialize wallet"); + + // Create or replace the wallet file at the selected path. + if let Ok(mut file) = File::create(&wallet_path).await { + // Write the serialized wallet bytes to disk. + if let Err(err) = file.write_all(json_wallet.as_bytes()).await { + error!("Error writing wallet to file: {err}"); + } + } else { + error!("Error creating wallet file"); + } + } +} diff --git a/src/wallets/short_address.rs b/src/wallets/short_address.rs new file mode 100644 index 0000000..e88641a --- /dev/null +++ b/src/wallets/short_address.rs @@ -0,0 +1,28 @@ +use crate::wallets::structures::Wallet; + +impl Wallet { + pub fn normalize_to_short_address(address: &str) -> Option { + // Accept an already canonical short address and normalize casing/format from bytes. + if Self::short_address_to_bytes(address).is_some() { + let short_address_bytes = Self::short_address_to_bytes(address)?; + return Self::bytes_to_short_address(&short_address_bytes); + } + + // Accept a vanity address as a valid short-address style alias. + if Self::vanity_address_to_bytes(address).is_some() { + let vanity_address_bytes = Self::vanity_address_to_bytes(address)?; + return Self::bytes_to_vanity_address(&vanity_address_bytes); + } + + // Treat anything else as a possible long wallet address. + let long_address_bytes = Self::long_address_to_bytes(address.to_string()); + if long_address_bytes.len() != Self::ADDRESS_BYTES_LENGTH { + return None; + } + + // Derive the canonical short address from valid long-address bytes. + let derived_short_address = + Self::long_address_bytes_to_short_address_bytes(&long_address_bytes)?; + Self::bytes_to_short_address(&derived_short_address) + } +} diff --git a/src/wallets/signature.rs b/src/wallets/signature.rs new file mode 100644 index 0000000..cb08e9c --- /dev/null +++ b/src/wallets/signature.rs @@ -0,0 +1,36 @@ +use crate::thread_rng; +use crate::wallets::structures::Wallet; +use crate::{decode, encode}; +use fn_dsa::{SigningKey, SigningKeyStandard, DOMAIN_NONE, HASH_ID_RAW}; + +impl Wallet { + pub async fn sign_transaction(message: &str, private_key_hex: &str) -> String { + // Decode the wallet private key from its saved hex representation. + let private_key_bytes = decode(private_key_hex).expect("Failed to decode private key hex"); + + // Rebuild the FN-DSA signing key from the private key bytes. + let mut signing_key = SigningKeyStandard::decode(&private_key_bytes) + .expect("Failed to decode FN-DSA private key"); + + // Sign decoded hash bytes when the message is hex, otherwise sign the raw message bytes. + let message_bytes = decode(message).unwrap_or_else(|_| message.as_bytes().to_vec()); + + // Allocate the fixed-size FN-DSA signature buffer. + let mut signature_bytes = vec![0u8; Self::SIGNATURE_LENGTH]; + + // Use thread-local randomness for FN-DSA signing. + let mut rng = thread_rng(); + + // Write the signature bytes into the fixed buffer. + signing_key.sign( + &mut rng, + &DOMAIN_NONE, + &HASH_ID_RAW, + &message_bytes, + &mut signature_bytes, + ); + + // Return signatures as hex strings for transaction storage and verification. + encode(signature_bytes) + } +} diff --git a/src/wallets/structures.rs b/src/wallets/structures.rs new file mode 100644 index 0000000..9648e87 --- /dev/null +++ b/src/wallets/structures.rs @@ -0,0 +1,39 @@ +use crate::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SavedWallet { + pub long_address: String, + pub short_address: String, + pub vanity_address: Option, + pub public_key: String, + pub private_key: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Wallet { + pub saved: SavedWallet, + pub encryption_key: String, +} +impl Wallet { + pub const FN_DSA_LOGN: u32 = 9; + pub const PUBLIC_KEY_LENGTH: usize = 897; + pub const PRIVATE_KEY_LENGTH: usize = 1281; + pub const SIGNATURE_LENGTH: usize = 666; + pub const ADDRESS_BYTES_LENGTH: usize = 1 + Self::PUBLIC_KEY_LENGTH; + pub const ADDRESS_HEX_LENGTH: usize = Self::PUBLIC_KEY_LENGTH * 2; + pub const SHORT_ADDRESS_HASH_BYTES_LENGTH: usize = 20; + pub const SHORT_ADDRESS_BYTES_LENGTH: usize = Self::SHORT_ADDRESS_HASH_BYTES_LENGTH + 2; + pub const SHORT_ADDRESS_SEPARATOR: u8 = b'.'; + + pub fn display_wallet(&self) -> String { + let mut output = format!( + "Long Address: {}\nShort Address: {}", + self.saved.long_address, self.saved.short_address + ); + if let Some(vanity_address) = &self.saved.vanity_address { + output.push_str(&format!("\nVanity Address: {vanity_address}")); + } + output.push_str(&format!("\nPublic Key: {}", self.saved.public_key)); + output + } +} diff --git a/src/wallets/verifications.rs b/src/wallets/verifications.rs new file mode 100644 index 0000000..e6e5f09 --- /dev/null +++ b/src/wallets/verifications.rs @@ -0,0 +1,87 @@ +use crate::blocks::block::UnminedBlock; +use crate::decode; +use crate::wallets::structures::Wallet; +use fn_dsa::{VerifyingKey, VerifyingKeyStandard, DOMAIN_NONE, HASH_ID_RAW}; + +impl Wallet { + pub async fn vrf_verify(number: u128, hash: &str, address: &str, signature: &str) -> bool { + // Derive the VRF number from the submitted signature. + let calculated_number = UnminedBlock::generate_random_number(signature).await; + + // Accept the VRF only when the signature verifies and produces the expected number. + if Self::verify_transaction(hash, signature, address).await && calculated_number == number { + return true; + } + false + } + + pub async fn vrf_verify_with_public_key( + number: u128, + hash: &str, + public_key_hex: &str, + signature: &str, + ) -> bool { + // Derive the VRF number from the submitted signature. + let calculated_number = UnminedBlock::generate_random_number(signature).await; + + // Verify directly against a public key when a long wallet address is not available. + if Self::verify_transaction_with_public_key(hash, signature, public_key_hex).await + && calculated_number == number + { + return true; + } + false + } + + pub async fn verify_transaction(message: &str, signature_hex: &str, address: &str) -> bool { + // Decode and length-check the submitted signature. + let sig_bytes = match decode(signature_hex) { + Ok(b) if b.len() == Self::SIGNATURE_LENGTH => b, + _ => return false, + }; + + // Decode the long wallet address into network-prefixed public key bytes. + let wallet_bytes = Self::long_address_to_bytes(address.to_string()); + if wallet_bytes.len() != Self::ADDRESS_BYTES_LENGTH { + return false; + } + + // Rebuild the FN-DSA verifying key from the public key body. + let verifying_key = match VerifyingKeyStandard::decode(&wallet_bytes[1..]) { + Some(key) => key, + None => return false, + }; + + // Verify decoded hash bytes when the message is hex, otherwise verify raw message bytes. + let message_bytes = decode(message).unwrap_or_else(|_| message.as_bytes().to_vec()); + verifying_key.verify(&sig_bytes, &DOMAIN_NONE, &HASH_ID_RAW, &message_bytes) + } + + pub async fn verify_transaction_with_public_key( + message: &str, + signature_hex: &str, + public_key_hex: &str, + ) -> bool { + // Decode and length-check the submitted signature. + let sig_bytes = match decode(signature_hex) { + Ok(b) if b.len() == Self::SIGNATURE_LENGTH => b, + _ => return false, + }; + + // Decode and length-check the raw public key bytes. + let public_key_bytes = match decode(public_key_hex) { + Ok(bytes) if bytes.len() == Self::PUBLIC_KEY_LENGTH => bytes, + _ => return false, + }; + + // Rebuild the FN-DSA verifying key from the provided public key. + let verifying_key = match VerifyingKeyStandard::decode(&public_key_bytes) { + Some(key) => key, + None => return false, + }; + + // Verify decoded hash bytes when the message is hex, otherwise verify raw message bytes. + let message_bytes = decode(message).unwrap_or_else(|_| message.as_bytes().to_vec()); + verifying_key.verify(&sig_bytes, &DOMAIN_NONE, &HASH_ID_RAW, &message_bytes) + } +} diff --git a/src/wallets/verify_address.rs b/src/wallets/verify_address.rs new file mode 100644 index 0000000..a5e151a --- /dev/null +++ b/src/wallets/verify_address.rs @@ -0,0 +1,61 @@ +use crate::wallets::structures::Wallet; + +impl Wallet { + fn has_valid_public_key_bytes(public_key_bytes: &[u8]) -> bool { + // Public keys must match the FN-DSA public key byte length exactly. + if public_key_bytes.len() != Self::PUBLIC_KEY_LENGTH { + return false; + } + + // FN-DSA encoded public keys carry the security parameter in the first byte. + public_key_bytes[0] == Self::FN_DSA_LOGN as u8 + } + + // Validate a long wallet address for the active network and FN-DSA key layout. + pub async fn wallet_validation(wallet: &str) -> bool { + // Split the visible network prefix from the encoded key body. + let (network_prefix, encoded_key) = if let Some(rest) = wallet.strip_prefix("CLTC") { + ("CLTC", rest) + } else if let Some(rest) = wallet.strip_prefix("CLC") { + ("CLC", rest) + } else { + return false; + }; + + // Compare the wallet prefix against the currently compiled network. + let expected_byte = Self::current_network_byte(); + + if Wallet::map_wallet_to_byte(network_prefix) != expected_byte { + return false; + } + + // Long wallet address bodies must be the exact encoded public key length. + if encoded_key.len() != Self::ADDRESS_HEX_LENGTH { + return false; + } + + // The public key body is stored as hex text. + if !encoded_key.chars().all(|ch| ch.is_ascii_hexdigit()) { + return false; + } + + // Decode the address and verify the network byte and public key payload. + let wallet_bytes = Self::long_address_to_bytes(wallet.to_string()); + wallet_bytes.len() == Self::ADDRESS_BYTES_LENGTH + && wallet_bytes[0] == expected_byte + && Self::has_valid_public_key_bytes(&wallet_bytes[1..]) + } + + pub fn short_address_validation(short_address: &str) -> bool { + // Accept either canonical short-address bytes or vanity-address bytes. + let short_address_bytes = match Self::short_address_to_bytes(short_address) + .or_else(|| Self::vanity_address_to_bytes(short_address)) + { + Some(bytes) => bytes, + None => return false, + }; + + // The final byte must match the currently compiled network. + short_address_bytes[Self::SHORT_ADDRESS_BYTES_LENGTH - 1] == Self::current_network_byte() + } +} diff --git a/src/wallets/wallet_bytes.rs b/src/wallets/wallet_bytes.rs new file mode 100644 index 0000000..8040d29 --- /dev/null +++ b/src/wallets/wallet_bytes.rs @@ -0,0 +1,243 @@ +use crate::common::skein::{skein_256_hash_bytes, ripemd160_hash_bytes}; +use crate::log::error; +use crate::wallets::structures::Wallet; +use crate::{decode, encode}; + +impl Wallet { + pub fn long_address_to_bytes(wallet: String) -> Vec { + // Return an empty byte vector when the wallet cannot be decoded. + let mut wallet_bytes = Vec::new(); + + // Split the visible long-address prefix from the encoded public key body. + let (prefix, encoded_key) = if let Some(rest) = wallet.strip_prefix("CLTC") { + ("CLTC", rest) + } else if let Some(rest) = wallet.strip_prefix("CLC") { + ("CLC", rest) + } else { + return wallet_bytes; + }; + + // Long-address bodies must be exactly one public key encoded as hex. + if encoded_key.len() != Self::ADDRESS_HEX_LENGTH { + return wallet_bytes; + } + + // Convert the visible network prefix into the stored network byte. + let prefix_byte = Self::map_wallet_to_byte(prefix); + if prefix_byte == 0 { + return wallet_bytes; + } + + // Decode the XORed public key body from hex. + let xored_key_bytes = match decode(encoded_key) { + Ok(bytes) if bytes.len() == Self::PUBLIC_KEY_LENGTH => bytes, + _ => return wallet_bytes, + }; + + // Store the network byte first, followed by the unmasked public key bytes. + wallet_bytes.push(prefix_byte); + wallet_bytes.extend(xored_key_bytes.into_iter().map(|byte| byte ^ prefix_byte)); + wallet_bytes + } + + pub fn bytes_to_long_address(bytes: Vec) -> String { + // Long-address bytes must contain the network byte and public key body. + if bytes.len() != Self::ADDRESS_BYTES_LENGTH { + error!( + "Invalid byte length for wallet. Expected {} bytes, got {}", + Self::ADDRESS_BYTES_LENGTH, + bytes.len() + ); + return String::new(); + } + + // Read the network byte and public key body from the fixed layout. + let prefix_byte = bytes[0]; + let public_key_bytes = &bytes[1..Self::ADDRESS_BYTES_LENGTH]; + + // Convert the network byte into the visible long-address prefix. + let prefix = Self::map_byte_to_wallet(prefix_byte); + if prefix.is_empty() { + error!("Invalid wallet network byte: {prefix_byte}"); + return String::new(); + } + + // XOR the public key body for the visible long-address encoding. + let xored_public_key: Vec = public_key_bytes + .iter() + .map(|byte| byte ^ prefix_byte) + .collect(); + + // Encode the masked public key body as hex and prepend the network prefix. + let address = encode(xored_public_key); + let wallet = format!("{prefix}{address}"); + wallet + } + + pub fn bytes_to_short_address(short_address_bytes: &[u8]) -> Option { + // Short-address bytes must contain a 20-byte payload, separator, and network byte. + if short_address_bytes.len() != Self::SHORT_ADDRESS_BYTES_LENGTH { + return None; + } + + // The separator byte must be the stored dot between payload and suffix. + if short_address_bytes[Self::SHORT_ADDRESS_HASH_BYTES_LENGTH] + != Self::SHORT_ADDRESS_SEPARATOR + { + return None; + } + + // Convert the final network byte into the visible short-address suffix. + let network_suffix = + Self::map_byte_to_wallet(short_address_bytes[Self::SHORT_ADDRESS_BYTES_LENGTH - 1]); + if network_suffix.is_empty() { + return None; + } + let network_suffix = network_suffix.to_ascii_lowercase(); + + // Canonical short addresses encode the 20-byte hash payload as 40 hex characters. + let payload_bytes = &short_address_bytes[..Self::SHORT_ADDRESS_HASH_BYTES_LENGTH]; + let hash_hex = encode(payload_bytes); + Some(format!("{hash_hex}.{network_suffix}")) + } + + pub fn short_address_to_bytes(short_address: &str) -> Option> { + // Split the canonical short address into hash payload and network suffix. + let (payload, network_suffix) = short_address.rsplit_once('.')?; + + // Convert the visible suffix into the stored network byte. + let network_byte = Self::map_wallet_to_byte(network_suffix); + if network_byte == 0 { + return None; + } + + // Canonical short addresses must use a 40-character hex payload. + if payload.len() != Self::SHORT_ADDRESS_HASH_BYTES_LENGTH * 2 + || !payload.chars().all(|ch| ch.is_ascii_hexdigit()) + { + return None; + } + + // Decode the 40-character hex payload into the stored 20-byte hash. + let payload_bytes = decode(payload).ok()?; + if payload_bytes.len() != Self::SHORT_ADDRESS_HASH_BYTES_LENGTH { + return None; + } + + // Build the fixed 22-byte short-address layout. + let mut short_address_bytes = Vec::with_capacity(Self::SHORT_ADDRESS_BYTES_LENGTH); + short_address_bytes.extend_from_slice(&payload_bytes); + short_address_bytes.push(Self::SHORT_ADDRESS_SEPARATOR); + short_address_bytes.push(network_byte); + Some(short_address_bytes) + } + + pub fn vanity_address_to_bytes(vanity_address: &str) -> Option> { + // Split the vanity address into text payload and network suffix. + let (payload, network_suffix) = vanity_address.rsplit_once('.')?; + + // Convert the visible suffix into the stored network byte. + let network_byte = Self::map_wallet_to_byte(network_suffix); + if network_byte == 0 { + return None; + } + + // Validate and normalize the fixed-width vanity payload. + let payload_bytes = Self::sanitize_vanity_address_bytes(payload)?; + + // Build the fixed 22-byte vanity-address layout. + let mut vanity_address_bytes = Vec::with_capacity(Self::SHORT_ADDRESS_BYTES_LENGTH); + vanity_address_bytes.extend_from_slice(&payload_bytes); + vanity_address_bytes.push(Self::SHORT_ADDRESS_SEPARATOR); + vanity_address_bytes.push(network_byte); + Some(vanity_address_bytes) + } + + pub fn bytes_to_vanity_address(vanity_address_bytes: &[u8]) -> Option { + // Vanity-address bytes must contain a 20-byte payload, separator, and network byte. + if vanity_address_bytes.len() != Self::SHORT_ADDRESS_BYTES_LENGTH { + return None; + } + + // The separator byte must be the stored dot between payload and suffix. + if vanity_address_bytes[Self::SHORT_ADDRESS_HASH_BYTES_LENGTH] + != Self::SHORT_ADDRESS_SEPARATOR + { + return None; + } + + // Convert the final network byte into the visible vanity-address suffix. + let network_suffix = + Self::map_byte_to_wallet(vanity_address_bytes[Self::SHORT_ADDRESS_BYTES_LENGTH - 1]); + if network_suffix.is_empty() { + return None; + } + let network_suffix = network_suffix.to_ascii_lowercase(); + + // Interpret the 20-byte payload as ASCII vanity text. + let payload = + std::str::from_utf8(&vanity_address_bytes[..Self::SHORT_ADDRESS_HASH_BYTES_LENGTH]) + .ok()?; + + // Reuse vanity sanitization so decoded bytes follow the same stored rules. + let payload_bytes = Self::sanitize_vanity_address_bytes(payload)?; + let payload = String::from_utf8(payload_bytes).ok()?; + Some(format!("{payload}.{network_suffix}")) + } + + pub fn long_address_bytes_to_short_address_bytes(long_address_bytes: &[u8]) -> Option> { + // Long-address bytes must contain the network byte and public key body. + if long_address_bytes.len() != Self::ADDRESS_BYTES_LENGTH { + return None; + } + + // Keep the source network byte as the short-address network byte. + let network_byte = long_address_bytes[0]; + if Self::map_byte_to_wallet(network_byte).is_empty() { + return None; + } + + // Hash the public key body with Skein before RIPEMD-160 shortening. + let skein_hash_hex = skein_256_hash_bytes(&long_address_bytes[1..]); + let skein_hash_bytes = decode(&skein_hash_hex).ok()?; + + // RIPEMD-160 produces the 20-byte short-address payload. + let ripemd_hash = ripemd160_hash_bytes(&skein_hash_bytes); + + // Ensure the shortened hash matches the fixed payload length. + if ripemd_hash.len() != Self::SHORT_ADDRESS_HASH_BYTES_LENGTH { + return None; + } + + // Build the fixed 22-byte short-address layout. + let mut short_address = Vec::with_capacity(Self::SHORT_ADDRESS_BYTES_LENGTH); + short_address.extend_from_slice(&ripemd_hash); + short_address.push(Self::SHORT_ADDRESS_SEPARATOR); + short_address.push(network_byte); + Some(short_address) + } + + pub fn sanitize_vanity_address_bytes(payload: &str) -> Option> { + // Vanity payloads are stored as exactly 20 ASCII bytes. + if payload.len() != Self::SHORT_ADDRESS_HASH_BYTES_LENGTH { + return None; + } + if !payload.is_ascii() { + return None; + } + + // Leading spaces are allowed for fixed-width padding, but at least one letter is required. + let vanity_string = payload.trim_start(); + if vanity_string.is_empty() { + return None; + } + + // After padding is removed, vanity payloads may contain letters only. + if !vanity_string.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + + // Store vanity payload text in lowercase while preserving leading padding. + Some(payload.to_ascii_lowercase().into_bytes()) + } +}