diff --git a/.cla-signed-users b/.cla-signed-users index 49b3730d..6eaf64c7 100644 --- a/.cla-signed-users +++ b/.cla-signed-users @@ -4,8 +4,12 @@ andrestielau prasanna-anchorage hassan-anchor francisco-olea-anchor +guido-peirano-anchor +diego-rivas-anchor # Turnkey r-n-o # Distributed Lab KyrylR - +#bots +#makes the PR +github-actions[bot] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c362c377 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing + +Thank you for your interest in contributing to this project! This document outlines the contribution workflow and guidelines. + +## Governance + +For details about authority, core team structure, and decision-making, please refer to [GOVERNANCE.md](GOVERNANCE.md). + +## How to Contribute + +### For Community/Public Contributors +1. **Fork** the repository +2. **Create a feature branch** from `main` +3. **Make your changes** following the code style and practices used in the project +4. **Sign the Contributor License Agreement (CLA) if this is your first contribution. The CLA check will prompt you with instructions if needed.** +5. **Submit a Pull Request** with a clear description of your changes +6. **Wait for Core Team review** - A Core Team member will review and approve your PR before it can be merged + +### For Core Team Members +1. Core Team members can review, approve, and merge PRs from community contributors and other Core Team members +2. Follow the same PR workflow but with merge authority + +## PR Guidelines +- **Clear description:** Explain what problem your PR solves and how it solves it +- **Test coverage:** Include tests for new functionality +- **Code style:** Follow existing code conventions in the repository +- **One concern per PR:** Keep PRs focused on a single feature or bug fix + +## Code Standards +- Format your code using [rustfmt](https://github.com/rust-lang/rustfmt) by running `make -C src fmt` before submitting +- Address all linter warnings by running `make -C src lint` and fixing issues +- Run `make -C src test` to ensure all tests pass before submitting +- No breaking changes without discussion + +## Questions or Issues? +If you have questions about the contribution process, please reach out to the Core Team through the repository's issue tracker. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 00000000..281f5c95 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,18 @@ +# Governance + +## Authority and The Core Team + +### Authority +**Anchorage Digital** retains all rights, title, and interest in the repository. Final decision-making authority rests with the Anchorage Digital internal engineering leadership. + +### The Core Team +The "Core Team" is the only group authorized to merge code, approve pull requests, and manage releases. This team is composed of: + +1. **Anchorage Digital Employees:** Internal engineering staff. +2. **Authorized Partners:** designated representatives from partner organizations (e.g., Turnkey). +3. **Contractors:** Engineering contractors vetted and authorized by Anchorage Digital. + +### Contribution Workflow +* **Core Team Members:** May review and approve PRs. We currently require at least one Anchorage employee to review and Approve PRs. +* **Community/Public:** May submit PRs, but they must be approved by a Core Team member. We currently require at least one Anchorage employee to review and Approve PRs. For protocol owners in chains/dapps that we consider in maintenance mode, we want to require 1 Anchorage Employee and 1 Protocol Owner to approve the PR. +* Note: Being a Partner or Contractor does not grant automatic administrative rights over the repository settings, only code maintenance rights. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..9093bd3f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy + +We take the security of this project seriously. We appreciate your help in disclosing vulnerabilities responsibly and ethically. + +## Supported Versions + +Only the latest versions of this project are currently supported with security updates. +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +If you have discovered a security vulnerability, we ask that you report it privately to the maintainers. + +### Method 1: GitHub Private Reporting (Preferred) +You can privately report a vulnerability directly on GitHub: +1. Go to the **Security** tab of this repository. +2. Click on **Advisories**. +3. Click **"Report a vulnerability"**. + +### Method 2: Email +If you cannot use the GitHub reporting tool, please email the maintainers at **[security@anchorage.com]**. + +## Reporting Guidelines + +To help us triage and patch the issue as quickly as possible, please include: +* A description of the vulnerability. +* Steps to reproduce the issue (a proof-of-concept code or script is ideal). +* Any relevant logs or error messages. +* The version of the software you are running. + +## Our Response Policy + +After you report a vulnerability, here is what you can expect: +1. **Acknowledgement:** We will acknowledge receipt of your report within **[72 hours]**. +2. **Assessment:** We will confirm the vulnerability and determine its severity. +3. **Fix:** We will work on a patch. We may ask you to verify the fix. +4. **Release:** We will release a security update and a public security advisory. + +We ask that you maintain confidentiality during this process and do not disclose the vulnerability publicly until a patch has been released. + +## Bounty Program +**[Choose one of the following options and delete the other]** + +* **Option A:** We currently **do not** offer a paid bug bounty program. diff --git a/src/Cargo.lock b/src/Cargo.lock index 2f5439aa..f29b36d9 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -86,10 +86,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52a2c365c0245cbb8959de725fc2b44c754b673fdf34c9a7f9d4a25c35a7bf1" dependencies = [ "ahash 0.8.12", - "solana-epoch-schedule", - "solana-hash", - "solana-pubkey", - "solana-sha256-hasher", + "solana-epoch-schedule 2.2.1", + "solana-hash 2.3.0", + "solana-pubkey 2.4.0", + "solana-sha256-hasher 2.3.0", "solana-svm-feature-set", ] @@ -100,8 +100,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8289c8a8a2ef5aa10ce49a070f360f4e035ee3410b8d8f3580fb39d8cf042581" dependencies = [ "agave-feature-set", - "solana-pubkey", - "solana-sdk-ids", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -170,7 +170,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6068f356948cd84b5ad9ac30c50478e433847f14a50714d2b68f15d052724049" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.4.1", "num_enum 0.7.5", "strum 0.27.2", ] @@ -182,7 +182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3abecb92ba478a285fbf5689100dbafe4003ded4a09bf4b5ef62cca87cd4f79e" dependencies = [ "alloy-eips", - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rlp", "alloy-serde", "alloy-trie", @@ -209,7 +209,7 @@ checksum = "2e864d4f11d1fb8d3ac2fd8f3a15f1ee46d55ec6d116b342ed1b2cb737f25894" dependencies = [ "alloy-consensus", "alloy-eips", - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rlp", "alloy-serde", "serde", @@ -223,10 +223,10 @@ checksum = "c98d21aeef3e0783046c207abd3eb6cb41f6e77e0c0fc8077ebecd6df4f9d171" dependencies = [ "alloy-consensus", "alloy-dyn-abi", - "alloy-json-abi", + "alloy-json-abi 1.4.1", "alloy-network", "alloy-network-primitives", - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-provider", "alloy-rpc-types-eth", "alloy-sol-types", @@ -243,9 +243,9 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdff496dd4e98a81f4861e66f7eaf5f2488971848bb42d9c892f871730245c8" dependencies = [ - "alloy-json-abi", - "alloy-primitives", - "alloy-sol-type-parser", + "alloy-json-abi 1.4.1", + "alloy-primitives 1.4.1", + "alloy-sol-type-parser 1.4.1", "alloy-sol-types", "itoa", "serde", @@ -259,7 +259,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rlp", "crc", "serde", @@ -272,7 +272,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b82752a889170df67bbb36d42ca63c531eb16274f0d7299ae2a680facba17bd" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rlp", "serde", ] @@ -283,7 +283,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d4769c6ffddca380b0070d71c8b7f30bed375543fe76bb2f74ec0acf4b7cd16" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rlp", "serde", "thiserror 2.0.17", @@ -298,7 +298,7 @@ dependencies = [ "alloy-eip2124", "alloy-eip2930", "alloy-eip7702", - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rlp", "alloy-serde", "auto_impl", @@ -311,14 +311,26 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "alloy-json-abi" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4584e3641181ff073e9d5bec5b3b8f78f9749d9fb108a1cfbc4399a4a139c72a" +dependencies = [ + "alloy-primitives 0.8.26", + "alloy-sol-type-parser 0.8.26", + "serde", + "serde_json", +] + [[package]] name = "alloy-json-abi" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5513d5e6bd1cba6bdcf5373470f559f320c05c8c59493b6e98912fbe6733943f" dependencies = [ - "alloy-primitives", - "alloy-sol-type-parser", + "alloy-primitives 1.4.1", + "alloy-sol-type-parser 1.4.1", "serde", "serde_json", ] @@ -329,7 +341,7 @@ version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87b774478fcc616993e97659697f3e3c7988fdad598e46ee0ed11209cd0d8ee" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-sol-types", "http 1.3.1", "serde", @@ -349,7 +361,7 @@ dependencies = [ "alloy-eips", "alloy-json-rpc", "alloy-network-primitives", - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rpc-types-any", "alloy-rpc-types-eth", "alloy-serde", @@ -372,11 +384,38 @@ checksum = "219dccd2cf753a43bd9b0fbb7771a16927ffdb56e43e3a15755bef1a74d614aa" dependencies = [ "alloy-consensus", "alloy-eips", - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-serde", "serde", ] +[[package]] +name = "alloy-primitives" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777d58b30eb9a4db0e5f59bc30e8c2caef877fee7dc8734cf242a51a60f22e05" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more 2.0.1", + "foldhash 0.1.5", + "hashbrown 0.15.5", + "indexmap 2.12.0", + "itoa", + "k256 0.13.4", + "keccak-asm", + "paste", + "proptest", + "rand 0.8.5", + "ruint", + "rustc-hash", + "serde", + "sha3", + "tiny-keccak", +] + [[package]] name = "alloy-primitives" version = "1.4.1" @@ -416,7 +455,7 @@ dependencies = [ "alloy-json-rpc", "alloy-network", "alloy-network-primitives", - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-signer", @@ -469,7 +508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0f67d1e655ed93efca217213340d21cce982333cc44a1d918af9150952ef66" dependencies = [ "alloy-json-rpc", - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-transport", "futures", "pin-project", @@ -503,7 +542,7 @@ dependencies = [ "alloy-consensus-any", "alloy-eips", "alloy-network-primitives", - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rlp", "alloy-serde", "alloy-sol-types", @@ -520,7 +559,7 @@ version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "596cfa360922ba9af901cc7370c68640e4f72adb6df0ab064de32f21fec498d7" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.4.1", "serde", "serde_json", ] @@ -531,7 +570,7 @@ version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f06333680d04370c8ed3a6b0eccff384e422c3d8e6b19e61fedc3a9f0ab7743" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.4.1", "async-trait", "auto_impl", "either", @@ -588,6 +627,16 @@ dependencies = [ "syn-solidity", ] +[[package]] +name = "alloy-sol-type-parser" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c13fc168b97411e04465f03e632f31ef94cad1c7c8951bf799237fd7870d535" +dependencies = [ + "serde", + "winnow", +] + [[package]] name = "alloy-sol-type-parser" version = "1.4.1" @@ -604,8 +653,8 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70319350969a3af119da6fb3e9bddb1bce66c9ea933600cb297c8b1850ad2a3c" dependencies = [ - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 1.4.1", + "alloy-primitives 1.4.1", "alloy-sol-macro", "serde", ] @@ -639,7 +688,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3412d52bb97c6c6cc27ccc28d4e6e8cf605469101193b50b0bd5813b1f990b5" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.4.1", "alloy-rlp", "arrayvec", "derive_more 2.0.1", @@ -3352,6 +3401,15 @@ dependencies = [ "five8_core", ] +[[package]] +name = "five8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f76610e969fa1784327ded240f1e28a3fd9520c9cec93b636fcf62dd37f772" +dependencies = [ + "five8_core", +] + [[package]] name = "five8_const" version = "0.1.4" @@ -3361,6 +3419,15 @@ dependencies = [ "five8_core", ] +[[package]] +name = "five8_const" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a0f1728185f277989ca573a402716ae0beaaea3f76a8ff87ef9dd8fb19436c5" +dependencies = [ + "five8_core", +] + [[package]] name = "five8_core" version = "0.1.2" @@ -3820,6 +3887,7 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", + "serde", ] [[package]] @@ -6116,6 +6184,7 @@ dependencies = [ "tracing-log 0.2.0", "tracing-subscriber", "visualsign", + "visualsign-ethereum", "visualsign-solana", "visualsign-unspecified", ] @@ -8217,12 +8286,12 @@ dependencies = [ "serde", "serde_bytes", "serde_derive", - "solana-account-info", - "solana-clock", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-sysvar", + "solana-account-info 2.3.0", + "solana-clock 2.2.2", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-sysvar 2.3.0", ] [[package]] @@ -8242,22 +8311,22 @@ dependencies = [ "solana-account", "solana-account-decoder-client-types", "solana-address-lookup-table-interface", - "solana-clock", + "solana-clock 2.2.2", "solana-config-program-client", - "solana-epoch-schedule", - "solana-fee-calculator", - "solana-instruction", + "solana-epoch-schedule 2.2.1", + "solana-fee-calculator 2.2.1", + "solana-instruction 2.3.1", "solana-loader-v3-interface", "solana-nonce", - "solana-program-option", - "solana-program-pack", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-slot-hashes", - "solana-slot-history", + "solana-program-option 2.2.1", + "solana-program-pack 2.2.1", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-slot-hashes 2.2.1", + "solana-slot-history 2.2.1", "solana-stake-interface", - "solana-sysvar", + "solana-sysvar 2.3.0", "solana-vote-interface", "spl-generic-token", "spl-token 8.0.0", @@ -8280,7 +8349,7 @@ dependencies = [ "serde_derive", "serde_json", "solana-account", - "solana-pubkey", + "solana-pubkey 2.4.0", "zstd", ] @@ -8292,9 +8361,50 @@ checksum = "c8f5152a288ef1912300fc6efa6c2d1f9bb55d9398eb6c72326360b8063987da" dependencies = [ "bincode", "serde", - "solana-program-error", - "solana-program-memory", - "solana-pubkey", + "solana-program-error 2.2.2", + "solana-program-memory 2.3.1", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-account-info" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3397241392f5756925029acaa8515dc70fcbe3d8059d4885d7d6533baf64fd" +dependencies = [ + "solana-address 2.0.0", + "solana-program-error 3.0.0", + "solana-program-memory 3.1.0", +] + +[[package]] +name = "solana-address" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ecac8e1b7f74c2baa9e774c42817e3e75b20787134b76cc4d45e8a604488f5" +dependencies = [ + "solana-address 2.0.0", +] + +[[package]] +name = "solana-address" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37320fd2945c5d654b2c6210624a52d66c3f1f73b653ed211ab91a703b35bdd" +dependencies = [ + "borsh 1.5.7", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek 4.1.3", + "five8 1.0.0", + "five8_const 1.0.0", + "serde", + "serde_derive", + "solana-atomic-u64 3.0.0", + "solana-define-syscall 4.0.1", + "solana-program-error 3.0.0", + "solana-sanitize 3.0.1", + "solana-sha256-hasher 3.1.0", ] [[package]] @@ -8307,11 +8417,11 @@ dependencies = [ "bytemuck", "serde", "serde_derive", - "solana-clock", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-slot-hashes", + "solana-clock 2.2.2", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-slot-hashes 2.2.1", ] [[package]] @@ -8323,6 +8433,15 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "solana-atomic-u64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a933ff1e50aff72d02173cfcd7511bd8540b027ee720b75f353f594f834216d0" +dependencies = [ + "parking_lot", +] + [[package]] name = "solana-big-mod-exp" version = "2.2.1" @@ -8331,7 +8450,7 @@ checksum = "75db7f2bbac3e62cfd139065d15bcda9e2428883ba61fc8d27ccb251081e7567" dependencies = [ "num-bigint 0.4.6", "num-traits", - "solana-define-syscall", + "solana-define-syscall 2.3.0", ] [[package]] @@ -8342,7 +8461,7 @@ checksum = "19a3787b8cf9c9fe3dd360800e8b70982b9e5a8af9e11c354b6665dd4a003adc" dependencies = [ "bincode", "serde", - "solana-instruction", + "solana-instruction 2.3.1", ] [[package]] @@ -8352,9 +8471,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a0801e25a1b31a14494fc80882a036be0ffd290efc4c2d640bfcca120a4672" dependencies = [ "blake3", - "solana-define-syscall", - "solana-hash", - "solana-sanitize", + "solana-define-syscall 2.3.0", + "solana-hash 2.3.0", + "solana-sanitize 2.2.1", ] [[package]] @@ -8368,7 +8487,7 @@ dependencies = [ "ark-ff 0.4.2", "ark-serialize 0.4.2", "bytemuck", - "solana-define-syscall", + "solana-define-syscall 2.3.0", "thiserror 2.0.17", ] @@ -8382,6 +8501,15 @@ dependencies = [ "borsh 1.5.7", ] +[[package]] +name = "solana-borsh" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc402b16657abbfa9991cd5cbfac5a11d809f7e7d28d3bb291baeb088b39060e" +dependencies = [ + "borsh 1.5.7", +] + [[package]] name = "solana-client-traits" version = "2.2.1" @@ -8391,16 +8519,16 @@ dependencies = [ "solana-account", "solana-commitment-config", "solana-epoch-info", - "solana-hash", - "solana-instruction", + "solana-hash 2.3.0", + "solana-instruction 2.3.1", "solana-keypair", "solana-message", - "solana-pubkey", - "solana-signature", - "solana-signer", - "solana-system-interface", + "solana-pubkey 2.4.0", + "solana-signature 2.3.0", + "solana-signer 2.2.1", + "solana-system-interface 1.0.0", "solana-transaction", - "solana-transaction-error", + "solana-transaction-error 2.2.1", ] [[package]] @@ -8411,9 +8539,22 @@ checksum = "1bb482ab70fced82ad3d7d3d87be33d466a3498eb8aa856434ff3c0dfc2e2e31" dependencies = [ "serde", "serde_derive", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-clock" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb62e9381182459a4520b5fe7fb22d423cae736239a6427fc398a88743d0ed59" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids 3.1.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.1.0", ] [[package]] @@ -8424,7 +8565,7 @@ checksum = "7ace9fea2daa28354d107ea879cff107181d85cd4e0f78a2bedb10e1a428c97e" dependencies = [ "serde", "serde_derive", - "solana-hash", + "solana-hash 2.3.0", ] [[package]] @@ -8446,8 +8587,8 @@ dependencies = [ "borsh 1.5.7", "serde", "serde_derive", - "solana-instruction", - "solana-sdk-ids", + "solana-instruction 2.3.1", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -8469,12 +8610,26 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8dc71126edddc2ba014622fc32d0f5e2e78ec6c5a1e0eb511b85618c09e9ea11" dependencies = [ - "solana-account-info", - "solana-define-syscall", - "solana-instruction", - "solana-program-error", - "solana-pubkey", - "solana-stable-layout", + "solana-account-info 2.3.0", + "solana-define-syscall 2.3.0", + "solana-instruction 2.3.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-stable-layout 2.2.1", +] + +[[package]] +name = "solana-cpi" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dea26709d867aada85d0d3617db0944215c8bb28d3745b912de7db13a23280c" +dependencies = [ + "solana-account-info 3.1.0", + "solana-define-syscall 4.0.1", + "solana-instruction 3.1.0", + "solana-program-error 3.0.0", + "solana-pubkey 4.0.0", + "solana-stable-layout 3.0.0", ] [[package]] @@ -8486,7 +8641,21 @@ dependencies = [ "bytemuck", "bytemuck_derive", "curve25519-dalek 4.1.3", - "solana-define-syscall", + "solana-define-syscall 2.3.0", + "subtle", + "thiserror 2.0.17", +] + +[[package]] +name = "solana-curve25519" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbfd91a8aa99fff637999b5a944894ff2866076f331c315de21e3a1ea1edac9" +dependencies = [ + "bytemuck", + "bytemuck_derive", + "curve25519-dalek 4.1.3", + "solana-define-syscall 3.0.0", "subtle", "thiserror 2.0.17", ] @@ -8506,6 +8675,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ae3e2abcf541c8122eafe9a625d4d194b4023c20adde1e251f94e056bb1aee2" +[[package]] +name = "solana-define-syscall" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9697086a4e102d28a156b8d6b521730335d6951bd39a5e766512bbe09007cee" + +[[package]] +name = "solana-define-syscall" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5b1c0bc1d4a4d10c88a4100499d954c09d3fecfae4912c1a074dff68b1738" + [[package]] name = "solana-derivation-path" version = "2.2.1" @@ -8517,6 +8698,17 @@ dependencies = [ "uriparse", ] +[[package]] +name = "solana-derivation-path" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff71743072690fdbdfcdc37700ae1cb77485aaad49019473a81aee099b1e0b8c" +dependencies = [ + "derivation-path", + "qstring", + "uriparse", +] + [[package]] name = "solana-ed25519-program" version = "2.2.3" @@ -8527,9 +8719,9 @@ dependencies = [ "bytemuck_derive", "ed25519-dalek 1.0.1", "solana-feature-set", - "solana-instruction", + "solana-instruction 2.3.1", "solana-precompile-error", - "solana-sdk-ids", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -8550,10 +8742,24 @@ checksum = "86b575d3dd323b9ea10bb6fe89bf6bf93e249b215ba8ed7f68f1a3633f384db7" dependencies = [ "serde", "serde_derive", - "solana-hash", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-hash 2.3.0", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-epoch-rewards" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b319a4ed70390af911090c020571f0ff1f4ec432522d05ab89f5c08080381995" +dependencies = [ + "serde", + "serde_derive", + "solana-hash 3.1.0", + "solana-sdk-ids 3.1.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.1.0", ] [[package]] @@ -8563,8 +8769,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c5fd2662ae7574810904585fd443545ed2b568dbd304b25a31e79ccc76e81b" dependencies = [ "siphasher 0.3.11", - "solana-hash", - "solana-pubkey", + "solana-hash 2.3.0", + "solana-pubkey 2.4.0", ] [[package]] @@ -8575,9 +8781,22 @@ checksum = "3fce071fbddecc55d727b1d7ed16a629afe4f6e4c217bc8d00af3b785f6f67ed" dependencies = [ "serde", "serde_derive", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-epoch-schedule" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5481e72cc4d52c169db73e4c0cd16de8bc943078aac587ec4817a75cc6388f" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids 3.1.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.1.0", ] [[package]] @@ -8589,15 +8808,15 @@ dependencies = [ "serde", "serde_derive", "solana-address-lookup-table-interface", - "solana-clock", - "solana-hash", - "solana-instruction", + "solana-clock 2.2.2", + "solana-hash 2.3.0", + "solana-instruction 2.3.1", "solana-keccak-hasher", "solana-message", "solana-nonce", - "solana-pubkey", - "solana-sdk-ids", - "solana-system-interface", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-system-interface 1.0.0", "thiserror 2.0.17", ] @@ -8611,13 +8830,13 @@ dependencies = [ "serde", "serde_derive", "solana-account", - "solana-account-info", - "solana-instruction", - "solana-program-error", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-system-interface", + "solana-account-info 2.3.0", + "solana-instruction 2.3.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-system-interface 1.0.0", ] [[package]] @@ -8628,10 +8847,10 @@ checksum = "93b93971e289d6425f88e6e3cb6668c4b05df78b3c518c249be55ced8efd6b6d" dependencies = [ "ahash 0.8.12", "lazy_static", - "solana-epoch-schedule", - "solana-hash", - "solana-pubkey", - "solana-sha256-hasher", + "solana-epoch-schedule 2.2.1", + "solana-hash 2.3.0", + "solana-pubkey 2.4.0", + "solana-sha256-hasher 2.3.0", ] [[package]] @@ -8645,6 +8864,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "solana-fee-calculator" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a73cc03ca4bed871ca174558108835f8323e85917bb38b9c81c7af2ab853efe" +dependencies = [ + "log", + "serde", + "serde_derive", +] + [[package]] name = "solana-fee-structure" version = "2.3.0" @@ -8669,21 +8899,21 @@ dependencies = [ "serde", "serde_derive", "solana-account", - "solana-clock", + "solana-clock 2.2.2", "solana-cluster-type", - "solana-epoch-schedule", - "solana-fee-calculator", - "solana-hash", + "solana-epoch-schedule 2.2.1", + "solana-fee-calculator 2.2.1", + "solana-hash 2.3.0", "solana-inflation", "solana-keypair", "solana-logger", "solana-poh-config", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-sha256-hasher", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-sha256-hasher 2.3.0", "solana-shred-version", - "solana-signer", + "solana-signer 2.2.1", "solana-time-utils", ] @@ -8706,15 +8936,39 @@ dependencies = [ "borsh 1.5.7", "bytemuck", "bytemuck_derive", - "five8", + "five8 0.2.1", "js-sys", "serde", "serde_derive", - "solana-atomic-u64", - "solana-sanitize", + "solana-atomic-u64 2.2.1", + "solana-sanitize 2.2.1", "wasm-bindgen", ] +[[package]] +name = "solana-hash" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337c246447142f660f778cf6cb582beba8e28deb05b3b24bfb9ffd7c562e5f41" +dependencies = [ + "solana-hash 4.0.1", +] + +[[package]] +name = "solana-hash" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5d48a6ee7b91fc7b998944ab026ed7b3e2fc8ee3bc58452644a86c2648152f" +dependencies = [ + "bytemuck", + "bytemuck_derive", + "five8 1.0.0", + "serde", + "serde_derive", + "solana-atomic-u64 3.0.0", + "solana-sanitize 3.0.1", +] + [[package]] name = "solana-inflation" version = "2.2.1" @@ -8739,11 +8993,35 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "solana-define-syscall", - "solana-pubkey", + "solana-define-syscall 2.3.0", + "solana-pubkey 2.4.0", "wasm-bindgen", ] +[[package]] +name = "solana-instruction" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1b699a2c1518028a9982e255e0eca10c44d90006542d9d7f9f40dbce3f7c78" +dependencies = [ + "bincode", + "borsh 1.5.7", + "serde", + "solana-define-syscall 4.0.1", + "solana-instruction-error", + "solana-pubkey 4.0.0", +] + +[[package]] +name = "solana-instruction-error" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04259e03c05faf38a8c24217b5cfe4c90572ae6184ab49cddb1584fdd756d3f" +dependencies = [ + "num-traits", + "solana-program-error 3.0.0", +] + [[package]] name = "solana-instructions-sysvar" version = "2.2.2" @@ -8751,14 +9029,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0e85a6fad5c2d0c4f5b91d34b8ca47118fc593af706e523cdbedf846a954f57" dependencies = [ "bitflags 2.10.0", - "solana-account-info", - "solana-instruction", - "solana-program-error", - "solana-pubkey", - "solana-sanitize", - "solana-sdk-ids", - "solana-serialize-utils", - "solana-sysvar-id", + "solana-account-info 2.3.0", + "solana-instruction 2.3.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-serialize-utils 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-instructions-sysvar" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddf67876c541aa1e21ee1acae35c95c6fbc61119814bfef70579317a5e26955" +dependencies = [ + "bitflags 2.10.0", + "solana-account-info 3.1.0", + "solana-instruction 3.1.0", + "solana-instruction-error", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", + "solana-sdk-ids 3.1.0", + "solana-serialize-utils 3.1.0", + "solana-sysvar-id 3.1.0", ] [[package]] @@ -8768,9 +9064,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7aeb957fbd42a451b99235df4942d96db7ef678e8d5061ef34c9b34cae12f79" dependencies = [ "sha3", - "solana-define-syscall", - "solana-hash", - "solana-sanitize", + "solana-define-syscall 2.3.0", + "solana-hash 2.3.0", + "solana-sanitize 2.2.1", ] [[package]] @@ -8781,14 +9077,14 @@ checksum = "bd3f04aa1a05c535e93e121a95f66e7dcccf57e007282e8255535d24bf1e98bb" dependencies = [ "ed25519-dalek 1.0.1", "ed25519-dalek-bip32", - "five8", + "five8 0.2.1", "rand 0.7.3", - "solana-derivation-path", - "solana-pubkey", - "solana-seed-derivable", - "solana-seed-phrase", - "solana-signature", - "solana-signer", + "solana-derivation-path 2.2.1", + "solana-pubkey 2.4.0", + "solana-seed-derivable 2.2.1", + "solana-seed-phrase 2.2.1", + "solana-signature 2.3.0", + "solana-signer 2.2.1", "wasm-bindgen", ] @@ -8800,9 +9096,22 @@ checksum = "4a6360ac2fdc72e7463565cd256eedcf10d7ef0c28a1249d261ec168c1b55cdd" dependencies = [ "serde", "serde_derive", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-last-restart-slot" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcda154ec827f5fc1e4da0af3417951b7e9b8157540f81f936c4a8b1156134d0" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids 3.1.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.1.0", ] [[package]] @@ -8814,9 +9123,9 @@ dependencies = [ "serde", "serde_bytes", "serde_derive", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -8828,10 +9137,10 @@ dependencies = [ "serde", "serde_bytes", "serde_derive", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-system-interface", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-system-interface 1.0.0", ] [[package]] @@ -8843,10 +9152,10 @@ dependencies = [ "serde", "serde_bytes", "serde_derive", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-system-interface", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-system-interface 1.0.0", ] [[package]] @@ -8874,14 +9183,14 @@ dependencies = [ "serde", "serde_derive", "solana-bincode", - "solana-hash", - "solana-instruction", - "solana-pubkey", - "solana-sanitize", - "solana-sdk-ids", + "solana-hash 2.3.0", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", "solana-short-vec", - "solana-system-interface", - "solana-transaction-error", + "solana-system-interface 1.0.0", + "solana-transaction-error 2.2.1", "wasm-bindgen", ] @@ -8891,7 +9200,16 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f36a1a14399afaabc2781a1db09cb14ee4cc4ee5c7a5a3cfcc601811379a8092" dependencies = [ - "solana-define-syscall", + "solana-define-syscall 2.3.0", +] + +[[package]] +name = "solana-msg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "264275c556ea7e22b9d3f87d56305546a38d4eee8ec884f3b126236cb7dcbbb4" +dependencies = [ + "solana-define-syscall 3.0.0", ] [[package]] @@ -8908,10 +9226,10 @@ checksum = "703e22eb185537e06204a5bd9d509b948f0066f2d1d814a6f475dafb3ddf1325" dependencies = [ "serde", "serde_derive", - "solana-fee-calculator", - "solana-hash", - "solana-pubkey", - "solana-sha256-hasher", + "solana-fee-calculator 2.2.1", + "solana-hash 2.3.0", + "solana-pubkey 2.4.0", + "solana-sha256-hasher 2.3.0", ] [[package]] @@ -8921,9 +9239,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cde971a20b8dbf60144d6a84439dda86b5466e00e2843091fe731083cda614da" dependencies = [ "solana-account", - "solana-hash", + "solana-hash 2.3.0", "solana-nonce", - "solana-sdk-ids", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -8933,13 +9251,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b526398ade5dea37f1f147ce55dae49aa017a5d7326606359b0445ca8d946581" dependencies = [ "num_enum 0.7.5", - "solana-hash", + "solana-hash 2.3.0", "solana-packet", - "solana-pubkey", - "solana-sanitize", - "solana-sha256-hasher", - "solana-signature", - "solana-signer", + "solana-pubkey 2.4.0", + "solana-sanitize 2.2.1", + "solana-sha256-hasher 2.3.0", + "solana-signature 2.3.0", + "solana-signer 2.2.1", ] [[package]] @@ -8987,8 +9305,8 @@ dependencies = [ "solana-feature-set", "solana-message", "solana-precompile-error", - "solana-pubkey", - "solana-sdk-ids", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", "solana-secp256k1-program", "solana-secp256r1-program", ] @@ -8999,9 +9317,9 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81a57a24e6a4125fc69510b6774cd93402b943191b6cddad05de7281491c90fe" dependencies = [ - "solana-pubkey", - "solana-signature", - "solana-signer", + "solana-pubkey 2.4.0", + "solana-signature 2.3.0", + "solana-signer 2.2.1", ] [[package]] @@ -9029,56 +9347,56 @@ dependencies = [ "serde", "serde_bytes", "serde_derive", - "solana-account-info", + "solana-account-info 2.3.0", "solana-address-lookup-table-interface", - "solana-atomic-u64", + "solana-atomic-u64 2.2.1", "solana-big-mod-exp", "solana-bincode", "solana-blake3-hasher", - "solana-borsh", - "solana-clock", - "solana-cpi", + "solana-borsh 2.2.1", + "solana-clock 2.2.2", + "solana-cpi 2.2.1", "solana-decode-error", - "solana-define-syscall", - "solana-epoch-rewards", - "solana-epoch-schedule", + "solana-define-syscall 2.3.0", + "solana-epoch-rewards 2.2.1", + "solana-epoch-schedule 2.2.1", "solana-example-mocks", "solana-feature-gate-interface", - "solana-fee-calculator", - "solana-hash", - "solana-instruction", - "solana-instructions-sysvar", + "solana-fee-calculator 2.2.1", + "solana-hash 2.3.0", + "solana-instruction 2.3.1", + "solana-instructions-sysvar 2.2.2", "solana-keccak-hasher", - "solana-last-restart-slot", + "solana-last-restart-slot 2.2.1", "solana-loader-v2-interface", "solana-loader-v3-interface", "solana-loader-v4-interface", "solana-message", - "solana-msg", + "solana-msg 2.2.1", "solana-native-token", "solana-nonce", - "solana-program-entrypoint", - "solana-program-error", - "solana-program-memory", - "solana-program-option", - "solana-program-pack", - "solana-pubkey", - "solana-rent", - "solana-sanitize", - "solana-sdk-ids", - "solana-sdk-macro", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-program-memory 2.3.1", + "solana-program-option 2.2.1", + "solana-program-pack 2.2.1", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", "solana-secp256k1-recover", "solana-serde-varint", - "solana-serialize-utils", - "solana-sha256-hasher", + "solana-serialize-utils 2.2.1", + "solana-sha256-hasher 2.3.0", "solana-short-vec", - "solana-slot-hashes", - "solana-slot-history", - "solana-stable-layout", + "solana-slot-hashes 2.2.1", + "solana-slot-history 2.2.1", + "solana-stable-layout 2.2.1", "solana-stake-interface", - "solana-system-interface", - "solana-sysvar", - "solana-sysvar-id", + "solana-system-interface 1.0.0", + "solana-sysvar 2.3.0", + "solana-sysvar-id 2.2.1", "solana-vote-interface", "thiserror 2.0.17", "wasm-bindgen", @@ -9090,10 +9408,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32ce041b1a0ed275290a5008ee1a4a6c48f5054c8a3d78d313c08958a06aedbd" dependencies = [ - "solana-account-info", - "solana-msg", - "solana-program-error", - "solana-pubkey", + "solana-account-info 2.3.0", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-program-entrypoint" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c9b0a1ff494e05f503a08b3d51150b73aa639544631e510279d6375f290997" +dependencies = [ + "solana-account-info 3.1.0", + "solana-define-syscall 4.0.1", + "solana-program-error 3.0.0", + "solana-pubkey 4.0.0", ] [[package]] @@ -9107,9 +9437,18 @@ dependencies = [ "serde", "serde_derive", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-pubkey", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-program-error" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1af32c995a7b692a915bb7414d5f8e838450cf7c70414e763d8abcae7b51f28" +dependencies = [ + "borsh 1.5.7", ] [[package]] @@ -9118,7 +9457,16 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a5426090c6f3fd6cfdc10685322fede9ca8e5af43cd6a59e98bfe4e91671712" dependencies = [ - "solana-define-syscall", + "solana-define-syscall 2.3.0", +] + +[[package]] +name = "solana-program-memory" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4068648649653c2c50546e9a7fb761791b5ab0cda054c771bb5808d3a4b9eb52" +dependencies = [ + "solana-define-syscall 4.0.1", ] [[package]] @@ -9127,13 +9475,28 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc677a2e9bc616eda6dbdab834d463372b92848b2bfe4a1ed4e4b4adba3397d0" +[[package]] +name = "solana-program-option" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e7b4ddb464f274deb4a497712664c3b612e3f5f82471d4e47710fc4ab1c3095" + [[package]] name = "solana-program-pack" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "319f0ef15e6e12dc37c597faccb7d62525a509fec5f6975ecb9419efddeb277b" dependencies = [ - "solana-program-error", + "solana-program-error 2.2.2", +] + +[[package]] +name = "solana-program-pack" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c169359de21f6034a63ebf96d6b380980307df17a8d371344ff04a883ec4e9d0" +dependencies = [ + "solana-program-error 3.0.0", ] [[package]] @@ -9147,22 +9510,40 @@ dependencies = [ "bytemuck", "bytemuck_derive", "curve25519-dalek 4.1.3", - "five8", - "five8_const", + "five8 0.2.1", + "five8_const 0.1.4", "getrandom 0.2.16", "js-sys", "num-traits", "rand 0.8.5", "serde", "serde_derive", - "solana-atomic-u64", + "solana-atomic-u64 2.2.1", "solana-decode-error", - "solana-define-syscall", - "solana-sanitize", - "solana-sha256-hasher", + "solana-define-syscall 2.3.0", + "solana-sanitize 2.2.1", + "solana-sha256-hasher 2.3.0", "wasm-bindgen", ] +[[package]] +name = "solana-pubkey" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8909d399deb0851aa524420beeb5646b115fd253ef446e35fe4504c904da3941" +dependencies = [ + "solana-address 1.1.0", +] + +[[package]] +name = "solana-pubkey" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6f7104d456b58e1418c21a8581e89810278d1190f70f27ece7fc0b2c9282a57" +dependencies = [ + "solana-address 2.0.0", +] + [[package]] name = "solana-quic-definitions" version = "2.3.1" @@ -9180,9 +9561,22 @@ checksum = "d1aea8fdea9de98ca6e8c2da5827707fb3842833521b528a713810ca685d2480" dependencies = [ "serde", "serde_derive", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-rent" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b702d8c43711e3c8a9284a4f1bbc6a3de2553deb25b0c8142f9a44ef0ce5ddc1" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids 3.1.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.1.0", ] [[package]] @@ -9194,12 +9588,12 @@ dependencies = [ "serde", "serde_derive", "solana-account", - "solana-clock", - "solana-epoch-schedule", + "solana-clock 2.2.2", + "solana-epoch-schedule 2.2.1", "solana-genesis-config", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -9208,7 +9602,7 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f6f9113c6003492e74438d1288e30cffa8ccfdc2ef7b49b9e816d8034da18cd" dependencies = [ - "solana-pubkey", + "solana-pubkey 2.4.0", "solana-reward-info", ] @@ -9220,8 +9614,8 @@ checksum = "e4b22ea19ca2a3f28af7cd047c914abf833486bf7a7c4a10fc652fff09b385b1" dependencies = [ "lazy_static", "solana-feature-set", - "solana-pubkey", - "solana-sdk-ids", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -9240,6 +9634,12 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61f1bc1357b8188d9c4a3af3fc55276e56987265eb7ad073ae6f8180ee54cecf" +[[package]] +name = "solana-sanitize" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9" + [[package]] name = "solana-sdk" version = "2.3.1" @@ -9259,7 +9659,7 @@ dependencies = [ "solana-commitment-config", "solana-compute-budget-interface", "solana-decode-error", - "solana-derivation-path", + "solana-derivation-path 2.2.1", "solana-ed25519-program", "solana-epoch-info", "solana-epoch-rewards-hasher", @@ -9268,7 +9668,7 @@ dependencies = [ "solana-genesis-config", "solana-hard-forks", "solana-inflation", - "solana-instruction", + "solana-instruction 2.3.1", "solana-keypair", "solana-message", "solana-native-token", @@ -9280,32 +9680,32 @@ dependencies = [ "solana-precompiles", "solana-presigner", "solana-program", - "solana-program-memory", - "solana-pubkey", + "solana-program-memory 2.3.1", + "solana-pubkey 2.4.0", "solana-quic-definitions", "solana-rent-collector", "solana-rent-debits", "solana-reserved-account-keys", "solana-reward-info", - "solana-sanitize", - "solana-sdk-ids", - "solana-sdk-macro", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", "solana-secp256k1-program", "solana-secp256k1-recover", "solana-secp256r1-program", - "solana-seed-derivable", - "solana-seed-phrase", + "solana-seed-derivable 2.2.1", + "solana-seed-phrase 2.2.1", "solana-serde", "solana-serde-varint", "solana-short-vec", "solana-shred-version", - "solana-signature", - "solana-signer", + "solana-signature 2.3.0", + "solana-signer 2.2.1", "solana-system-transaction", "solana-time-utils", "solana-transaction", "solana-transaction-context", - "solana-transaction-error", + "solana-transaction-error 2.2.1", "solana-validator-exit", "thiserror 2.0.17", "wasm-bindgen", @@ -9317,7 +9717,16 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c5d8b9cc68d5c88b062a33e23a6466722467dde0035152d8fb1afbcdf350a5f" dependencies = [ - "solana-pubkey", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-sdk-ids" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def234c1956ff616d46c9dd953f251fa7096ddbaa6d52b165218de97882b7280" +dependencies = [ + "solana-address 2.0.0", ] [[package]] @@ -9332,6 +9741,18 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "solana-sdk-macro" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6430000e97083460b71d9fbadc52a2ab2f88f53b3a4c5e58c5ae3640a0e8c00" +dependencies = [ + "bs58 0.5.1", + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "solana-secp256k1-program" version = "2.2.3" @@ -9345,10 +9766,10 @@ dependencies = [ "serde_derive", "sha3", "solana-feature-set", - "solana-instruction", + "solana-instruction 2.3.1", "solana-precompile-error", - "solana-sdk-ids", - "solana-signature", + "solana-sdk-ids 2.2.1", + "solana-signature 2.3.0", ] [[package]] @@ -9359,7 +9780,7 @@ checksum = "baa3120b6cdaa270f39444f5093a90a7b03d296d362878f7a6991d6de3bbe496" dependencies = [ "borsh 1.5.7", "libsecp256k1 0.6.0", - "solana-define-syscall", + "solana-define-syscall 2.3.0", "thiserror 2.0.17", ] @@ -9372,9 +9793,9 @@ dependencies = [ "bytemuck", "openssl", "solana-feature-set", - "solana-instruction", + "solana-instruction 2.3.1", "solana-precompile-error", - "solana-sdk-ids", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -9389,7 +9810,16 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beb82b5adb266c6ea90e5cf3967235644848eac476c5a1f2f9283a143b7c97f" dependencies = [ - "solana-derivation-path", + "solana-derivation-path 2.2.1", +] + +[[package]] +name = "solana-seed-derivable" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7bdb72758e3bec33ed0e2658a920f1f35dfb9ed576b951d20d63cb61ecd95c" +dependencies = [ + "solana-derivation-path 3.0.0", ] [[package]] @@ -9403,6 +9833,17 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "solana-seed-phrase" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc905b200a95f2ea9146e43f2a7181e3aeb55de6bc12afb36462d00a3c7310de" +dependencies = [ + "hmac 0.12.1", + "pbkdf2", + "sha2 0.10.9", +] + [[package]] name = "solana-serde" version = "2.2.1" @@ -9427,9 +9868,20 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "817a284b63197d2b27afdba829c5ab34231da4a9b4e763466a003c40ca4f535e" dependencies = [ - "solana-instruction", - "solana-pubkey", - "solana-sanitize", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", + "solana-sanitize 2.2.1", +] + +[[package]] +name = "solana-serialize-utils" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e41dd8feea239516c623a02f0a81c2367f4b604d7965237fed0751aeec33ed" +dependencies = [ + "solana-instruction-error", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", ] [[package]] @@ -9439,8 +9891,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5aa3feb32c28765f6aa1ce8f3feac30936f16c5c3f7eb73d63a5b8f6f8ecdc44" dependencies = [ "sha2 0.10.9", - "solana-define-syscall", - "solana-hash", + "solana-define-syscall 2.3.0", + "solana-hash 2.3.0", +] + +[[package]] +name = "solana-sha256-hasher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7dc3011ea4c0334aaaa7e7128cb390ecf546b28d412e9bf2064680f57f588f" +dependencies = [ + "sha2 0.10.9", + "solana-define-syscall 4.0.1", + "solana-hash 4.0.1", ] [[package]] @@ -9459,8 +9922,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afd3db0461089d1ad1a78d9ba3f15b563899ca2386351d38428faa5350c60a98" dependencies = [ "solana-hard-forks", - "solana-hash", - "solana-sha256-hasher", + "solana-hash 2.3.0", + "solana-sha256-hasher 2.3.0", ] [[package]] @@ -9470,12 +9933,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64c8ec8e657aecfc187522fc67495142c12f35e55ddeca8698edbb738b8dbd8c" dependencies = [ "ed25519-dalek 1.0.1", - "five8", + "five8 0.2.1", "rand 0.8.5", "serde", "serde-big-array", "serde_derive", - "solana-sanitize", + "solana-sanitize 2.2.1", +] + +[[package]] +name = "solana-signature" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb8057cc0e9f7b5e89883d49de6f407df655bb6f3a71d0b7baf9986a2218fd9" +dependencies = [ + "five8 0.2.1", + "solana-sanitize 3.0.1", ] [[package]] @@ -9484,9 +9957,20 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c41991508a4b02f021c1342ba00bcfa098630b213726ceadc7cb032e051975b" dependencies = [ - "solana-pubkey", - "solana-signature", - "solana-transaction-error", + "solana-pubkey 2.4.0", + "solana-signature 2.3.0", + "solana-transaction-error 2.2.1", +] + +[[package]] +name = "solana-signer" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bfea97951fee8bae0d6038f39a5efcb6230ecdfe33425ac75196d1a1e3e3235" +dependencies = [ + "solana-pubkey 3.0.0", + "solana-signature 3.1.0", + "solana-transaction-error 3.0.0", ] [[package]] @@ -9497,9 +9981,22 @@ checksum = "0c8691982114513763e88d04094c9caa0376b867a29577939011331134c301ce" dependencies = [ "serde", "serde_derive", - "solana-hash", - "solana-sdk-ids", - "solana-sysvar-id", + "solana-hash 2.3.0", + "solana-sdk-ids 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-slot-hashes" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a293f952293281443c04f4d96afd9d547721923d596e92b4377ed2360f1746" +dependencies = [ + "serde", + "serde_derive", + "solana-hash 3.1.0", + "solana-sdk-ids 3.1.0", + "solana-sysvar-id 3.1.0", ] [[package]] @@ -9511,8 +10008,21 @@ dependencies = [ "bv", "serde", "serde_derive", - "solana-sdk-ids", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-slot-history" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f914f6b108f5bba14a280b458d023e3621c9973f27f015a4d755b50e88d89e97" +dependencies = [ + "bv", + "serde", + "serde_derive", + "solana-sdk-ids 3.1.0", + "solana-sysvar-id 3.1.0", ] [[package]] @@ -9521,8 +10031,18 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f14f7d02af8f2bc1b5efeeae71bc1c2b7f0f65cd75bcc7d8180f2c762a57f54" dependencies = [ - "solana-instruction", - "solana-pubkey", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-stable-layout" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1da74507795b6e8fb60b7c7306c0c36e2c315805d16eaaf479452661234685ac" +dependencies = [ + "solana-instruction 3.1.0", + "solana-pubkey 3.0.0", ] [[package]] @@ -9536,14 +10056,14 @@ dependencies = [ "num-traits", "serde", "serde_derive", - "solana-clock", - "solana-cpi", + "solana-clock 2.2.2", + "solana-cpi 2.2.1", "solana-decode-error", - "solana-instruction", - "solana-program-error", - "solana-pubkey", - "solana-system-interface", - "solana-sysvar-id", + "solana-instruction 2.3.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-system-interface 1.0.0", + "solana-sysvar-id 2.2.1", ] [[package]] @@ -9563,23 +10083,38 @@ dependencies = [ "serde", "serde_derive", "solana-decode-error", - "solana-instruction", - "solana-pubkey", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", "wasm-bindgen", ] +[[package]] +name = "solana-system-interface" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1790547bfc3061f1ee68ea9d8dc6c973c02a163697b24263a8e9f2e6d4afa2" +dependencies = [ + "num-traits", + "serde", + "serde_derive", + "solana-instruction 3.1.0", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", +] + [[package]] name = "solana-system-transaction" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bd98a25e5bcba8b6be8bcbb7b84b24c2a6a8178d7fb0e3077a916855ceba91a" dependencies = [ - "solana-hash", + "solana-hash 2.3.0", "solana-keypair", "solana-message", - "solana-pubkey", - "solana-signer", - "solana-system-interface", + "solana-pubkey 2.4.0", + "solana-signer 2.2.1", + "solana-system-interface 1.0.0", "solana-transaction", ] @@ -9596,28 +10131,60 @@ dependencies = [ "lazy_static", "serde", "serde_derive", - "solana-account-info", - "solana-clock", - "solana-define-syscall", - "solana-epoch-rewards", - "solana-epoch-schedule", - "solana-fee-calculator", - "solana-hash", - "solana-instruction", - "solana-instructions-sysvar", - "solana-last-restart-slot", - "solana-program-entrypoint", - "solana-program-error", - "solana-program-memory", - "solana-pubkey", - "solana-rent", - "solana-sanitize", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-slot-hashes", - "solana-slot-history", + "solana-account-info 2.3.0", + "solana-clock 2.2.2", + "solana-define-syscall 2.3.0", + "solana-epoch-rewards 2.2.1", + "solana-epoch-schedule 2.2.1", + "solana-fee-calculator 2.2.1", + "solana-hash 2.3.0", + "solana-instruction 2.3.1", + "solana-instructions-sysvar 2.2.2", + "solana-last-restart-slot 2.2.1", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-program-memory 2.3.1", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-slot-hashes 2.2.1", + "solana-slot-history 2.2.1", "solana-stake-interface", - "solana-sysvar-id", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-sysvar" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3205cc7db64a0f1a20b7eb2405773fa64e45f7fe0fc7a73e50e90eca6b2b0be7" +dependencies = [ + "base64 0.22.1", + "bincode", + "lazy_static", + "serde", + "serde_derive", + "solana-account-info 3.1.0", + "solana-clock 3.0.0", + "solana-define-syscall 4.0.1", + "solana-epoch-rewards 3.0.0", + "solana-epoch-schedule 3.0.0", + "solana-fee-calculator 3.0.0", + "solana-hash 4.0.1", + "solana-instruction 3.1.0", + "solana-last-restart-slot 3.0.0", + "solana-program-entrypoint 3.1.1", + "solana-program-error 3.0.0", + "solana-program-memory 3.1.0", + "solana-pubkey 4.0.0", + "solana-rent 3.0.0", + "solana-sdk-ids 3.1.0", + "solana-sdk-macro 3.0.0", + "solana-slot-hashes 3.0.0", + "solana-slot-history 3.0.0", + "solana-sysvar-id 3.1.0", ] [[package]] @@ -9626,8 +10193,18 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5762b273d3325b047cfda250787f8d796d781746860d5d0a746ee29f3e8812c1" dependencies = [ - "solana-pubkey", - "solana-sdk-ids", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", +] + +[[package]] +name = "solana-sysvar-id" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17358d1e9a13e5b9c2264d301102126cf11a47fd394cdf3dec174fe7bc96e1de" +dependencies = [ + "solana-address 2.0.0", + "solana-sdk-ids 3.1.0", ] [[package]] @@ -9647,19 +10224,19 @@ dependencies = [ "serde_derive", "solana-bincode", "solana-feature-set", - "solana-hash", - "solana-instruction", + "solana-hash 2.3.0", + "solana-instruction 2.3.1", "solana-keypair", "solana-message", "solana-precompiles", - "solana-pubkey", - "solana-sanitize", - "solana-sdk-ids", + "solana-pubkey 2.4.0", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", "solana-short-vec", - "solana-signature", - "solana-signer", - "solana-system-interface", - "solana-transaction-error", + "solana-signature 2.3.0", + "solana-signer 2.2.1", + "solana-system-interface 1.0.0", + "solana-transaction-error 2.2.1", "wasm-bindgen", ] @@ -9673,11 +10250,11 @@ dependencies = [ "serde", "serde_derive", "solana-account", - "solana-instruction", - "solana-instructions-sysvar", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", + "solana-instruction 2.3.1", + "solana-instructions-sysvar 2.2.2", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -9688,8 +10265,18 @@ checksum = "222a9dc8fdb61c6088baab34fc3a8b8473a03a7a5fd404ed8dd502fa79b67cb1" dependencies = [ "serde", "serde_derive", - "solana-instruction", - "solana-sanitize", + "solana-instruction 2.3.1", + "solana-sanitize 2.2.1", +] + +[[package]] +name = "solana-transaction-error" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4222065402340d7e6aec9dc3e54d22992ddcf923d91edcd815443c2bfca3144a" +dependencies = [ + "solana-instruction-error", + "solana-sanitize 3.0.1", ] [[package]] @@ -9710,21 +10297,21 @@ dependencies = [ "serde_json", "solana-account-decoder", "solana-address-lookup-table-interface", - "solana-clock", - "solana-hash", - "solana-instruction", + "solana-clock 2.2.2", + "solana-hash 2.3.0", + "solana-instruction 2.3.1", "solana-loader-v2-interface", "solana-loader-v3-interface", "solana-message", - "solana-program-option", - "solana-pubkey", + "solana-program-option 2.2.1", + "solana-pubkey 2.4.0", "solana-reward-info", - "solana-sdk-ids", - "solana-signature", + "solana-sdk-ids 2.2.1", + "solana-signature 2.3.0", "solana-stake-interface", - "solana-system-interface", + "solana-system-interface 1.0.0", "solana-transaction", - "solana-transaction-error", + "solana-transaction-error 2.2.1", "solana-transaction-status-client-types", "solana-vote-interface", "spl-associated-token-account 7.0.0", @@ -9752,10 +10339,10 @@ dependencies = [ "solana-commitment-config", "solana-message", "solana-reward-info", - "solana-signature", + "solana-signature 2.3.0", "solana-transaction", "solana-transaction-context", - "solana-transaction-error", + "solana-transaction-error 2.2.1", "thiserror 2.0.17", ] @@ -9776,17 +10363,17 @@ dependencies = [ "num-traits", "serde", "serde_derive", - "solana-clock", + "solana-clock 2.2.2", "solana-decode-error", - "solana-hash", - "solana-instruction", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", + "solana-hash 2.3.0", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", "solana-serde-varint", - "solana-serialize-utils", + "solana-serialize-utils 2.2.1", "solana-short-vec", - "solana-system-interface", + "solana-system-interface 1.0.0", ] [[package]] @@ -9811,14 +10398,51 @@ dependencies = [ "serde_derive", "serde_json", "sha3", - "solana-derivation-path", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-seed-derivable", - "solana-seed-phrase", - "solana-signature", - "solana-signer", + "solana-derivation-path 2.2.1", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-seed-derivable 2.2.1", + "solana-seed-phrase 2.2.1", + "solana-signature 2.3.0", + "solana-signer 2.2.1", + "subtle", + "thiserror 2.0.17", + "wasm-bindgen", + "zeroize", +] + +[[package]] +name = "solana-zk-sdk" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9602bcb1f7af15caef92b91132ec2347e1c51a72ecdbefdaefa3eac4b8711475" +dependencies = [ + "aes-gcm-siv", + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek 4.1.3", + "getrandom 0.2.16", + "itertools 0.12.1", + "js-sys", + "merlin", + "num-derive", + "num-traits", + "rand 0.8.5", + "serde", + "serde_derive", + "serde_json", + "sha3", + "solana-derivation-path 3.0.0", + "solana-instruction 3.1.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.1.0", + "solana-seed-derivable 3.0.0", + "solana-seed-phrase 3.0.0", + "solana-signature 3.1.0", + "solana-signer 3.0.0", "subtle", "thiserror 2.0.17", "wasm-bindgen", @@ -9916,8 +10540,8 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f8349dbcbe575f354f9a533a21f272f3eb3808a49e2fdc1c34393b88ba76cb" dependencies = [ - "solana-instruction", - "solana-pubkey", + "solana-instruction 2.3.1", + "solana-pubkey 2.4.0", ] [[package]] @@ -9927,8 +10551,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7398da23554a31660f17718164e31d31900956054f54f52d5ec1be51cb4f4b3" dependencies = [ "bytemuck", - "solana-program-error", - "solana-sha256-hasher", + "solana-program-error 2.2.2", + "solana-sha256-hasher 2.3.0", + "spl-discriminator-derive", +] + +[[package]] +name = "spl-discriminator" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cc11459e265d5b501534144266620289720b4c44522a47bc6b63cd295d2f3" +dependencies = [ + "bytemuck", + "solana-program-error 3.0.0", + "solana-sha256-hasher 3.1.0", "spl-discriminator-derive", ] @@ -9964,8 +10600,8 @@ checksum = "ce0f668975d2b0536e8a8fd60e56a05c467f06021dae037f1d0cfed0de2e231d" dependencies = [ "bytemuck", "solana-program", - "solana-zk-sdk", - "spl-pod", + "solana-zk-sdk 2.3.13", + "spl-pod 0.5.1", "spl-token-confidential-transfer-proof-extraction 0.2.1", ] @@ -9976,19 +10612,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65edfeed09cd4231e595616aa96022214f9c9d2be02dea62c2b30d5695a6833a" dependencies = [ "bytemuck", - "solana-account-info", - "solana-cpi", - "solana-instruction", - "solana-msg", - "solana-program-entrypoint", - "solana-program-error", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-system-interface", - "solana-sysvar", - "solana-zk-sdk", - "spl-pod", + "solana-account-info 2.3.0", + "solana-cpi 2.2.1", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-system-interface 1.0.0", + "solana-sysvar 2.3.0", + "solana-zk-sdk 2.3.13", + "spl-pod 0.5.1", "spl-token-confidential-transfer-proof-extraction 0.3.0", ] @@ -9999,23 +10635,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56cc66fe64651a48c8deb4793d8a5deec8f8faf19f355b9df294387bc5a36b5f" dependencies = [ "bytemuck", - "solana-account-info", - "solana-cpi", - "solana-instruction", - "solana-msg", - "solana-program-entrypoint", - "solana-program-error", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", + "solana-account-info 2.3.0", + "solana-cpi 2.2.1", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", "solana-security-txt", - "solana-system-interface", - "solana-sysvar", - "solana-zk-sdk", - "spl-pod", + "solana-system-interface 1.0.0", + "solana-sysvar 2.3.0", + "solana-zk-sdk 2.3.13", + "spl-pod 0.5.1", "spl-token-confidential-transfer-proof-extraction 0.4.1", ] +[[package]] +name = "spl-elgamal-registry-interface" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "065f54100d118d24036283e03120b2f60cb5b7d597d3db649e13690e22d41398" +dependencies = [ + "bytemuck", + "solana-instruction 3.1.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.1.0", + "solana-zk-sdk 4.0.0", + "spl-token-confidential-transfer-proof-extraction 0.5.1", +] + [[package]] name = "spl-generic-token" version = "1.0.1" @@ -10023,7 +10674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "741a62a566d97c58d33f9ed32337ceedd4e35109a686e31b1866c5dfa56abddc" dependencies = [ "bytemuck", - "solana-pubkey", + "solana-pubkey 2.4.0", ] [[package]] @@ -10032,12 +10683,22 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f09647c0974e33366efeb83b8e2daebb329f0420149e74d3a4bd2c08cf9f7cb" dependencies = [ - "solana-account-info", - "solana-instruction", - "solana-msg", - "solana-program-entrypoint", - "solana-program-error", - "solana-pubkey", + "solana-account-info 2.3.0", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "spl-memo-interface" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4e2aedd58f858337fa609af5ad7100d4a243fdaf6a40d6eb4c28c5f19505d3" +dependencies = [ + "solana-instruction 3.1.0", + "solana-pubkey 3.0.0", ] [[package]] @@ -10052,11 +10713,30 @@ dependencies = [ "num-derive", "num-traits", "solana-decode-error", - "solana-msg", - "solana-program-error", - "solana-program-option", - "solana-pubkey", - "solana-zk-sdk", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-program-option 2.2.1", + "solana-pubkey 2.4.0", + "solana-zk-sdk 2.3.13", + "thiserror 2.0.17", +] + +[[package]] +name = "spl-pod" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1233fdecd7461611d69bb87bc2e95af742df47291975d21232a0be8217da9de" +dependencies = [ + "borsh 1.5.7", + "bytemuck", + "bytemuck_derive", + "num-derive", + "num-traits", + "num_enum 0.7.5", + "solana-program-error 3.0.0", + "solana-program-option 3.0.0", + "solana-pubkey 3.0.0", + "solana-zk-sdk 4.0.0", "thiserror 2.0.17", ] @@ -10082,12 +10762,27 @@ dependencies = [ "num-derive", "num-traits", "solana-decode-error", - "solana-msg", - "solana-program-error", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", "spl-program-error-derive 0.5.0", "thiserror 2.0.17", ] +[[package]] +name = "spl-program-error" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c4f6cf26cb6768110bf024bc7224326c720d711f7ad25d16f40f6cee40edb2d" +dependencies = [ + "num-derive", + "num-traits", + "num_enum 0.7.5", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", + "spl-program-error-derive 0.6.0", + "thiserror 2.0.17", +] + [[package]] name = "spl-program-error-derive" version = "0.4.1" @@ -10112,6 +10807,18 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "spl-program-error-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ec8965aa4dc6c74701cbb48b9cad5af35b9a394514934949edbb357b78f840d" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.9", + "syn 2.0.108", +] + [[package]] name = "spl-stake-pool" version = "2.0.3" @@ -10130,8 +10837,8 @@ dependencies = [ "solana-program", "solana-security-txt", "solana-stake-interface", - "solana-system-interface", - "spl-pod", + "solana-system-interface 1.0.0", + "spl-pod 0.5.1", "spl-token-2022 9.0.0", "thiserror 2.0.17", ] @@ -10145,14 +10852,14 @@ dependencies = [ "bytemuck", "num-derive", "num-traits", - "solana-account-info", + "solana-account-info 2.3.0", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "spl-program-error 0.6.0", "spl-type-length-value 0.7.0", "thiserror 1.0.69", @@ -10167,19 +10874,40 @@ dependencies = [ "bytemuck", "num-derive", "num-traits", - "solana-account-info", + "solana-account-info 2.3.0", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "spl-program-error 0.7.0", "spl-type-length-value 0.8.0", "thiserror 2.0.17", ] +[[package]] +name = "spl-tlv-account-resolution" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6927f613c9d7ce20835d3cefb602137cab2518e383a047c0eaa58054a60644c8" +dependencies = [ + "bytemuck", + "num-derive", + "num-traits", + "num_enum 0.7.5", + "solana-account-info 3.1.0", + "solana-instruction 3.1.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "spl-discriminator 0.5.1", + "spl-pod 0.7.1", + "spl-program-error 0.8.0", + "spl-type-length-value 0.9.0", + "thiserror 2.0.17", +] + [[package]] name = "spl-token" version = "7.0.0" @@ -10206,20 +10934,20 @@ dependencies = [ "num-derive", "num-traits", "num_enum 0.7.5", - "solana-account-info", - "solana-cpi", + "solana-account-info 2.3.0", + "solana-cpi 2.2.1", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-entrypoint", - "solana-program-error", - "solana-program-memory", - "solana-program-option", - "solana-program-pack", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-sysvar", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-program-memory 2.3.1", + "solana-program-option 2.2.1", + "solana-program-pack 2.2.1", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-sysvar 2.3.0", "thiserror 2.0.17", ] @@ -10236,10 +10964,10 @@ dependencies = [ "num_enum 0.7.5", "solana-program", "solana-security-txt", - "solana-zk-sdk", + "solana-zk-sdk 2.3.13", "spl-elgamal-registry 0.1.1", "spl-memo", - "spl-pod", + "spl-pod 0.5.1", "spl-token 7.0.0", "spl-token-confidential-transfer-ciphertext-arithmetic 0.2.1", "spl-token-confidential-transfer-proof-extraction 0.2.1", @@ -10262,28 +10990,28 @@ dependencies = [ "num-derive", "num-traits", "num_enum 0.7.5", - "solana-account-info", - "solana-clock", - "solana-cpi", + "solana-account-info 2.3.0", + "solana-clock 2.2.2", + "solana-cpi 2.2.1", "solana-decode-error", - "solana-instruction", - "solana-msg", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", "solana-native-token", - "solana-program-entrypoint", - "solana-program-error", - "solana-program-memory", - "solana-program-option", - "solana-program-pack", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-program-memory 2.3.1", + "solana-program-option 2.2.1", + "solana-program-pack 2.2.1", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", "solana-security-txt", - "solana-system-interface", - "solana-sysvar", - "solana-zk-sdk", + "solana-system-interface 1.0.0", + "solana-sysvar 2.3.0", + "solana-zk-sdk 2.3.13", "spl-elgamal-registry 0.2.0", "spl-memo", - "spl-pod", + "spl-pod 0.5.1", "spl-token 8.0.0", "spl-token-confidential-transfer-ciphertext-arithmetic 0.3.1", "spl-token-confidential-transfer-proof-extraction 0.3.0", @@ -10306,28 +11034,28 @@ dependencies = [ "num-derive", "num-traits", "num_enum 0.7.5", - "solana-account-info", - "solana-clock", - "solana-cpi", + "solana-account-info 2.3.0", + "solana-clock 2.2.2", + "solana-cpi 2.2.1", "solana-decode-error", - "solana-instruction", - "solana-msg", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", "solana-native-token", - "solana-program-entrypoint", - "solana-program-error", - "solana-program-memory", - "solana-program-option", - "solana-program-pack", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-program-memory 2.3.1", + "solana-program-option 2.2.1", + "solana-program-pack 2.2.1", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", "solana-security-txt", - "solana-system-interface", - "solana-sysvar", - "solana-zk-sdk", + "solana-system-interface 1.0.0", + "solana-sysvar 2.3.0", + "solana-zk-sdk 2.3.13", "spl-elgamal-registry 0.3.0", "spl-memo", - "spl-pod", + "spl-pod 0.5.1", "spl-token 8.0.0", "spl-token-confidential-transfer-ciphertext-arithmetic 0.3.1", "spl-token-confidential-transfer-proof-extraction 0.4.1", @@ -10339,6 +11067,75 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "spl-token-2022" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552427d9117528d037daa0e70416d51322c8a33241317210f230304d852be61e" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum 0.7.5", + "solana-account-info 3.1.0", + "solana-clock 3.0.0", + "solana-cpi 3.1.0", + "solana-instruction 3.1.0", + "solana-msg 3.0.0", + "solana-program-entrypoint 3.1.1", + "solana-program-error 3.0.0", + "solana-program-memory 3.1.0", + "solana-program-option 3.0.0", + "solana-program-pack 3.0.0", + "solana-pubkey 3.0.0", + "solana-rent 3.0.0", + "solana-sdk-ids 3.1.0", + "solana-security-txt", + "solana-system-interface 2.0.0", + "solana-sysvar 3.1.0", + "solana-zk-sdk 4.0.0", + "spl-elgamal-registry-interface", + "spl-memo-interface", + "spl-pod 0.7.1", + "spl-token-2022-interface", + "spl-token-confidential-transfer-ciphertext-arithmetic 0.4.1", + "spl-token-confidential-transfer-proof-extraction 0.5.1", + "spl-token-confidential-transfer-proof-generation 0.5.1", + "spl-token-group-interface 0.7.1", + "spl-token-metadata-interface 0.8.0", + "spl-transfer-hook-interface 2.1.0", + "thiserror 2.0.17", +] + +[[package]] +name = "spl-token-2022-interface" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcd81188211f4b3c8a5eba7fd534c7142f9dd026123b3472492782cc72f4dc6" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum 0.7.5", + "solana-account-info 3.1.0", + "solana-instruction 3.1.0", + "solana-program-error 3.0.0", + "solana-program-option 3.0.0", + "solana-program-pack 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.1.0", + "solana-zk-sdk 4.0.0", + "spl-pod 0.7.1", + "spl-token-confidential-transfer-proof-extraction 0.5.1", + "spl-token-confidential-transfer-proof-generation 0.5.1", + "spl-token-group-interface 0.7.1", + "spl-token-metadata-interface 0.8.0", + "spl-type-length-value 0.9.0", + "thiserror 2.0.17", +] + [[package]] name = "spl-token-confidential-transfer-ciphertext-arithmetic" version = "0.2.1" @@ -10347,8 +11144,8 @@ checksum = "170378693c5516090f6d37ae9bad2b9b6125069be68d9acd4865bbe9fc8499fd" dependencies = [ "base64 0.22.1", "bytemuck", - "solana-curve25519", - "solana-zk-sdk", + "solana-curve25519 2.3.13", + "solana-zk-sdk 2.3.13", ] [[package]] @@ -10359,8 +11156,20 @@ checksum = "cddd52bfc0f1c677b41493dafa3f2dbbb4b47cf0990f08905429e19dc8289b35" dependencies = [ "base64 0.22.1", "bytemuck", - "solana-curve25519", - "solana-zk-sdk", + "solana-curve25519 2.3.13", + "solana-zk-sdk 2.3.13", +] + +[[package]] +name = "spl-token-confidential-transfer-ciphertext-arithmetic" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afbeb07f737d868f145512a4bcf9f59da275b7a3483df0add3f71eb812b689fb" +dependencies = [ + "base64 0.22.1", + "bytemuck", + "solana-curve25519 3.1.2", + "solana-zk-sdk 4.0.0", ] [[package]] @@ -10370,10 +11179,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eff2d6a445a147c9d6dd77b8301b1e116c8299601794b558eafa409b342faf96" dependencies = [ "bytemuck", - "solana-curve25519", + "solana-curve25519 2.3.13", "solana-program", - "solana-zk-sdk", - "spl-pod", + "solana-zk-sdk 2.3.13", + "spl-pod 0.5.1", "thiserror 2.0.17", ] @@ -10384,16 +11193,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe2629860ff04c17bafa9ba4bed8850a404ecac81074113e1f840dbd0ebb7bd6" dependencies = [ "bytemuck", - "solana-account-info", - "solana-curve25519", - "solana-instruction", - "solana-instructions-sysvar", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "solana-sdk-ids", - "solana-zk-sdk", - "spl-pod", + "solana-account-info 2.3.0", + "solana-curve25519 2.3.13", + "solana-instruction 2.3.1", + "solana-instructions-sysvar 2.2.2", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-zk-sdk 2.3.13", + "spl-pod 0.5.1", "thiserror 2.0.17", ] @@ -10404,16 +11213,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512c85bdbbb4cbcc2038849a9e164c958b16541f252b53ea1a3933191c0a4a1a" dependencies = [ "bytemuck", - "solana-account-info", - "solana-curve25519", - "solana-instruction", - "solana-instructions-sysvar", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "solana-sdk-ids", - "solana-zk-sdk", - "spl-pod", + "solana-account-info 2.3.0", + "solana-curve25519 2.3.13", + "solana-instruction 2.3.1", + "solana-instructions-sysvar 2.2.2", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-zk-sdk 2.3.13", + "spl-pod 0.5.1", + "thiserror 2.0.17", +] + +[[package]] +name = "spl-token-confidential-transfer-proof-extraction" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879a9ebad0d77383d3ea71e7de50503554961ff0f4ef6cbca39ad126e6f6da3a" +dependencies = [ + "bytemuck", + "solana-account-info 3.1.0", + "solana-curve25519 3.1.2", + "solana-instruction 3.1.0", + "solana-instructions-sysvar 3.0.0", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.1.0", + "solana-zk-sdk 4.0.0", + "spl-pod 0.7.1", "thiserror 2.0.17", ] @@ -10424,7 +11253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8627184782eec1894de8ea26129c61303f1f0adeed65c20e0b10bc584f09356d" dependencies = [ "curve25519-dalek 4.1.3", - "solana-zk-sdk", + "solana-zk-sdk 2.3.13", "thiserror 1.0.69", ] @@ -10435,7 +11264,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa27b9174bea869a7ebf31e0be6890bce90b1a4288bc2bbf24bd413f80ae3fde" dependencies = [ "curve25519-dalek 4.1.3", - "solana-zk-sdk", + "solana-zk-sdk 2.3.13", + "thiserror 2.0.17", +] + +[[package]] +name = "spl-token-confidential-transfer-proof-generation" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0cd59fce3dc00f563c6fa364d67c3f200d278eae681f4dc250240afcfe044b1" +dependencies = [ + "curve25519-dalek 4.1.3", + "solana-zk-sdk 4.0.0", "thiserror 2.0.17", ] @@ -10449,12 +11289,12 @@ dependencies = [ "num-derive", "num-traits", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "thiserror 1.0.69", ] @@ -10468,12 +11308,30 @@ dependencies = [ "num-derive", "num-traits", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", + "thiserror 2.0.17", +] + +[[package]] +name = "spl-token-group-interface" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "452d0f758af20caaa10d9a6f7608232e000d4c74462f248540b3d2ddfa419776" +dependencies = [ + "bytemuck", + "num-derive", + "num-traits", + "num_enum 0.7.5", + "solana-instruction 3.1.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "spl-discriminator 0.5.1", + "spl-pod 0.7.1", "thiserror 2.0.17", ] @@ -10486,14 +11344,14 @@ dependencies = [ "borsh 1.5.7", "num-derive", "num-traits", - "solana-borsh", + "solana-borsh 2.2.1", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "spl-type-length-value 0.7.0", "thiserror 1.0.69", ] @@ -10507,18 +11365,37 @@ dependencies = [ "borsh 1.5.7", "num-derive", "num-traits", - "solana-borsh", + "solana-borsh 2.2.1", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "spl-type-length-value 0.8.0", "thiserror 2.0.17", ] +[[package]] +name = "spl-token-metadata-interface" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c467c7c3bd056f8fe60119e7ec34ddd6f23052c2fa8f1f51999098063b72676" +dependencies = [ + "borsh 1.5.7", + "num-derive", + "num-traits", + "solana-borsh 3.0.0", + "solana-instruction 3.1.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "spl-discriminator 0.5.1", + "spl-pod 0.7.1", + "spl-type-length-value 0.9.0", + "thiserror 2.0.17", +] + [[package]] name = "spl-transfer-hook-interface" version = "0.9.0" @@ -10529,15 +11406,15 @@ dependencies = [ "bytemuck", "num-derive", "num-traits", - "solana-account-info", - "solana-cpi", + "solana-account-info 2.3.0", + "solana-cpi 2.2.1", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "spl-program-error 0.6.0", "spl-tlv-account-resolution 0.9.0", "spl-type-length-value 0.7.0", @@ -10554,21 +11431,47 @@ dependencies = [ "bytemuck", "num-derive", "num-traits", - "solana-account-info", - "solana-cpi", + "solana-account-info 2.3.0", + "solana-cpi 2.2.1", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", + "solana-instruction 2.3.1", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "spl-program-error 0.7.0", "spl-tlv-account-resolution 0.10.0", "spl-type-length-value 0.8.0", "thiserror 2.0.17", ] +[[package]] +name = "spl-transfer-hook-interface" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34b46b8f39bc64a9ab177a0ea8e9a58826db76f8d9d154a2400ee60baef7b1e" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "solana-account-info 3.1.0", + "solana-cpi 3.1.0", + "solana-instruction 3.1.0", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.1.0", + "solana-system-interface 2.0.0", + "spl-discriminator 0.5.1", + "spl-pod 0.7.1", + "spl-program-error 0.8.0", + "spl-tlv-account-resolution 0.11.1", + "spl-type-length-value 0.9.0", + "thiserror 2.0.17", +] + [[package]] name = "spl-type-length-value" version = "0.7.0" @@ -10578,12 +11481,12 @@ dependencies = [ "bytemuck", "num-derive", "num-traits", - "solana-account-info", + "solana-account-info 2.3.0", "solana-decode-error", - "solana-msg", - "solana-program-error", - "spl-discriminator", - "spl-pod", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "thiserror 1.0.69", ] @@ -10596,12 +11499,30 @@ dependencies = [ "bytemuck", "num-derive", "num-traits", - "solana-account-info", + "solana-account-info 2.3.0", "solana-decode-error", - "solana-msg", - "solana-program-error", - "spl-discriminator", - "spl-pod", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", + "thiserror 2.0.17", +] + +[[package]] +name = "spl-type-length-value" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca20a1a19f4507a98ca4b28ff5ed54cac9b9d34ed27863e2bde50a3238f9a6ac" +dependencies = [ + "bytemuck", + "num-derive", + "num-traits", + "num_enum 0.7.5", + "solana-account-info 3.1.0", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", + "spl-discriminator 0.5.1", + "spl-pod 0.7.1", "thiserror 2.0.17", ] @@ -11959,7 +12880,8 @@ version = "0.1.0" dependencies = [ "alloy-consensus", "alloy-contract", - "alloy-primitives", + "alloy-json-abi 0.8.26", + "alloy-primitives 1.4.1", "alloy-rlp", "alloy-sol-types", "base64 0.22.1", @@ -11967,8 +12889,10 @@ dependencies = [ "hex", "log", "num_enum 0.7.5", + "phf", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.17", "visualsign", ] @@ -11987,12 +12911,14 @@ dependencies = [ "serde_json", "solana-program", "solana-sdk", - "solana-system-interface", + "solana-system-interface 1.0.0", "solana-transaction-status", "solana_parser", "spl-associated-token-account 6.0.0", "spl-stake-pool", "spl-token 7.0.0", + "spl-token-2022 10.0.0", + "spl-token-2022-interface", "tracing", "visualsign", ] diff --git a/src/chain_parsers/visualsign-ethereum/ARCHITECTURE.md b/src/chain_parsers/visualsign-ethereum/ARCHITECTURE.md new file mode 100644 index 00000000..9d1c6359 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/ARCHITECTURE.md @@ -0,0 +1,378 @@ +# VisualSign Ethereum Module Architecture + +## Overview + +The visualsign-ethereum module provides transaction visualization for Ethereum and EVM-compatible chains. It follows a layered architecture that separates generic contract standards from protocol-specific implementations. + +## Directory Structure + +``` +src/ +├── lib.rs - Main entry point, transaction parsing +├── chains.rs - Chain ID to name mappings +├── context.rs - VisualizerContext for transaction context +├── fmt.rs - Formatting utilities (ether, gwei, etc) +├── registry.rs - ContractRegistry for address-to-type mapping +├── token_metadata.rs - Canonical wallet token format +├── visualizer.rs - VisualizerRegistry and builder pattern +│ +├── contracts/ - Generic contract standards +│ ├── mod.rs - Re-exports all contract modules +│ └── core/ - Core contract standards +│ ├── mod.rs +│ ├── erc20.rs - ERC20 token standard visualizer +│ ├── erc721.rs - ERC721 NFT standard visualizer +│ └── fallback.rs - Catch-all hex visualizer for unknown contracts +│ +└── protocols/ - Protocol-specific implementations + ├── mod.rs - register_all() function + └── uniswap/ - Uniswap DEX protocol + ├── mod.rs - Protocol registration + ├── config.rs - Contract addresses and chain deployments + └── contracts/ - Uniswap-specific contract visualizers + ├── mod.rs + └── universal_router.rs - Universal Router (V2/V3/V4) visualizer +``` + +## Key Concepts + +### Contracts vs Protocols + +**Contracts** (`src/contracts/`): +- Generic, cross-protocol contract standards +- Implemented by many different projects +- Examples: ERC20, ERC721, ERC1155 +- Organized by category: + - **core/** - Fundamental token standards (ERC20, ERC721) + - **staking/** - Generic staking patterns (future) + - **governance/** - Generic governance patterns (future) + +**Protocols** (`src/protocols/`): +- Specific DeFi/Web3 protocols with custom business logic +- Each protocol is a collection of related contracts +- Examples: Uniswap, Aave, Compound +- Each protocol contains: + - **config.rs** - Contract addresses, chain deployments, metadata + - **contracts/** - Protocol-specific contract visualizers + - **mod.rs** - Registration function + +### Example: Uniswap Protocol + +``` +protocols/uniswap/ +├── config.rs # Addresses for all chains (Mainnet, Arbitrum, etc) +├── contracts/ +│ ├── universal_router.rs # Handles Universal Router calls +│ ├── v3_router.rs # (future) V3-specific router +│ └── v2_router.rs # (future) V2-specific router +└── mod.rs # register() function +``` + +The `config.rs` file defines: +- **Contract type markers** (type-safe unit structs implementing `ContractType`) +- Contract addresses per chain +- Helper methods to query deployments + +## Type-Safe Contract Identifiers + +The module uses the `ContractType` trait to ensure compile-time uniqueness of contract types: + +```rust +/// Define a contract type marker (in protocols/uniswap/config.rs) +pub struct UniswapUniversalRouter; +impl ContractType for UniswapUniversalRouter {} + +// If someone copies this and forgets to rename: +pub struct UniswapUniversalRouter; // ❌ Compile error: duplicate type! +``` + +This ensures compile-time uniqueness and automatic type ID generation from type names. + +## Registration System + +The module uses a dual-registry pattern: + +### 1. ContractRegistry (Address → Type) +Maps `(chain_id, address)` to contract type string: +```rust +// Type-safe registration (preferred) +registry.register_contract_typed::(1, vec![address]); + +// String-based registration (backward compatibility) +registry.register_contract(1, "CustomContract", vec![address]); +``` + +### 2. EthereumVisualizerRegistry (Type → Visualizer) +Maps contract type to visualizer implementation: +```rust +// Example: "UniswapUniversalRouter" → UniswapUniversalRouterVisualizer +visualizer_reg.register(Box::new(UniswapUniversalRouterVisualizer::new())); +``` + +### Registration Flow + +```rust +// protocols/uniswap/mod.rs +pub fn register( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + use config::UniswapUniversalRouter; + + let address = UniswapConfig::universal_router_address(); + + // 1. Register Universal Router on all supported chains (type-safe) + for &chain_id in UniswapConfig::universal_router_chains() { + contract_reg.register_contract_typed::( + chain_id, + vec![address], + ); + } + + // 2. Register visualizers (future) + // visualizer_reg.register(Box::new(UniswapUniversalRouterVisualizer::new())); +} + +// protocols/mod.rs +pub fn register_all( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + uniswap::register(contract_reg, visualizer_reg); + // Future: aave::register(contract_reg, visualizer_reg); + // Future: compound::register(contract_reg, visualizer_reg); +} +``` + +## Visualization Pipeline + +1. **Transaction Parsing** (`decode_transaction()` / `EthereumTransactionWrapper::from_string()`) + - Parse RLP-encoded transaction + - Extract chain_id, to, value, input data + +2. **Contract Type Lookup** (`EthereumVisualSignConverter::to_visual_sign_payload()`) + - Query `ContractRegistry` with (chain_id, to_address) + - Get contract type string (e.g., "Uniswap_UniversalRouter") + +3. **Visualizer Dispatch** (future enhancement) + - Query `EthereumVisualizerRegistry` with contract type + - Invoke visualizer's `visualize()` method + +4. **Fallback Visualization** (`convert_to_visual_sign_payload()`) + - If no specific visualizer handles the call + - Use `FallbackVisualizer` to display raw hex + +## Scope and Limitations + +### Calldata Decoding vs Transaction Simulation + +This module **decodes transaction calldata** to show user intent. It does **not simulate transaction execution** to show results or state changes. + +#### What We Can Decode (Calldata Analysis): +✅ Function calls and parameters (e.g., `execute(commands, inputs, deadline)`) +✅ **Outgoing amounts** - Exact amounts user is sending (e.g., "240 SETH", "60 SETH") +✅ **Minimum expected outputs** - Slippage protection (e.g., ">=0.0035 WETH") +✅ Token symbols from registry (e.g., "SETH", "WETH" instead of addresses) +✅ Pool fee tiers (e.g., "0.3% fee" indicates which V3 pool tier) +✅ Recipients and addresses for transfers and payments +✅ Deadline timestamps +✅ Command sequences showing transaction flow (e.g., swap → pay fee → unwrap) + +**Example output:** +``` +Command 1: Swap 240 SETH for >=0.00357 WETH via V3 (0.3% fee) +Command 2: Swap 60 SETH for >=0.000895 WETH via V3 (1% fee) +Command 3: Pay 0.25% of WETH to 0x000000fee13a103a10d593b9ae06b3e05f2e7e1c +Command 4: Unwrap >=0.00446920 WETH to ETH +``` + +#### What Requires Simulation (Out of Scope): + +❌ **Actual received amounts** - Exact output after execution (vs minimum expected) + - We show: ">=0.00357 WETH" (from calldata) + - Simulation shows: "0.003573913782539750 WETH received" (actual result) + - Requires: EVM execution to compute exact amounts after slippage + +❌ **Pool address resolution** - Which specific pool contract handles each swap + - We show: "via V3 (0.3% fee)" (fee tier from calldata) + - Simulation shows: "via pool 0xd6e420f6...34cd" (actual pool address) + - Requires: RPC queries to find pools for token pairs + fee tier + +❌ **Balance changes in external contracts** - State deltas in pools, routers, etc. + - We show: User intent (swap X for Y, pay fee, unwrap) + - Simulation shows: "Pool 0xd6e420f6: WETH -0.0036, SETH +240" + - Requires: State tracking during execution for all touched contracts + +❌ **Multi-hop routing** - Intermediate tokens in complex swap paths + - Current: Single-hop decoding (token A → token B) + - Future enhancement: Parse multi-hop paths from calldata (no simulation needed) + +❌ **Gas estimation** - Actual gas consumed + - Requires: EVM execution + +**Why these are out of scope:** + +1. **Architectural separation**: Visualizers decode calldata (signing time), not execution results (runtime) +2. **No RPC dependency**: This module is pure calldata → human-readable transformation +3. **Deterministic behavior**: Decoding doesn't depend on chain state or external data +4. **Performance**: No network calls or heavy computation required + +**Tools that provide simulation:** +- [Tenderly](https://tenderly.co) - Full EVM simulation with state tracking +- [Foundry's cast](https://book.getfoundry.sh/cast/) - Local simulation +- Block explorers with internal transaction tracing + +This module's goal is to make **what the user is signing** clear, not to predict execution outcomes. + +## Adding New Protocols + +To add a new protocol (e.g., Aave): + +1. **Create protocol directory**: + ```bash + mkdir -p src/protocols/aave/contracts + ``` + +2. **Create config.rs with type-safe contract markers**: + ```rust + // src/protocols/aave/config.rs + use alloy_primitives::Address; + use crate::registry::ContractType; + + /// Contract type marker for Aave Lending Pool + #[derive(Debug, Clone, Copy)] + pub struct AaveLendingPool; + impl ContractType for AaveLendingPool {} + + /// Aave protocol configuration + pub struct AaveConfig; + + impl AaveConfig { + pub fn lending_pool_address() -> Address { + "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9".parse().unwrap() + } + + pub fn lending_pool_chains() -> &'static [u64] { + &[1, 137, 42161, 10, 8453] // Mainnet, Polygon, Arbitrum, etc. + } + } + ``` + +3. **Create contract visualizers**: + ```rust + // src/protocols/aave/contracts/lending_pool.rs + pub struct AaveLendingPoolVisualizer {} + ``` + +4. **Create registration function**: + ```rust + // src/protocols/aave/mod.rs + pub fn register( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, + ) { + use config::AaveLendingPool; + + let address = AaveConfig::lending_pool_address(); + + // Register using type-safe method + for &chain_id in AaveConfig::lending_pool_chains() { + contract_reg.register_contract_typed::( + chain_id, + vec![address], + ); + } + + // Register visualizers (future) + // visualizer_reg.register(Box::new(AaveLendingPoolVisualizer::new())); + } + ``` + +5. **Register in protocols/mod.rs**: + ```rust + pub mod aave; + + pub fn register_all(...) { + uniswap::register(contract_reg, visualizer_reg); + aave::register(contract_reg, visualizer_reg); + } + ``` + +## Fallback Mechanism + +The `FallbackVisualizer` ([contracts/core/fallback.rs](src/contracts/core/fallback.rs)) provides a catch-all for unknown contract calls: + +- Returns raw calldata as hex: `0x1234567890abcdef` +- Label: "Contract Call Data" +- Similar to Solana's unknown program handler + +This ensures all transactions can be visualized, even without specific protocol support. + +## Configuration Pattern + +Each protocol uses a simple configuration struct with static methods: + +```rust +use alloy_primitives::Address; +use crate::registry::ContractType; + +/// Contract type marker (compile-time unique) +#[derive(Debug, Clone, Copy)] +pub struct UniswapUniversalRouter; +impl ContractType for UniswapUniversalRouter {} + +/// Protocol configuration +pub struct UniswapConfig; + +impl UniswapConfig { + /// Returns the Universal Router address (same across chains) + pub fn universal_router_address() -> Address { + "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD".parse().unwrap() + } + + /// Returns supported chain IDs + pub fn universal_router_chains() -> &'static [u64] { + &[1, 10, 137, 8453, 42161] + } +} +``` + +## Future Enhancements + +### 1. Visualizer Trait Implementation +Currently, protocol visualizers (like `UniswapV4Visualizer`) use ad-hoc methods. They should implement the `ContractVisualizer` trait: + +```rust +impl ContractVisualizer for UniswapUniversalRouterVisualizer { + fn contract_type(&self) -> &str { + UNISWAP_UNIVERSAL_ROUTER + } + + fn visualize(&self, context: &VisualizerContext) + -> Result>, VisualSignError> + { + // Decode and visualize Universal Router calls + } +} +``` + +### 2. Registry Architecture Refactor +See `EthereumVisualSignConverter::create_layered_registry()` for detailed TODO about moving registries from converter ownership to context-based passing. + +### 3. Protocol Version Support +Each protocol should support multiple versions: +``` +protocols/uniswap/contracts/ +├── v2_router.rs +├── v3_router.rs +└── universal_router.rs +``` + +### 4. Cross-Protocol Standards +Some patterns span multiple protocols: +``` +contracts/ +├── core/ # ERC standards +├── staking/ # Generic staking (not protocol-specific) +└── governance/ # Generic governance contracts +``` diff --git a/src/chain_parsers/visualsign-ethereum/CLAUDE.md b/src/chain_parsers/visualsign-ethereum/CLAUDE.md new file mode 100644 index 00000000..f5faa51a --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/CLAUDE.md @@ -0,0 +1,331 @@ +# VisualSign Ethereum Module Guidelines + +## Field Builders + +The `visualsign` crate provides field builder functions that reduce boilerplate when creating payload fields. Always use these rather than constructing field structs directly. + +### Available Functions + +Import from `visualsign::field_builders`: + +#### `create_text_field(label: &str, text: &str) -> Result` +Creates a TextV2 field. Use for simple text display (network names, addresses, etc). + +```rust +use visualsign::field_builders::create_text_field; + +let field = create_text_field("Network", "Ethereum Mainnet")?; +``` + +#### `create_amount_field(label: &str, amount: &str, abbreviation: &str) -> Result` +Creates an AmountV2 field with token symbol. Validates that amount is a proper signed decimal number. + +```rust +use visualsign::field_builders::create_amount_field; + +let field = create_amount_field("Value", "1.5", "USDC")?; +``` + +#### `create_number_field(label: &str, number: &str, unit: &str) -> Result` +Creates a Number field with optional unit. Similar to amount but without requiring a symbol. + +```rust +use visualsign::field_builders::create_number_field; + +let field = create_number_field("Gas Limit", "21000", "units")?; +``` + +#### `create_address_field(label: &str, address: &str, name: Option<&str>, memo: Option<&str>, asset_label: Option<&str>, badge_text: Option<&str>) -> Result` +Creates an AddressV2 field with optional metadata. + +```rust +use visualsign::field_builders::create_address_field; + +let field = create_address_field( + "To", + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + Some("Vitalik"), + None, + Some("ETH"), + Some("Founder"), +)?; +``` + +#### `create_raw_data_field(data: &[u8], optional_fallback_string: Option) -> Result` +Creates a TextV2 field for raw bytes. Displays as hex by default. + +```rust +use visualsign::field_builders::create_raw_data_field; + +let field = create_raw_data_field(b"calldata", None)?; +``` + +### Number Validation + +All amount and number fields validate the input using a regex pattern: +- Valid: `123`, `123.45`, `-123.45`, `+678.90`, `0`, `0.0` +- Invalid: `-.45`, `123.`, `abc`, `12.3.4`, `--1` + +## Token Metadata + +The `token_metadata` module provides canonical wallet format for token data: + +```rust +use crate::token_metadata::{ChainMetadata, TokenMetadata, ErcStandard, parse_network_id}; + +// Parse network identifier to chain ID +let chain_id = parse_network_id("ETHEREUM_MAINNET")?; // Returns 1 + +// Create token metadata +let token = TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + decimals: 6, +}; + +// Hash protobuf bytes +let hash = compute_metadata_hash(protobuf_bytes); +``` + +### Supported Networks + +- `ETHEREUM_MAINNET` → chain_id: 1 +- `POLYGON_MAINNET` → chain_id: 137 +- `ARBITRUM_MAINNET` → chain_id: 42161 +- `OPTIMISM_MAINNET` → chain_id: 10 +- `BASE_MAINNET` → chain_id: 8453 + +## Registry + +The `ContractRegistry` maps `(chain_id, Address) -> TokenMetadata` for efficient lookups: + +```rust +use crate::registry::ContractRegistry; + +let mut registry = ContractRegistry::new(); + +// Register token with metadata +registry.register_token(1, token_metadata)?; + +// Get token symbol +let symbol = registry.get_token_symbol(1, address); + +// Format token amount with proper decimals +let formatted = registry.format_token_amount(1, address, raw_amount); + +// Load from wallet metadata +registry.load_chain_metadata(&chain_metadata)?; +``` + +## Context and Visualization + +The `VisualizerContext` provides execution context for transaction visualization: + +```rust +use crate::context::{VisualizerContext, VisualizerContextParams}; + +let params = VisualizerContextParams { + chain_id, + sender: sender_address, + current_contract: contract_address, + calldata, + registry, + visualizers, +}; +let context = VisualizerContext::new(params); + +// Create nested call context +let nested = context.for_nested_call(nested_contract, nested_calldata); +``` + +## Best Practices + +1. **Always use field builders** - Don't construct SignablePayloadField structs directly +2. **Handle errors** - All field builders return `Result` types +3. **Prefer canonical types** - Use `TokenMetadata` from `token_metadata` module +4. **Use registry for lookups** - Don't duplicate token metadata storage +5. **Network ID mapping** - Always use `parse_network_id()` to convert string IDs to chain IDs +6. **Validate amounts** - Field builders validate number formats automatically +7. **Chain ID + Address as key** - Always use (chain_id, Address) tuple for token lookups + +## Module Structure + +``` +src/ +├── lib.rs - Main entry point, re-exports +├── chains.rs - Chain name mappings +├── context.rs - VisualizerContext for transaction context +├── contracts/ - Contract-specific visualizers (ERC20, Uniswap, etc) +├── fmt.rs - Formatting utilities (ether, gwei, etc) +├── protocols/ - Protocol-specific handlers +├── registry.rs - ContractRegistry for metadata lookup +├── token_metadata.rs - Canonical wallet token format +└── visualizer.rs - VisualizerRegistry and builder +``` + +## Milestone 1.1 - Token and Contract Registry + +- `TokenMetadata`: canonical wallet token format with symbol, name, erc_standard, contract_address, decimals +- `ChainMetadata`: grouping of tokens by network, sent from wallets as protobuf +- `parse_network_id()`: maps network identifiers to chain IDs +- `compute_metadata_hash()`: SHA256 hashing of protobuf metadata bytes +- `ContractRegistry`: (chain_id, Address) → TokenMetadata mapping for efficient lookups +- Field builders from visualsign: reusable field construction utilities + +## Common Patterns + +### Creating transaction fields + +```rust +use visualsign::field_builders::*; + +let mut fields = vec![ + create_text_field("Network", "Ethereum Mainnet")?, + create_address_field("To", "0x...", None, None, None, None)?, + create_amount_field("Value", "1.5", "ETH")?, + create_number_field("Gas Limit", "21000", "")?, +]; +``` + +### Formatting token amounts + +```rust +use crate::registry::ContractRegistry; + +if let Some((formatted, symbol)) = registry.format_token_amount(chain_id, token_address, raw_amount) { + let field = create_amount_field("Amount", &formatted, &symbol)?; + // Use field... +} +``` + +### Loading wallet metadata + +```rust +use crate::registry::ContractRegistry; + +let mut registry = ContractRegistry::new(); +registry.load_chain_metadata(&wallet_metadata)?; + +// Now all tokens from wallet are indexed by (chain_id, address) +``` + +## Solidity Protocol Decoders + +All protocol decoders (Uniswap, future Aave, etc.) follow a clean, repeatable pattern using the `sol!` macro from alloy. + +### Decoder Pattern + +Every decoder has 4 steps: + +1. **Define struct with sol!** - Type-safe parameter structure +2. **Decode or handle error** - Use `StructName::abi_decode(bytes)` +3. **Resolve tokens from registry** - Get symbols and format amounts +4. **Return TextV2 field** - Human-readable summary + +### Example: Simple Decoder + +```rust +fn decode_operation( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + // Step 1: Decode parameters + let params = match OperationParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Operation: 0x{}", hex::encode(bytes)), + label: "Operation".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + // Step 2: Resolve token symbols via registry + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + // Step 3: Format amount with decimals + let (amount_str, _) = registry + .and_then(|r| { + let amount: u128 = params.amount.to_string().parse().ok()?; + r.format_token_amount(chain_id, params.token, amount) + }) + .unwrap_or_else(|| (params.amount.to_string(), token_symbol.clone())); + + // Step 4: Create human-readable summary + let text = format!("Operation with {} {}", amount_str, token_symbol); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Operation".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } +} +``` + +### Defining Parameter Structs + +Use the `sol!` macro to define all parameters: + +```rust +sol! { + struct SwapParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + } + + struct TransferParams { + address to; + uint256 amount; + bytes data; + } +} +``` + +**Benefits:** +- Automatic type-safe ABI decoding +- No manual byte parsing needed +- Compile-time correctness + +### Reusable Address Utilities + +For canonical contracts like WETH: + +```rust +use crate::utils::address_utils::WellKnownAddresses; + +let weth = WellKnownAddresses::weth(chain_id)?; // Get WETH for this chain +let usdc = WellKnownAddresses::usdc(chain_id)?; // Get USDC for this chain +let permit2 = WellKnownAddresses::permit2(); // Same on all chains +``` + +### No ASCII Restrictions + +Always use ASCII for terminal compatibility: +- Use `>=` instead of `≥` +- Use `<=` instead of `≤` +- Use `->` instead of `→` + +### Adding New Protocols + +To add Aave, Curve, or any other protocol: + +1. Create `src/protocols/aave/` directory +2. Define Aave function structs with `sol!` +3. Create decoder functions (20-40 lines each) +4. Add to main visualizer registry + +See `DECODER_GUIDE.md` for complete examples. diff --git a/src/chain_parsers/visualsign-ethereum/Cargo.toml b/src/chain_parsers/visualsign-ethereum/Cargo.toml index 6f0718ae..b7c2d6b3 100644 --- a/src/chain_parsers/visualsign-ethereum/Cargo.toml +++ b/src/chain_parsers/visualsign-ethereum/Cargo.toml @@ -5,16 +5,19 @@ edition = "2024" [dependencies] alloy-consensus = "1.0.42" +alloy-contract = "1.0.42" +alloy-json-abi = "0.8.18" alloy-primitives = "1.3.0" alloy-rlp = "0.3.12" alloy-sol-types = "1.4.1" -alloy-contract = "1.0.42" base64 = "0.22.1" chrono = { version = "0.4", features = ["std", "clock"] } hex = "0.4.3" log = "0.4" num_enum = "0.7.2" +phf = { version = "0.11", features = ["macros"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10" thiserror = "2.0.12" visualsign = { workspace = true } diff --git a/src/chain_parsers/visualsign-ethereum/DECODER_GUIDE.md b/src/chain_parsers/visualsign-ethereum/DECODER_GUIDE.md new file mode 100644 index 00000000..4b3debff --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/DECODER_GUIDE.md @@ -0,0 +1,344 @@ +# Solidity Protocol Decoder Implementation Guide + +This guide shows how to add clean, maintainable decoders for any Solidity-based protocol (Uniswap, Aave, Curve, etc.) using the patterns established in this codebase. + +## The Pattern: Simple, Repeatable, and Type-Safe + +Every decoder follows this simple pattern: + +```rust +/// Decodes OPERATION command parameters +fn decode_operation( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + // 1. Decode the struct using sol! macro + let params = match OperationParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + // Return error field if decoding fails + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Operation: 0x{}", hex::encode(bytes)), + label: "Operation".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + // 2. Extract data from params and resolve tokens via registry + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + let amount_u128 = params.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, params.token, amount_u128)) + .unwrap_or_else(|| (params.amount.to_string(), token_symbol.clone())); + + // 3. Create human-readable text summary + let text = format!("Perform operation with {} {}", amount_str, token_symbol); + + // 4. Return as TextV2 field + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Operation".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } +} +``` + +## Step-by-Step Implementation + +### Step 1: Define Struct Parameters with sol! Macro + +In your main decoder file, define all the parameter structs using the `sol!` macro: + +```rust +sol! { + struct SwapParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + } + + struct ApproveLendParams { + address token; + address lendingPool; + uint256 amount; + } +} +``` + +**Why?** The `sol!` macro from alloy automatically generates: +- Type-safe `abi_decode()` function +- Proper ABI encoding/decoding +- Clean field access without manual byte parsing + +### Step 2: Add Decoder Function + +Create a `decode_*` function for each operation type. Keep it focused: + +```rust +fn decode_swap( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + // Decode or return error + let params = match SwapParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => return error_field("Swap"), + }; + + // Get token symbols from registry + let token_in = registry + .and_then(|r| r.get_token_symbol(chain_id, params.tokenIn)) + .unwrap_or_else(|| format!("{:?}", params.tokenIn)); + + let token_out = registry + .and_then(|r| r.get_token_symbol(chain_id, params.tokenOut)) + .unwrap_or_else(|| format!("{:?}", params.tokenOut)); + + // Format amounts using registry decimals + let (amount_in_str, _) = registry + .and_then(|r| { + let amount: u128 = params.amountIn.to_string().parse().ok()?; + r.format_token_amount(chain_id, params.tokenIn, amount) + }) + .unwrap_or_else(|| (params.amountIn.to_string(), token_in.clone())); + + let text = format!("Swap {} {} for {} {}", + amount_in_str, token_in, params.minAmountOut, token_out + ); + + text_field("Swap", text) +} +``` + +### Step 3: Add to Match Statement + +In your main decoder function, add each operation to the match statement: + +```rust +match operation_type { + OperationType::Swap => Self::decode_swap(bytes, chain_id, registry), + OperationType::ApproveLend => Self::decode_approve_lend(bytes, chain_id, registry), + _ => unimplemented_field(operation_type), +} +``` + +### Step 4: Leverage Registry for Token Resolution + +The `ContractRegistry` is your key to clean code. Use these methods: + +```rust +// Get token symbol +let symbol = registry.get_token_symbol(chain_id, address); + +// Format amount with decimals +let (formatted, symbol) = registry.format_token_amount( + chain_id, + token_address, + raw_amount // u128 +); +``` + +## Real-World Examples from Uniswap Router + +### Simple Decoder: Wrap ETH + +```rust +fn decode_wrap_eth( + bytes: &[u8], + _chain_id: u64, + _registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + let params = match WrapEthParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Wrap ETH: 0x{}", hex::encode(bytes)), + label: "Wrap ETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let amount_str = params.amountMin.to_string(); + let text = format!("Wrap {} ETH to WETH", amount_str); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Wrap ETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } +} +``` + +### Complex Decoder: V3 Swap Exact In + +```rust +fn decode_v3_swap_exact_in( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + // Decode parameters + let params = match V3SwapExactInputParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => return error_field("V3 Swap Exact In"), + }; + + // Parse V3 path (address[20] + fee[3bytes] + address[20] + ...) + if params.path.0.len() < 43 { + return invalid_path_field(); + } + + let path_bytes = ¶ms.path.0; + let token_in = Address::from_slice(&path_bytes[0..20]); + let fee = u32::from_be_bytes([0, path_bytes[20], path_bytes[21], path_bytes[22]]); + let token_out = Address::from_slice(&path_bytes[23..43]); + + // Resolve tokens + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{:?}", token_in)); + + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{:?}", token_out)); + + // Format amounts + let (amount_in_str, _) = registry + .and_then(|r| { + let amount: u128 = params.amountIn.to_string().parse().ok()?; + r.format_token_amount(chain_id, token_in, amount) + }) + .unwrap_or_else(|| (params.amountIn.to_string(), token_in_symbol.clone())); + + let (amount_out_str, _) = registry + .and_then(|r| { + let amount: u128 = params.amountOutMinimum.to_string().parse().ok()?; + r.format_token_amount(chain_id, token_out, amount) + }) + .unwrap_or_else(|| (params.amountOutMinimum.to_string(), token_out_symbol.clone())); + + let fee_pct = fee as f64 / 10000.0; + let text = format!( + "Swap {} {} for >={} {} via V3 ({}% fee)", + amount_in_str, token_in_symbol, amount_out_str, token_out_symbol, fee_pct + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V3 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } +} +``` + +## Key Principles + +### 1. Type Safety First +Use the `sol!` macro to generate type-safe decoders. Avoid manual byte parsing. + +### 2. Registry as Single Source of Truth +All token symbols and decimals come from `ContractRegistry`. This ensures consistency and allows wallets to customize metadata. + +### 3. Graceful Error Handling +Always handle decode failures by returning a TextV2 field with the hex input. This gives users visibility into what failed. + +### 4. Clean, Human-Readable Output +Format amounts with proper decimals and symbols. Make the transaction intent clear. + +### 5. No ASCII Characters in Strings +Use `>=` and `<=` instead of non-ASCII characters like `≥` and `≤` for terminal compatibility. + +## Reusable Utilities + +### WellKnownAddresses + +For contracts like WETH that don't need registry lookups: + +```rust +use crate::utils::address_utils::WellKnownAddresses; + +let weth_address = WellKnownAddresses::weth(chain_id)?; +let permit2_address = WellKnownAddresses::permit2(); +``` + +### Error Fields + +Create consistent error fields: + +```rust +SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{}: 0x{}", operation_name, hex::encode(bytes)), + label: operation_name.to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, +} +``` + +## Adding Support for Aave + +When you're ready to add Aave support, follow this pattern: + +```rust +// 1. Define Aave structs using sol! +sol! { + struct DepositParams { + address asset; + uint256 amount; + address onBehalfOf; + } + + struct BorrowParams { + address asset; + uint256 amount; + uint256 interestRateMode; + address onBehalfOf; + } +} + +// 2. Create decoder functions (same pattern as Uniswap) +fn decode_deposit(bytes: &[u8], chain_id: u64, registry: Option<&ContractRegistry>) -> SignablePayloadField { + // ... follows the same pattern ... +} + +// 3. Add to main match statement +match aave_operation { + AaveOp::Deposit => decode_deposit(bytes, chain_id, registry), + AaveOp::Borrow => decode_borrow(bytes, chain_id, registry), + // ... +} +``` + +## Summary + +The pattern is simple and scales: +1. Define structs with `sol!` +2. Create decoder function (20-40 lines) +3. Add to match statement +4. Test with real transaction data + +This approach has been used successfully for Uniswap's 19 command types. It will work for any Solidity protocol. diff --git a/src/chain_parsers/visualsign-ethereum/examples/using_abijson/README.md b/src/chain_parsers/visualsign-ethereum/examples/using_abijson/README.md new file mode 100644 index 00000000..83feacbe --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/examples/using_abijson/README.md @@ -0,0 +1,168 @@ +# Using Embedded ABI JSON with VisualSign Parser + +This example demonstrates how to use compile-time embedded ABI JSON files with the visualsign-parser to enable transaction visualization for custom contracts. + +## Why Compile-Time Embedding? + +Like the `sol!` macro used throughout the parser, ABIs must be embedded at compile-time: + +- **Security**: ABIs are validated during compilation, not loaded at runtime +- **Performance**: No file I/O or JSON parsing overhead at runtime +- **Determinism**: Same binary always uses the same ABIs +- **Simplicity**: No external file dependencies to manage + +## Quick Start + +### For Dapp Developers + +To enable visualization for your custom contract: + +1. **Generate ABI JSON** from your Solidity contract: + ```bash + solc --abi SimpleToken.sol > SimpleToken.abi.json + ``` + +2. **Embed in your application** using `include_str!` macro: + ```rust + const MY_CONTRACT_ABI: &str = include_str!("path/to/SimpleToken.abi.json"); + ``` + +3. **Register in ABI registry**: + ```rust + use visualsign_ethereum::embedded_abis::register_embedded_abi; + use visualsign_ethereum::abi_registry::AbiRegistry; + + let mut registry = AbiRegistry::new(); + register_embedded_abi(&mut registry, "SimpleToken", MY_CONTRACT_ABI)?; + registry.map_address(1, contract_address, "SimpleToken"); + ``` + +4. **Use in CLI** (pass address mappings): + ```bash + cargo run --release -- \ + --chain ethereum \ + --transaction 0x... \ + --abi-json-mappings SimpleToken:0x1234567890123456789012345678901234567890 + ``` + +5. **Or via Rust code** in your application + +### Using the Example + +#### Via CLI + +```bash +# List available ABIs and see help +cargo run --example using_abijson -- --help + +# Test with a mock address mapping (validates format) +# Note: You need to build a custom binary with embedded ABIs for actual usage +cargo run --example using_abijson -- \ + --chain ethereum \ + --transaction 0x... \ + --abi-json-mappings SimpleToken:0x +``` + +#### Via Rust Code + +```rust +use visualsign_ethereum::embedded_abis::register_embedded_abi; +use visualsign_ethereum::abi_registry::AbiRegistry; +use visualsign_ethereum::contracts::core::DynamicAbiVisualizer; +use visualsign_ethereum::visualizer::CalldataVisualizer; +use std::sync::Arc; + +const SIMPLE_TOKEN_ABI: &str = include_str!("contracts/SimpleToken.abi.json"); + +fn main() -> Result<(), Box> { + // Create registry and register ABI + let mut registry = AbiRegistry::new(); + register_embedded_abi(&mut registry, "SimpleToken", SIMPLE_TOKEN_ABI)?; + + // Get the ABI for a specific address (requires prior registration) + let contract_address: alloy_primitives::Address = + "0x1234567890123456789012345678901234567890".parse()?; + registry.map_address(1, contract_address, "SimpleToken"); + + // Retrieve and create visualizer + if let Some(abi) = registry.get_abi_for_address(1, contract_address) { + let visualizer = DynamicAbiVisualizer::new(abi); + + // Decode function call (transfer: a9059cbb) + let calldata = hex::decode("a9059cbb0000000000000000000000001234567890123456789012345678901234567890")?; + + if let Some(field) = visualizer.visualize_calldata(&calldata, 1, None) { + println!("Visualization: {:#?}", field); + } else { + println!("Could not visualize"); + } + } + + Ok(()) +} +``` + +## How It Works + +1. **ABI Parsing**: The JSON ABI is embedded at compile-time using `include_str!` +2. **Function Selection**: The 4-byte selector is used to find matching functions +3. **Visualization**: Parameters are displayed in a structured PreviewLayout + +Example visualization output for `mint(address to, uint256 amount)`: +``` +mint(address,uint256) +├── to: 0x1234... +└── amount: 1000000000000000000 +``` + +## CLI Integration + +The parser CLI now supports the `--abi-json-mappings` flag for mapping custom ABI JSON files to contract addresses: + +### Format + +``` +--abi-json-mappings AbiName:0xAddress +``` + +### Multiple Mappings + +You can provide multiple `--abi-json-mappings` flags to register different ABIs: + +```bash +cargo run --release -- \ + --chain ethereum \ + --transaction 0x... \ + --abi-json-mappings Token:0x1111111111111111111111111111111111111111 \ + --abi-json-mappings Router:0x2222222222222222222222222222222222222222 +``` + +### Validation + +The CLI validates each ABI mapping and reports: +- Successfully mapped ABIs (logged to stderr) +- Invalid format warnings (logged to stderr) +- Final registration summary + +## Supported Features + +- ✅ Compile-time ABI embedding with `include_str!` +- ✅ Per-chain address mapping (register same ABI on multiple chains) +- ✅ Function selector matching (4-byte opcodes) +- ✅ Structured PreviewLayout visualization +- ✅ Multiple ABIs per binary +- ✅ CLI `--abi-json-mappings` flag for address mapping +- ✅ Optional ABI signatures (secp256k1) for validation (planned) + +## Limitations + +- ⚠️ No runtime parameter decoding (type-safe decoding requires compile-time generation) +- ⚠️ Parameters shown with type names, not decoded values (future enhancement) +- ⚠️ Fallback-only - doesn't override built-in visualizers (Uniswap, ERC20, etc.) +- ⚠️ Signature validation not yet implemented (will be required when specified) + +## Next Steps + +See the full implementation guides: +- [CLAUDE.md](../../CLAUDE.md) - Module development guidelines +- [DECODER_GUIDE.md](../../DECODER_GUIDE.md) - Writing custom decoders diff --git a/src/chain_parsers/visualsign-ethereum/examples/using_abijson/TESTING.md b/src/chain_parsers/visualsign-ethereum/examples/using_abijson/TESTING.md new file mode 100644 index 00000000..05a62a9a --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/examples/using_abijson/TESTING.md @@ -0,0 +1,405 @@ +# Testing ABI Embedding with Real Contracts + +This guide shows how to test the ABI embedding feature using real contract ABIs pulled from the blockchain. + +## Prerequisites + +Install `cast` from the Foundry toolkit: + +```bash +curl -L https://foundry.paradigm.xyz | bash +foundryup +``` + +Verify installation: +```bash +cast --version +``` + +## Getting Real ABIs + +### Method 1: Using curl + Etherscan API (Recommended) + +Get a free Etherscan API key at: https://etherscan.io/apis + +```bash +ETHERSCAN_API_KEY="YOUR_API_KEY" + +# Get USDC ABI +curl -s "https://api.etherscan.io/api" \ + -d "module=contract" \ + -d "action=getabi" \ + -d "address=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" \ + -d "apikey=$ETHERSCAN_API_KEY" | jq '.result' > usdc.abi.json + +# Get WETH ABI +curl -s "https://api.etherscan.io/api" \ + -d "module=contract" \ + -d "action=getabi" \ + -d "address=0xc02aaa39b223fe8d0a0e8d0c9f8d0b21d0a0e8d0c" \ + -d "apikey=$ETHERSCAN_API_KEY" | jq '.result' > weth.abi.json +``` + +### Method 2: Using `cast` to test calldata + +While `cast` may not have `abi` subcommand in all versions, you can use it to work with calldata: + +```bash +# Encode function call +cast calldata "transfer(address,uint256)" \ + 0x1234567890123456789012345678901234567890 \ + 1000000 + +# Decode calldata with an ABI +cast abi-decode "transfer(address,uint256)" \ + "0xa9059cbb0000000000000000000000001234567890123456789012345678901234567890000000000000000000000000000000000000000000000000000000000f4240" +``` + +### Method 3: Online ABI repositories + +- **Etherscan UI**: Visit `etherscan.io` → Search address → Contract tab → Copy ABI +- **4byte.directory**: https://www.4byte.directory/ (for function signatures) +- **OpenZeppelin**: Pre-made standard ABIs (ERC20, ERC721, etc.) + +Example (ERC20 standard): +```bash +# Save a standard ERC20 ABI +curl -s https://raw.githubusercontent.com/OpenZeppelin/openzeppelin-contracts/master/build/contracts/ERC20.json \ + | jq '.abi' > erc20_standard.abi.json +``` + +## Testing Locally + +### Step 1: Pull Example ABIs + +```bash +cd examples/using_abijson + +# Set your Etherscan API key +export ETHERSCAN_API_KEY="YOUR_API_KEY" + +# Get USDC ABI +curl -s "https://api.etherscan.io/api" \ + -d "module=contract" \ + -d "action=getabi" \ + -d "address=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" \ + -d "apikey=$ETHERSCAN_API_KEY" | jq '.result' > contracts/USDC.abi.json + +# Get USDT ABI +curl -s "https://api.etherscan.io/api" \ + -d "module=contract" \ + -d "action=getabi" \ + -d "address=0xdac17f958d2ee523a2206206994597c13d831ec7" \ + -d "apikey=$ETHERSCAN_API_KEY" | jq '.result' > contracts/USDT.abi.json + +# Verify ABIs are valid +jq '.[] | select(.name == "transfer") | .inputs' contracts/USDC.abi.json +``` + +### Step 2: Create a Test Binary with Embedded ABIs + +Create `examples/using_abijson/main.rs`: + +```rust +use visualsign_ethereum::embedded_abis::register_embedded_abi; +use visualsign_ethereum::abi_registry::AbiRegistry; +use visualsign_ethereum::contracts::core::DynamicAbiVisualizer; +use visualsign_ethereum::visualizer::CalldataVisualizer; +use std::sync::Arc; + +// Embed real contract ABIs +const USDC_ABI: &str = include_str!("contracts/USDC.abi.json"); +const USDT_ABI: &str = include_str!("contracts/USDT.abi.json"); + +fn main() -> Result<(), Box> { + // Create and populate registry + let mut registry = AbiRegistry::new(); + register_embedded_abi(&mut registry, "USDC", USDC_ABI)?; + register_embedded_abi(&mut registry, "USDT", USDT_ABI)?; + + // Map to known addresses (Ethereum mainnet) + let usdc_addr: alloy_primitives::Address = + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".parse()?; + let usdt_addr: alloy_primitives::Address = + "0xdac17f958d2ee523a2206206994597c13d831ec7".parse()?; + + registry.map_address(1, usdc_addr, "USDC"); + registry.map_address(1, usdt_addr, "USDT"); + + println!("Registry created with 2 ABIs:"); + println!(" - USDC: {}", usdc_addr); + println!(" - USDT: {}", usdt_addr); + println!(); + + // Test: Decode USDC transfer + println!("Testing USDC transfer decoding..."); + if let Some(abi) = registry.get_abi_for_address(1, usdc_addr) { + let visualizer = DynamicAbiVisualizer::new(abi); + + // transfer(address to, uint256 amount) + // selector: 0xa9059cbb + let recipient: alloy_primitives::Address = + "0x1234567890123456789012345678901234567890".parse()?; + let amount = 1_000_000u128; // 1 USDC (6 decimals) + + // Build calldata + let mut calldata = vec![0xa9, 0x05, 0x9c, 0xbb]; // transfer selector + calldata.extend_from_slice(&[0u8; 32]); // Pad to 32 bytes for address + calldata[4 + 12..4 + 32].copy_from_slice(recipient.as_slice()); // Copy address + calldata.extend_from_slice(&amount.to_be_bytes().to_vec()); // amount (right-padded) + + if let Some(field) = visualizer.visualize_calldata(&calldata, 1, None) { + println!("✓ Successfully visualized USDC transfer"); + println!(" Field: {:#?}", field); + } else { + println!("✗ Could not visualize"); + } + } + + Ok(()) +} +``` + +### Step 3: Run the Example + +```bash +# From the project root +cargo run --example using_abijson +``` + +Output should show: +``` +Registry created with 2 ABIs: + - USDC: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 + - USDT: 0xdac17f958d2ee523a2206206994597c13d831ec7 + +Testing USDC transfer decoding... +✓ Successfully visualized USDC transfer + Field: ... +``` + +## Testing with CLI + +### Method 1: Generate Calldata with cast + +```bash +# Get function selector +cast sig "transfer(address,uint256)" +# Output: 0xa9059cbb + +# Generate full calldata using cast calldata +CALLDATA=$(cast calldata "transfer(address,uint256)" \ + 0x1234567890123456789012345678901234567890 \ + 1000000) + +echo "Generated calldata: $CALLDATA" +# Output: 0xa9059cbb000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000f4240 + +# Now you can test with the parser +# Note: The CLI expects full transactions, not just calldata +# For testing, you may need to wrap this in a transaction format +``` + +### Method 2: Working with Function Signatures + +```bash +# Get signatures for multiple USDC functions +cast sig "transfer(address,uint256)" # 0xa9059cbb +cast sig "approve(address,uint256)" # 0x095ea7b3 +cast sig "transferFrom(address,address,uint256)" # 0x23b872dd +cast sig "balanceOf(address)" # 0x70a08231 +``` + +### Method 3: Testing with SimpleToken Example + +Use the built-in SimpleToken example (doesn't need external ABIs): + +```bash +# Build calldata for SimpleToken.mint(address, uint256) +MINT_SELECTOR=$(cast sig "mint(address,uint256)") +echo "mint selector: $MINT_SELECTOR" + +# Generate mint calldata +MINT_CALLDATA=$(cast calldata "mint(address,uint256)" \ + 0x1234567890123456789012345678901234567890 \ + 1000000) + +echo "mint calldata: $MINT_CALLDATA" + +# Test parsing +cargo test -p visualsign-ethereum --lib embedded_abis::tests +``` + +## Real Contract Examples + +### USDC Token (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48) +```bash +# Minimal functions: transfer, transferFrom, approve, balanceOf, allowance +cast abi 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 | jq '.[] | select(.name | IN("transfer", "transferFrom", "approve"))' +``` + +### WETH (0xc02aaa39b223fe8d0a0e8d0c9f8d0b21d0a0e8d0c) +```bash +cast abi 0xc02aaa39b223fe8d0a0e8d0c9f8d0b21d0a0e8d0c | jq '.[] | select(.name | IN("deposit", "withdraw"))' +``` + +### Uniswap V3 SwapRouter (0xe592427a0aece92de3edee1f18e0157c05861564) +```bash +cast abi 0xe592427a0aece92de3edee1f18e0157c05861564 | jq '.[] | select(.name | IN("exactInputSingle", "exactOutputSingle"))' +``` + +## Validating ABI JSON + +```bash +# Verify ABI is valid JSON +jq . contracts/USDC.abi.json > /dev/null && echo "Valid JSON" + +# Count functions +jq '[.[] | select(.type == "function")] | length' contracts/USDC.abi.json + +# List all function names +jq -r '.[].name' contracts/USDC.abi.json | sort | uniq +``` + +## Common Issues & Solutions + +### `cast` command not found +```bash +# Make sure Foundry is in your PATH +export PATH="$HOME/.foundry/bin:$PATH" + +# Or reinstall if needed +curl -L https://foundry.paradigm.xyz | bash +foundryup +``` + +### Etherscan API returns empty response +```bash +# Check your API key +ETHERSCAN_API_KEY="YOUR_KEY" +curl "https://api.etherscan.io/api?module=account&action=balance&address=0x0000000000000000000000000000000000000000&apikey=$ETHERSCAN_API_KEY" + +# If you get {"status":"0"}, your key is invalid +# Get a free key: https://etherscan.io/apis +``` + +### Invalid ABI JSON from curl +```bash +# Check the raw response +curl -s "https://api.etherscan.io/api" \ + -d "module=contract" \ + -d "action=getabi" \ + -d "address=0xINVALID" \ + -d "apikey=$ETHERSCAN_API_KEY" | jq . + +# You'll see: {"status":"0","message":"Contract source code not verified"} +``` + +### Address format issues +Always use lowercase or checksummed addresses: +```rust +// Works - lowercase +let addr: alloy_primitives::Address = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".parse()?; + +// Also works - checksummed +let addr: alloy_primitives::Address = "0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".parse()?; +``` + +### Function selector mismatch +```bash +# Double-check function signature (must match ABI exactly) +cast sig "transfer(address,uint256)" # Correct +cast sig "transfer(address,uint)" # Wrong - uint must be uint256 + +# Verify against ABI +jq '.[] | select(.name == "transfer") | .inputs' contracts/USDC.abi.json +``` + +## Next Steps + +Once you have working ABIs: + +1. **Add to version control**: Commit `*.abi.json` files to your repo +2. **Create multiple examples**: One for each protocol (Uniswap, Aave, etc.) +3. **Add to CI**: Include ABI validation in CI/CD pipeline +4. **Document formats**: Add notes about ABI version and generation date + +## Testing Script + +Create `fetch_abis.sh`: + +```bash +#!/bin/bash +set -e + +# Configuration +ETHERSCAN_API_KEY="${ETHERSCAN_API_KEY:-}" +CONTRACTS_DIR="contracts" + +if [ -z "$ETHERSCAN_API_KEY" ]; then + echo "Error: ETHERSCAN_API_KEY not set" + echo "Get a free key at: https://etherscan.io/apis" + exit 1 +fi + +mkdir -p "$CONTRACTS_DIR" + +# Array of (address:name) pairs +CONTRACTS=( + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48:USDC" + "0xc02aaa39b223fe8d0a0e8d0c9f8d0b21d0a0e8d0c:WETH" + "0xdac17f958d2ee523a2206206994597c13d831ec7:USDT" +) + +echo "Fetching ABIs from Etherscan..." +for contract_info in "${CONTRACTS[@]}"; do + IFS=':' read -r address name <<< "$contract_info" + echo " Fetching $name ($address)..." + + response=$(curl -s "https://api.etherscan.io/api" \ + -d "module=contract" \ + -d "action=getabi" \ + -d "address=$address" \ + -d "apikey=$ETHERSCAN_API_KEY") + + # Extract ABI from response + echo "$response" | jq '.result' > "${CONTRACTS_DIR}/${name}.abi.json" + + # Check if we got valid ABI + if jq empty "${CONTRACTS_DIR}/${name}.abi.json" 2>/dev/null; then + echo " ✓ Saved to ${CONTRACTS_DIR}/${name}.abi.json" + else + echo " ✗ Failed to fetch $name" + cat "${CONTRACTS_DIR}/${name}.abi.json" + fi +done + +echo "" +echo "Verifying ABIs..." +for contract_info in "${CONTRACTS[@]}"; do + IFS=':' read -r address name <<< "$contract_info" + count=$(jq '[.[] | select(.type == "function")] | length' "${CONTRACTS_DIR}/${name}.abi.json" 2>/dev/null || echo "0") + echo " $name: $count functions" +done + +echo "" +echo "Running tests..." +cargo test -p visualsign-ethereum --lib embedded_abis +``` + +Run it: +```bash +export ETHERSCAN_API_KEY="YOUR_API_KEY" +chmod +x fetch_abis.sh +./fetch_abis.sh +``` + +Quick test without fetching: +```bash +# Just run existing tests +cargo test -p visualsign-ethereum --lib embedded_abis + +# Or test with cast +cast sig "mint(address,uint256)" +cast calldata "mint(address,uint256)" 0x1234567890123456789012345678901234567890 1000000 +``` diff --git a/src/chain_parsers/visualsign-ethereum/examples/using_abijson/contracts/SimpleToken.abi.json b/src/chain_parsers/visualsign-ethereum/examples/using_abijson/contracts/SimpleToken.abi.json new file mode 100644 index 00000000..22279cc0 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/examples/using_abijson/contracts/SimpleToken.abi.json @@ -0,0 +1,30 @@ +[ + { + "type": "function", + "name": "mint", + "inputs": [ + { + "name": "to", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "burn", + "inputs": [ + { + "name": "amount", + "type": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + } +] diff --git a/src/chain_parsers/visualsign-ethereum/examples/using_abijson/contracts/SimpleToken.sol b/src/chain_parsers/visualsign-ethereum/examples/using_abijson/contracts/SimpleToken.sol new file mode 100644 index 00000000..cdb9c95f --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/examples/using_abijson/contracts/SimpleToken.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title SimpleToken + * @dev A minimal example contract for demonstrating ABI-based visualization + */ +contract SimpleToken { + mapping(address => uint256) public balances; + + /// @dev Mint new tokens for a recipient + /// @param to The recipient address + /// @param amount The amount of tokens to mint + function mint(address to, uint256 amount) external { + balances[to] += amount; + } + + /// @dev Burn tokens from the sender + /// @param amount The amount of tokens to burn + function burn(uint256 amount) external { + require(balances[msg.sender] >= amount, "Insufficient balance"); + balances[msg.sender] -= amount; + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/abi_decoder.rs b/src/chain_parsers/visualsign-ethereum/src/abi_decoder.rs new file mode 100644 index 00000000..622e28f6 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/abi_decoder.rs @@ -0,0 +1,268 @@ +//! ABI-based function call decoder +//! +//! Decodes function calls using compile-time embedded ABIs. +//! Converts function calldata into structured visualizations. + +use std::sync::Arc; + +use alloy_json_abi::{Function, JsonAbi}; +use alloy_primitives::U256; + +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +use crate::registry::ContractRegistry; + +/// Decodes a single Solidity value from calldata +/// Simple implementation that handles common types +fn decode_solidity_value(ty: &str, data: &[u8], offset: &mut usize) -> String { + if ty == "address" { + // Addresses are 32 bytes (20 bytes address padded to 32) + if *offset + 32 <= data.len() { + let bytes = &data[*offset..*offset + 32]; + let addr_bytes = &bytes[12..32]; // Take last 20 bytes + *offset += 32; + return format!("0x{}", hex::encode(addr_bytes)); + } + } else if ty == "uint256" || ty == "uint" { + // uint256 is 32 bytes + if *offset + 32 <= data.len() { + let bytes = &data[*offset..*offset + 32]; + let val = U256::from_be_bytes(bytes.try_into().unwrap_or([0; 32])); + *offset += 32; + return val.to_string(); + } + } else if ty.starts_with("uint") { + // Other uint types - still 32 bytes in encoding + if *offset + 32 <= data.len() { + let bytes = &data[*offset..*offset + 32]; + let val = U256::from_be_bytes(bytes.try_into().unwrap_or([0; 32])); + *offset += 32; + return val.to_string(); + } + } else if ty == "address[]" { + // Dynamic address arrays - offset points to location of array + if *offset + 32 <= data.len() { + let array_offset = + U256::from_be_bytes(data[*offset..*offset + 32].try_into().unwrap_or([0; 32])); + *offset += 32; + + // Read array length at the offset + let array_offset_usize = array_offset.try_into().unwrap_or(0usize); + if array_offset_usize + 32 <= data.len() { + let array_len_val = U256::from_be_bytes( + data[array_offset_usize..array_offset_usize + 32] + .try_into() + .unwrap_or([0; 32]), + ); + let array_len: usize = array_len_val.try_into().unwrap_or(0); + let mut addresses = Vec::new(); + + for i in 0..array_len { + let addr_offset_val: usize = + (U256::from(array_offset_usize) + U256::from(32) + U256::from(i * 32)) + .try_into() + .unwrap_or(0); + if addr_offset_val + 32 <= data.len() { + let addr_bytes = &data[addr_offset_val + 12..addr_offset_val + 32]; // Take last 20 bytes + addresses.push(format!("0x{}", hex::encode(addr_bytes))); + } + } + + if addresses.is_empty() { + return "[]".to_string(); + } else { + return format!("[{}]", addresses.join(", ")); + } + } + } + } else if ty.ends_with("[]") { + // Other dynamic arrays - just show offset for now + if *offset + 32 <= data.len() { + let array_offset_val = + U256::from_be_bytes(data[*offset..*offset + 32].try_into().unwrap_or([0; 32])); + *offset += 32; + return format!("(dynamic array at offset {})", array_offset_val); + } + } + + // Fallback for unknown types + if *offset + 32 <= data.len() { + let hex_val = hex::encode(&data[*offset..(*offset + 32).min(data.len())]); + *offset = (*offset + 32).min(data.len()); + format!("{}: 0x{}", ty, hex_val) + } else { + format!("{}: (insufficient data)", ty) + } +} + +/// Decodes function calls using a JSON ABI +pub struct AbiDecoder { + abi: Arc, +} + +impl AbiDecoder { + /// Creates a new decoder for the given ABI + pub fn new(abi: Arc) -> Self { + Self { abi } + } + + /// Finds a function by its 4-byte selector + fn find_function_by_selector(&self, selector: &[u8; 4]) -> Option<&Function> { + self.abi.functions().find(|f| f.selector() == *selector) + } + + /// Decodes a function call from calldata + /// + /// # Arguments + /// * `calldata` - Complete calldata including 4-byte function selector + /// + /// # Returns + /// * `Ok((function_name, param_hex))` on success + /// * `Err` if selector doesn't match any function + pub fn decode_function( + &self, + calldata: &[u8], + ) -> Result<(String, String), Box> { + if calldata.len() < 4 { + return Err("Calldata too short for function selector".into()); + } + + let selector: [u8; 4] = calldata[0..4].try_into()?; + let function = self + .find_function_by_selector(&selector) + .ok_or("Function selector not found in ABI")?; + + let input_data = &calldata[4..]; + let param_hex = hex::encode(input_data); + + Ok((function.name.clone(), param_hex)) + } + + /// Creates a PreviewLayout visualization for a function call + pub fn visualize( + &self, + calldata: &[u8], + _chain_id: u64, + _registry: Option<&ContractRegistry>, + ) -> Result> { + if calldata.len() < 4 { + return Err("Calldata too short".into()); + } + + let selector: [u8; 4] = calldata[0..4].try_into()?; + let function = self + .find_function_by_selector(&selector) + .ok_or("Function not found")?; + + let input_data = &calldata[4..]; + + let mut expanded_fields = Vec::new(); + let mut offset = 0; + + // Build field for each input parameter + for (i, input) in function.inputs.iter().enumerate() { + let param_name = if !input.name.is_empty() { + input.name.clone() + } else { + format!("param{i}") + }; + + // Simple decoding based on type + let formatted = decode_solidity_value(&input.ty, input_data, &mut offset); + + let field = AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: formatted.clone(), + label: param_name, + }, + text_v2: SignablePayloadFieldTextV2 { text: formatted }, + }, + static_annotation: None, + dynamic_annotation: None, + }; + expanded_fields.push(field); + } + + // Build function signature + let param_types: Vec<&str> = function.inputs.iter().map(|i| i.ty.as_str()).collect(); + let signature = format!("{}({})", function.name, param_types.join(",")); + + let title = SignablePayloadFieldTextV2 { + text: function.name.clone(), + }; + + let subtitle = SignablePayloadFieldTextV2 { + text: signature.clone(), + }; + + Ok(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: signature, + label: function.name.clone(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(title), + subtitle: Some(subtitle), + condensed: None, + expanded: if expanded_fields.is_empty() { + None + } else { + Some(SignablePayloadFieldListLayout { + fields: expanded_fields, + }) + }, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIMPLE_ABI: &str = r#"[ + { + "type": "function", + "name": "transfer", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "amount", "type": "uint256"} + ], + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "approve", + "inputs": [ + {"name": "spender", "type": "address"}, + {"name": "amount", "type": "uint256"} + ], + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable" + } + ]"#; + + #[test] + fn test_decoder_creation() { + let abi: JsonAbi = serde_json::from_str(SIMPLE_ABI).expect("Failed to parse ABI"); + let decoder = AbiDecoder::new(Arc::new(abi)); + + // Should be able to look up functions + let selector = [0xa9, 0x05, 0x9c, 0xbb]; // transfer selector + assert!(decoder.find_function_by_selector(&selector).is_some()); + } + + #[test] + fn test_visualize_error_on_empty_calldata() { + let abi: JsonAbi = serde_json::from_str(SIMPLE_ABI).expect("Failed to parse ABI"); + let decoder = AbiDecoder::new(Arc::new(abi)); + + let result = decoder.visualize(&[], 1, None); + assert!(result.is_err()); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/abi_registry.rs b/src/chain_parsers/visualsign-ethereum/src/abi_registry.rs new file mode 100644 index 00000000..b7c73c5e --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/abi_registry.rs @@ -0,0 +1,239 @@ +//! ABI Registry for compile-time embedded JSON ABIs +//! +//! This module provides a registry for storing and looking up contract ABIs +//! that are embedded at compile-time using `include_str!` macro. +//! +//! ABIs must be embedded at compile-time (like `sol!` macro) for: +//! - Security: ABIs validated during compilation, not runtime +//! - Performance: No file I/O or JSON parsing overhead +//! - Determinism: Same binary always uses same ABIs + +use std::collections::HashMap; +use std::sync::Arc; + +use alloy_json_abi::JsonAbi; +use alloy_primitives::Address; + +/// Type alias for chain ID +pub type ChainId = u64; + +/// Registry for compile-time embedded ABIs +/// +/// Stores parsed JsonAbi instances and maps contract addresses to ABI names. +/// +/// # Example +/// +/// ```ignore +/// const MY_CONTRACT_ABI: &str = include_str!("contract.abi.json"); +/// +/// let mut registry = AbiRegistry::new(); +/// registry.register_abi("MyContract", MY_CONTRACT_ABI)?; +/// registry.map_address(1, address, "MyContract"); +/// +/// let abi = registry.get_abi_for_address(1, address); +/// ``` +#[derive(Clone)] +pub struct AbiRegistry { + /// Maps ABI name -> parsed JsonAbi + abis: Arc>>, + /// Maps (chain_id, contract_address) -> ABI name + address_mappings: Arc>, +} + +impl AbiRegistry { + /// Creates a new empty ABI registry + pub fn new() -> Self { + Self { + abis: Arc::new(HashMap::new()), + address_mappings: Arc::new(HashMap::new()), + } + } + + /// Registers an ABI with the given name + /// + /// The ABI JSON string should be embedded at compile-time using `include_str!`. + /// + /// # Arguments + /// * `name` - Identifier for this ABI (e.g., "SimpleToken", "UniswapV3") + /// * `abi_json` - JSON string containing the ABI definition + /// + /// # Returns + /// * `Ok(())` if ABI was successfully parsed and registered + /// * `Err` if JSON parsing fails + /// + /// # Example + /// + /// ```ignore + /// let mut registry = AbiRegistry::new(); + /// const ABI_JSON: &str = include_str!("abi.json"); + /// registry.register_abi("MyContract", ABI_JSON)?; + /// ``` + pub fn register_abi( + &mut self, + name: &str, + abi_json: &str, + ) -> Result<(), Box> { + let abi = serde_json::from_str::(abi_json)?; + Arc::get_mut(&mut self.abis) + .expect("ABI map should be mutable") + .insert(name.to_string(), Arc::new(abi)); + Ok(()) + } + + /// Maps a contract address to an ABI name for a specific chain + /// + /// # Arguments + /// * `chain_id` - The blockchain chain ID (e.g., 1 for Ethereum Mainnet) + /// * `address` - The contract address + /// * `abi_name` - The ABI name (must be previously registered) + pub fn map_address(&mut self, chain_id: ChainId, address: Address, abi_name: &str) { + Arc::get_mut(&mut self.address_mappings) + .expect("Address mappings should be mutable") + .insert((chain_id, address), abi_name.to_string()); + } + + /// Gets the ABI for a specific contract address on a given chain + /// + /// # Arguments + /// * `chain_id` - The blockchain chain ID + /// * `address` - The contract address + /// + /// # Returns + /// * `Some(Arc)` if address is mapped and ABI is registered + /// * `None` if address is not mapped or ABI not found + pub fn get_abi_for_address(&self, chain_id: ChainId, address: Address) -> Option> { + let abi_name = self.address_mappings.get(&(chain_id, address))?; + self.abis.get(abi_name).cloned() + } + + /// Gets an ABI by name + /// + /// # Arguments + /// * `name` - The ABI name (as registered with `register_abi`) + /// + /// # Returns + /// * `Some(Arc)` if ABI is registered + /// * `None` if ABI not found + pub fn get_abi(&self, name: &str) -> Option> { + self.abis.get(name).cloned() + } + + /// Lists all registered ABI names + pub fn list_abis(&self) -> Vec<&str> { + self.abis.keys().map(|s| s.as_str()).collect() + } + + /// Lists all address mappings for a given chain + pub fn list_mappings_for_chain(&self, chain_id: ChainId) -> Vec<(Address, &str)> { + self.address_mappings + .iter() + .filter(|((cid, _), _)| *cid == chain_id) + .map(|((_, addr), name)| (*addr, name.as_str())) + .collect() + } +} + +impl Default for AbiRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_ABI: &str = r#"[ + { + "type": "function", + "name": "transfer", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "amount", "type": "uint256"} + ], + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable" + } + ]"#; + + #[test] + fn test_register_and_retrieve_abi() { + let mut registry = AbiRegistry::new(); + registry + .register_abi("TestToken", TEST_ABI) + .expect("Failed to register ABI"); + + let abi = registry.get_abi("TestToken"); + assert!(abi.is_some()); + } + + #[test] + fn test_invalid_json_fails() { + let mut registry = AbiRegistry::new(); + let result = registry.register_abi("Invalid", "not valid json"); + assert!(result.is_err()); + } + + #[test] + fn test_address_mapping() { + let mut registry = AbiRegistry::new(); + registry + .register_abi("TestToken", TEST_ABI) + .expect("Failed to register ABI"); + + let addr = "0x1234567890123456789012345678901234567890" + .parse::
() + .unwrap(); + registry.map_address(1, addr, "TestToken"); + + let abi = registry.get_abi_for_address(1, addr); + assert!(abi.is_some()); + } + + #[test] + fn test_address_not_mapped() { + let registry = AbiRegistry::new(); + let addr = "0x1234567890123456789012345678901234567890" + .parse::
() + .unwrap(); + + let abi = registry.get_abi_for_address(1, addr); + assert!(abi.is_none()); + } + + #[test] + fn test_different_chains_separate() { + let mut registry = AbiRegistry::new(); + registry + .register_abi("TestToken", TEST_ABI) + .expect("Failed to register ABI"); + + let addr = "0x1234567890123456789012345678901234567890" + .parse::
() + .unwrap(); + + registry.map_address(1, addr, "TestToken"); + registry.map_address(137, addr, "TestToken"); + + // Same address on different chains + assert!(registry.get_abi_for_address(1, addr).is_some()); + assert!(registry.get_abi_for_address(137, addr).is_some()); + assert!(registry.get_abi_for_address(42161, addr).is_none()); + } + + #[test] + fn test_list_abis() { + let mut registry = AbiRegistry::new(); + registry + .register_abi("TokenA", TEST_ABI) + .expect("Failed to register"); + registry + .register_abi("TokenB", TEST_ABI) + .expect("Failed to register"); + + let abis = registry.list_abis(); + assert_eq!(abis.len(), 2); + assert!(abis.contains(&"TokenA")); + assert!(abis.contains(&"TokenB")); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/context.rs b/src/chain_parsers/visualsign-ethereum/src/context.rs new file mode 100644 index 00000000..5f539525 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/context.rs @@ -0,0 +1,310 @@ +use crate::abi_registry::AbiRegistry; +use alloy_primitives::Address; +use std::sync::Arc; +use visualsign::registry::LayeredRegistry; + +/// Backend registry for managing contract ABIs and metadata +pub trait RegistryBackend: Send + Sync { + /// Format a token amount using the registry's token information + fn format_token_amount(&self, amount: u128, decimals: u8) -> String; +} + +/// Registry for managing contract visualizers +pub trait VisualizerRegistry: Send + Sync { + /// Retrieves a visualizer by contract type + /// + /// # Arguments + /// * `contract_type` - The contract type to look up + /// + /// # Returns + /// * `Some(&dyn ContractVisualizer)` if found + /// * `None` if not found + fn get_visualizer( + &self, + contract_type: &str, + ) -> Option<&dyn crate::visualizer::ContractVisualizer>; +} + +/// Arguments for creating a new VisualizerContext +/// This is safer than making a new() with many arguments directly +/// which clippy doesn't like and is bug prone to missing fields or mixing them +pub struct VisualizerContextParams { + pub chain_id: u64, + pub sender: Address, + pub current_contract: Address, + pub calldata: Vec, + pub registry: Arc, + pub visualizers: Arc, + pub abi_registry: Option>, +} + +/// Context for visualizing Ethereum transactions and calls +#[derive(Clone)] +pub struct VisualizerContext { + /// The chain ID for the network + pub chain_id: u64, + /// The sender of the transaction + pub sender: Address, + /// The current contract being visualized + pub current_contract: Address, + /// The depth of nested calls (0 for top-level) + pub call_depth: usize, + /// The raw calldata for the current call, shared via Arc + pub calldata: Arc<[u8]>, + /// Registry containing contract ABI and metadata + pub registry: Arc, + /// Registry containing contract visualizers + pub visualizers: Arc, + /// Optional layered registry of ABIs for dynamic decoding (wallet-provided + compile-time) + pub abi_registry: Option>, +} + +impl VisualizerContext { + /// Creates a new, top-level visualizer context + pub fn new(params: VisualizerContextParams) -> Self { + Self { + chain_id: params.chain_id, + sender: params.sender, + current_contract: params.current_contract, + call_depth: 0, // Set defaults inside the constructor + calldata: Arc::from(params.calldata), + registry: params.registry, + visualizers: params.visualizers, + abi_registry: params.abi_registry, + } + } + + /// Creates a child context for a nested call with incremented call_depth + pub fn for_nested_call( + &self, + current_contract: Address, + calldata: Vec, // Still takes a Vec, as it's new data + ) -> Self { + Self { + chain_id: self.chain_id, + sender: self.sender, + current_contract, + call_depth: self.call_depth + 1, + calldata: Arc::from(calldata), // Convert to Arc + registry: self.registry.clone(), + visualizers: self.visualizers.clone(), + abi_registry: self.abi_registry.clone(), + } + } + + /// Helper method to format token amounts using the registry + pub fn format_token_amount(&self, amount: u128, decimals: u8) -> String { + self.registry.format_token_amount(amount, decimals) + } + + /// Retrieves a visualizer by contract type from the registry + /// + /// Enables visualizers to delegate to other visualizers during execution. + /// + /// # Arguments + /// * `contract_type` - The contract type identifier + /// + /// # Returns + /// * `Some(&dyn ContractVisualizer)` if found + /// * `None` if not registered + pub fn get_visualizer( + &self, + contract_type: &str, + ) -> Option<&dyn crate::visualizer::ContractVisualizer> { + self.visualizers.get_visualizer(contract_type) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mock implementation of RegistryBackend for testing + struct MockRegistryBackend; + + impl RegistryBackend for MockRegistryBackend { + fn format_token_amount(&self, amount: u128, decimals: u8) -> String { + // Use Alloy's format_units utility + alloy_primitives::utils::format_units(amount, decimals) + .unwrap_or_else(|_| amount.to_string()) + } + } + + /// Mock implementation of VisualizerRegistry for testing + struct MockVisualizerRegistry; + + impl VisualizerRegistry for MockVisualizerRegistry { + fn get_visualizer( + &self, + _contract_type: &str, + ) -> Option<&dyn crate::visualizer::ContractVisualizer> { + None + } + } + + #[test] + fn test_visualizer_context_creation() { + let registry = Arc::new(MockRegistryBackend); + let visualizers = Arc::new(MockVisualizerRegistry); + let sender = "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(); + let contract = "0xabcdefabcdefabcdefabcdefabcdefabcdefabce" + .parse() + .unwrap(); + let calldata = vec![0x12, 0x34, 0x56, 0x78]; + + let params = VisualizerContextParams { + chain_id: 1, + sender, + current_contract: contract, + calldata: calldata.clone(), + registry: registry.clone(), + visualizers: visualizers.clone(), + abi_registry: None, + }; + let context = VisualizerContext::new(params); + + assert_eq!(context.chain_id, 1); + assert_eq!(context.call_depth, 0); + assert_eq!(context.sender, sender); + assert_eq!(context.current_contract, contract); + assert_eq!(context.calldata.len(), 4); + assert_eq!(context.calldata.as_ref(), calldata.as_slice()); + } + + #[test] + fn test_visualizer_context_clone() { + let registry = Arc::new(MockRegistryBackend); + let visualizers = Arc::new(MockVisualizerRegistry); + let sender: Address = "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(); + let contract: Address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabce" + .parse() + .unwrap(); + let calldata = vec![0x12, 0x34, 0x56, 0x78]; + + let params = VisualizerContextParams { + chain_id: 1, + sender, + current_contract: contract, + calldata: calldata.clone(), + registry: registry.clone(), + visualizers: visualizers.clone(), + abi_registry: None, + }; + let context = VisualizerContext::new(params); + + let cloned = context.clone(); + + assert_eq!(cloned.chain_id, context.chain_id); + assert_eq!(cloned.call_depth, context.call_depth); + assert_eq!(cloned.sender, context.sender); + assert_eq!(cloned.current_contract, context.current_contract); + + // Test that the Arcs point to the same data and the data is correct + assert_eq!(cloned.calldata, context.calldata); + assert_eq!(cloned.calldata.as_ref(), calldata.as_slice()); + // Test that cloning the Arc was cheap (pointer comparison) + assert!(Arc::ptr_eq(&cloned.calldata, &context.calldata)); + assert!(Arc::ptr_eq(&cloned.registry, &context.registry)); + } + + #[test] + fn test_for_nested_call() { + let registry = Arc::new(MockRegistryBackend); + let visualizers = Arc::new(MockVisualizerRegistry); + let sender: Address = "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(); + let contract1: Address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabce" + .parse() + .unwrap(); + let contract2: Address = "0xfedcbafedcbafedcbafedcbafedcbafedcbafeda" + .parse() + .unwrap(); + let calldata1 = vec![0x12, 0x34, 0x56, 0x78]; + let calldata2 = vec![0xaa, 0xbb, 0xcc, 0xdd]; + let params = VisualizerContextParams { + chain_id: 1, + sender, + current_contract: contract1, + calldata: calldata1.clone(), + registry: registry.clone(), + visualizers: visualizers.clone(), + abi_registry: None, + }; + let context = VisualizerContext::new(params); + + let nested = context.for_nested_call(contract2, calldata2.clone()); + + assert_eq!(nested.chain_id, context.chain_id); + assert_eq!(nested.sender, context.sender); + assert_eq!(nested.current_contract, contract2); + assert_eq!(nested.call_depth, 1); + assert_eq!(nested.calldata.as_ref(), calldata2.as_slice()); + } + + #[test] + fn test_format_token_amount() { + let registry = Arc::new(MockRegistryBackend); + let visualizers = Arc::new(MockVisualizerRegistry); + + let params = VisualizerContextParams { + chain_id: 1, + sender: Address::ZERO, + current_contract: Address::ZERO, + calldata: vec![], + registry: registry.clone(), + visualizers: visualizers.clone(), + abi_registry: None, + }; + let context = VisualizerContext::new(params); + + // Test with 18 decimals (like ETH/USDC) + assert_eq!( + context.format_token_amount(1000000000000000000, 18), + "1.000000000000000000" + ); + assert_eq!( + context.format_token_amount(1500000000000000000, 18), + "1.500000000000000000" + ); + + // Test with 6 decimals (like USDT) + assert_eq!(context.format_token_amount(1000000, 6), "1.000000"); + assert_eq!(context.format_token_amount(1500000, 6), "1.500000"); + } + + #[test] + fn test_nested_call_increments_depth() { + let registry = Arc::new(MockRegistryBackend); + let visualizers = Arc::new(MockVisualizerRegistry); + let contract1 = "0xabcdefabcdefabcdefabcdefabcdefabcdefabce" + .parse() + .unwrap(); + let contract2 = "0xfedcbafedcbafedcbafedcbafedcbafedcbafeda" + .parse() + .unwrap(); + let contract3 = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let params = VisualizerContextParams { + chain_id: 1, + sender: Address::ZERO, + current_contract: contract1, + calldata: vec![], + registry: registry.clone(), + visualizers: visualizers.clone(), + abi_registry: None, + }; + let context = VisualizerContext::new(params); + + let nested1 = context.for_nested_call(contract2, vec![]); + assert_eq!(nested1.call_depth, 1); + + let nested2 = nested1.for_nested_call(contract3, vec![]); + assert_eq!(nested2.call_depth, 2); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/core/dynamic_abi.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/dynamic_abi.rs new file mode 100644 index 00000000..ce375cfc --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/core/dynamic_abi.rs @@ -0,0 +1,73 @@ +//! Dynamic ABI visualizer +//! +//! Provides visualization for contract calls using compile-time embedded ABI JSON. +//! Falls back to dynamic decoding when built-in visualizers don't recognize the function. + +use std::sync::Arc; + +use alloy_json_abi::JsonAbi; + +use visualsign::SignablePayloadField; + +use crate::abi_decoder::AbiDecoder; +use crate::registry::ContractRegistry; +use crate::visualizer::CalldataVisualizer; + +/// Visualizer for dynamically decoded ABI-based function calls +pub struct DynamicAbiVisualizer { + decoder: AbiDecoder, +} + +impl DynamicAbiVisualizer { + /// Creates a new dynamic visualizer from an ABI + pub fn new(abi: Arc) -> Self { + Self { + decoder: AbiDecoder::new(abi), + } + } +} + +impl CalldataVisualizer for DynamicAbiVisualizer { + fn visualize_calldata( + &self, + calldata: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + self.decoder.visualize(calldata, chain_id, registry).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_ABI: &str = r#"[ + { + "type": "function", + "name": "transfer", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "amount", "type": "uint256"} + ], + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable" + } + ]"#; + + #[test] + fn test_dynamic_visualizer_creation() { + let abi: JsonAbi = serde_json::from_str(TEST_ABI).expect("Failed to parse ABI"); + let _visualizer = DynamicAbiVisualizer::new(Arc::new(abi)); + } + + #[test] + fn test_calldata_visualizer_trait() { + let abi: JsonAbi = serde_json::from_str(TEST_ABI).expect("Failed to parse ABI"); + let visualizer = DynamicAbiVisualizer::new(Arc::new(abi)); + + // Test with empty calldata - should fail gracefully + let result = visualizer.visualize_calldata(&[], 1, None); + assert!(result.is_none()); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/erc20.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/erc20.rs similarity index 100% rename from src/chain_parsers/visualsign-ethereum/src/contracts/erc20.rs rename to src/chain_parsers/visualsign-ethereum/src/contracts/core/erc20.rs diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/core/erc721.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/erc721.rs new file mode 100644 index 00000000..f4a9a56f --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/core/erc721.rs @@ -0,0 +1,72 @@ +//! ERC-721 NFT Standard Visualizer +//! +//! Provides visualization for common ERC-721 functions. +//! +//! Reference: + +#![allow(unused_imports)] + +use alloy_sol_types::{SolCall, sol}; +use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; + +// ERC-721 interface +sol! { + interface IERC721 { + function balanceOf(address owner) external view returns (uint256 balance); + function ownerOf(uint256 tokenId) external view returns (address owner); + function safeTransferFrom(address from, address to, uint256 tokenId) external; + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; + function transferFrom(address from, address to, uint256 tokenId) external; + function approve(address to, uint256 tokenId) external; + function setApprovalForAll(address operator, bool approved) external; + function getApproved(uint256 tokenId) external view returns (address operator); + function isApprovedForAll(address owner, address operator) external view returns (bool); + } +} + +/// Visualizer for ERC-721 NFT contract calls +pub struct ERC721Visualizer; + +impl ERC721Visualizer { + /// Attempts to decode and visualize ERC-721 function calls + /// + /// # Arguments + /// * `input` - The calldata bytes + /// + /// # Returns + /// * `Some(field)` if a recognized ERC-721 function is found + /// * `None` if the input doesn't match any ERC-721 function + pub fn visualize_tx_commands(&self, input: &[u8]) -> Option { + if input.len() < 4 { + return None; + } + + // TODO: Implement ERC-721 function decoding + // - transferFrom(address,address,uint256) + // - safeTransferFrom variants + // - approve(address,uint256) + // - setApprovalForAll(address,bool) + // + // For now, return None to use fallback visualizer + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_visualize_empty_input() { + let visualizer = ERC721Visualizer; + assert_eq!(visualizer.visualize_tx_commands(&[]), None); + } + + #[test] + fn test_visualize_too_short() { + let visualizer = ERC721Visualizer; + assert_eq!(visualizer.visualize_tx_commands(&[0x01, 0x02]), None); + } + + // TODO: Add tests for each ERC-721 function once implemented +} diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/core/fallback.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/fallback.rs new file mode 100644 index 00000000..98dc4ac9 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/core/fallback.rs @@ -0,0 +1,93 @@ +//! Fallback visualizer for unknown/unhandled contract calls +//! +//! This visualizer acts as a catch-all for contract calls that don't have +//! specific visualizers. It displays the raw calldata as hex. + +use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; + +/// Fallback visualizer that displays raw hex data for unknown contracts +pub struct FallbackVisualizer; + +impl FallbackVisualizer { + /// Creates a new fallback visualizer + pub fn new() -> Self { + Self + } + + /// Visualizes unknown contract calldata as hex + /// + /// # Arguments + /// * `input` - The raw calldata bytes + /// + /// # Returns + /// A SignablePayloadField containing the hex-encoded calldata + pub fn visualize_hex(&self, input: &[u8]) -> SignablePayloadField { + let hex_data = if input.is_empty() { + "0x".to_string() + } else { + format!("0x{}", hex::encode(input)) + }; + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: hex_data.clone(), + label: "Input Data".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: hex_data }, + } + } +} + +impl Default for FallbackVisualizer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_visualize_empty_input() { + let visualizer = FallbackVisualizer::new(); + let field = visualizer.visualize_hex(&[]); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + assert_eq!(text_v2.text, "0x"); + } + _ => panic!("Expected TextV2 field"), + } + } + + #[test] + fn test_visualize_hex_data() { + let visualizer = FallbackVisualizer::new(); + let input = vec![0x12, 0x34, 0x56, 0x78, 0xab, 0xcd, 0xef]; + let field = visualizer.visualize_hex(&input); + + match field { + SignablePayloadField::TextV2 { text_v2, common } => { + assert_eq!(text_v2.text, "0x12345678abcdef"); + assert_eq!(common.label, "Input Data"); + } + _ => panic!("Expected TextV2 field"), + } + } + + #[test] + fn test_visualize_function_selector() { + let visualizer = FallbackVisualizer::new(); + // Simulate a function call with 4-byte selector + let input = vec![0xa9, 0x05, 0x9c, 0xbb]; + let field = visualizer.visualize_hex(&input); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + assert_eq!(text_v2.text, "0xa9059cbb"); + } + _ => panic!("Expected TextV2 field"), + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/core/mod.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/mod.rs new file mode 100644 index 00000000..b3469bd6 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/core/mod.rs @@ -0,0 +1,11 @@ +//! Core contract standards (ERC20, ERC721, etc.) + +pub mod dynamic_abi; +pub mod erc20; +pub mod erc721; +pub mod fallback; + +pub use dynamic_abi::DynamicAbiVisualizer; +pub use erc20::ERC20Visualizer; +pub use erc721::ERC721Visualizer; +pub use fallback::FallbackVisualizer; diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/mod.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/mod.rs index ba5f478e..f326cb4f 100644 --- a/src/chain_parsers/visualsign-ethereum/src/contracts/mod.rs +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/mod.rs @@ -1,2 +1,10 @@ -pub mod erc20; -pub mod uniswap; +//! Generic contract standards +//! +//! This module contains generic contract standards that are used across +//! multiple protocols (e.g., ERC20, ERC721, ERC1155). +//! +//! Protocol-specific contracts are located in the `protocols` module. + +pub mod core; + +pub use core::*; diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/uniswap.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/uniswap.rs deleted file mode 100644 index 74a64992..00000000 --- a/src/chain_parsers/visualsign-ethereum/src/contracts/uniswap.rs +++ /dev/null @@ -1,494 +0,0 @@ -use alloy_sol_types::{SolCall as _, sol}; -use chrono::{TimeZone, Utc}; -use num_enum::TryFromPrimitive; -use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; - -// From: https://github.com/Uniswap/universal-router/blob/main/contracts/interfaces/IUniversalRouter.sol -sol! { - interface IUniversalRouter { - /// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired. - /// @param commands A set of concatenated commands, each 1 byte in length - /// @param inputs An array of byte strings containing abi encoded inputs for each command - /// @param deadline The deadline by which the transaction must be executed - function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; - } -} - -// From: https://github.com/Uniswap/universal-router/blob/main/contracts/libraries/Commands.sol -#[derive(Copy, Clone, Debug, Eq, PartialEq, TryFromPrimitive)] -#[repr(u8)] -pub enum Command { - V3SwapExactIn = 0x00, - V3SwapExactOut = 0x01, - Permit2TransferFrom = 0x02, - Permit2PermitBatch = 0x03, - Sweep = 0x04, - Transfer = 0x05, - PayPortion = 0x06, - - V2SwapExactIn = 0x08, - V2SwapExactOut = 0x09, - Permit2Permit = 0x0a, - WrapEth = 0x0b, - UnwrapWeth = 0x0c, - Permit2TransferFromBatch = 0x0d, - BalanceCheckErc20 = 0x0e, - - V4Swap = 0x10, - V3PositionManagerPermit = 0x11, - V3PositionManagerCall = 0x12, - V4InitializePool = 0x13, - V4PositionManagerCall = 0x14, - - ExecuteSubPlan = 0x21, -} - -fn map_commands(raw: &[u8]) -> Vec { - let mut out = Vec::with_capacity(raw.len()); - for &b in raw { - if let Ok(cmd) = Command::try_from(b) { - out.push(cmd); - } - } - out -} - -pub struct UniswapV4Visualizer {} - -impl UniswapV4Visualizer { - pub fn visualize_tx_commands(&self, input: &[u8]) -> Option { - if input.len() < 4 { - return None; - } - if let Ok(call) = IUniversalRouter::executeCall::abi_decode(input) { - let deadline_val: i64 = match call.deadline.try_into() { - Ok(val) => val, - Err(_) => return None, - }; - let deadline = if deadline_val > 0 { - Utc.timestamp_opt(deadline_val, 0) - .single() - .map(|dt| dt.to_string()) - } else { - None - }; - let mapped = map_commands(&call.commands.0); - let mut detail_fields = Vec::new(); - - for (i, cmd) in mapped.iter().enumerate() { - let input_hex = call - .inputs - .get(i) - .map(|b| format!("0x{}", hex::encode(&b.0))) - .unwrap_or_else(|| "None".to_string()); // TODO: decode into readable values - - detail_fields.push(SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: format!("{cmd:?} input: {input_hex}"), - label: format!("Command {}", i + 1), - }, - preview_layout: visualsign::SignablePayloadFieldPreviewLayout { - title: Some(visualsign::SignablePayloadFieldTextV2 { - text: format!("{cmd:?}"), - }), - subtitle: Some(visualsign::SignablePayloadFieldTextV2 { - text: format!("Input: {input_hex}"), - }), - condensed: None, - expanded: None, - }, - }); - } - - // Deadline field (optional) - if let Some(dl) = &deadline { - detail_fields.push(SignablePayloadField::TextV2 { - common: SignablePayloadFieldCommon { - fallback_text: dl.clone(), - label: "Deadline".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { text: dl.clone() }, - }); - } - - return Some(SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: if let Some(dl) = &deadline { - format!( - "Universal Router Execute: {} commands ({:?}), deadline {}", - mapped.len(), - mapped, - dl - ) - } else { - format!( - "Universal Router Execute: {} commands ({:?})", - mapped.len(), - mapped - ) - }, - label: "Universal Router".to_string(), - }, - preview_layout: visualsign::SignablePayloadFieldPreviewLayout { - title: Some(visualsign::SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: if let Some(dl) = &deadline { - Some(visualsign::SignablePayloadFieldTextV2 { - text: format!("{} commands, deadline {}", mapped.len(), dl), - }) - } else { - Some(visualsign::SignablePayloadFieldTextV2 { - text: format!("{} commands", mapped.len()), - }) - }, - condensed: None, - expanded: Some(visualsign::SignablePayloadFieldListLayout { - fields: detail_fields - .into_iter() - .map(|f| visualsign::AnnotatedPayloadField { - signable_payload_field: f, - static_annotation: None, - dynamic_annotation: None, - }) - .collect(), - }), - }, - }); - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::{Bytes, U256}; - use visualsign::{ - AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, - SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, - SignablePayloadFieldTextV2, - }; - - fn encode_execute_call(commands: &[u8], inputs: Vec>, deadline: u64) -> Vec { - let inputs_bytes = inputs.into_iter().map(Bytes::from).collect::>(); - IUniversalRouter::executeCall { - commands: Bytes::from(commands.to_vec()), - inputs: inputs_bytes, - deadline: U256::from(deadline), - } - .abi_encode() - } - - #[test] - fn test_visualize_tx_commands_empty_input() { - assert_eq!(UniswapV4Visualizer {}.visualize_tx_commands(&[]), None); - assert_eq!( - UniswapV4Visualizer {}.visualize_tx_commands(&[0x01, 0x02, 0x03]), - None - ); - } - - #[test] - fn test_visualize_tx_commands_invalid_deadline() { - // deadline is not convertible to i64 (u64::MAX) - let input = encode_execute_call(&[0x00], vec![vec![0x01, 0x02]], u64::MAX); - assert_eq!(UniswapV4Visualizer {}.visualize_tx_commands(&input), None); - } - - #[test] - fn test_visualize_tx_commands_single_command_with_deadline() { - let commands = vec![Command::V3SwapExactIn as u8]; - let inputs = vec![vec![0xde, 0xad, 0xbe, 0xef]]; - let deadline = 1_700_000_000u64; // 2023-11-13T12:26:40Z - let input = encode_execute_call(&commands, inputs.clone(), deadline); - - // Build expected field - let dt = chrono::Utc.timestamp_opt(deadline as i64, 0).unwrap(); - let deadline_str = dt.to_string(); - - assert_eq!( - UniswapV4Visualizer {} - .visualize_tx_commands(&input) - .unwrap(), - SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: format!( - "Universal Router Execute: 1 commands ([V3SwapExactIn]), deadline {deadline_str}" - ), - label: "Universal Router".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: format!("1 commands, deadline {deadline_str}"), - }), - condensed: None, - expanded: Some(SignablePayloadFieldListLayout { - fields: vec![ - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "V3SwapExactIn input: 0xdeadbeef" - .to_string(), - label: "Command 1".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "V3SwapExactIn".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0xdeadbeef".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::TextV2 { - common: SignablePayloadFieldCommon { - fallback_text: deadline_str.clone(), - label: "Deadline".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { - text: deadline_str.clone(), - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - ], - }), - }, - } - ); - } - - #[test] - fn test_visualize_tx_commands_multiple_commands_no_deadline() { - let commands = vec![ - Command::V3SwapExactIn as u8, - Command::Transfer as u8, - Command::WrapEth as u8, - ]; - let inputs = vec![vec![0x01, 0x02], vec![0x03, 0x04, 0x05], vec![0x06]]; - let deadline = 0u64; - let input = encode_execute_call(&commands, inputs.clone(), deadline); - - assert_eq!( - UniswapV4Visualizer {} - .visualize_tx_commands(&input) - .unwrap(), - SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: - "Universal Router Execute: 3 commands ([V3SwapExactIn, Transfer, WrapEth])" - .to_string(), - label: "Universal Router".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "3 commands".to_string(), - }), - condensed: None, - expanded: Some(SignablePayloadFieldListLayout { - fields: vec![ - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "V3SwapExactIn input: 0x0102".to_string(), - label: "Command 1".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "V3SwapExactIn".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0x0102".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "Transfer input: 0x030405".to_string(), - label: "Command 2".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Transfer".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0x030405".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "WrapEth input: 0x06".to_string(), - label: "Command 3".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "WrapEth".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0x06".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - ], - }), - }, - } - ); - } - - #[test] - fn test_visualize_tx_commands_command_without_input() { - // Only one command, but no input for it - let commands = vec![Command::Sweep as u8]; - let inputs = vec![]; // No input - let deadline = 1_700_000_000u64; - let input = encode_execute_call(&commands, inputs.clone(), deadline); - - let dt = chrono::Utc.timestamp_opt(deadline as i64, 0).unwrap(); - let deadline_str = dt.to_string(); - - assert_eq!( - UniswapV4Visualizer {} - .visualize_tx_commands(&input) - .unwrap(), - SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: format!( - "Universal Router Execute: 1 commands ([Sweep]), deadline {deadline_str}", - ), - label: "Universal Router".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: format!("1 commands, deadline {deadline_str}"), - }), - condensed: None, - expanded: Some(SignablePayloadFieldListLayout { - fields: vec![ - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "Sweep input: None".to_string(), - label: "Command 1".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Sweep".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: None".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::TextV2 { - common: SignablePayloadFieldCommon { - fallback_text: deadline_str.clone(), - label: "Deadline".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { - text: deadline_str.clone(), - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - ], - }), - }, - } - ); - } - - #[test] - fn test_visualize_tx_commands_unrecognized_command() { - // 0xff is not a valid Command, so it should be skipped - let commands = vec![0xff, Command::Transfer as u8]; - let inputs = vec![vec![0x01], vec![0x02]]; - let deadline = 0u64; - let input = encode_execute_call(&commands, inputs.clone(), deadline); - - assert_eq!( - UniswapV4Visualizer {} - .visualize_tx_commands(&input) - .unwrap(), - SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "Universal Router Execute: 1 commands ([Transfer])".to_string(), - label: "Universal Router".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "1 commands".to_string(), - }), - condensed: None, - expanded: Some(SignablePayloadFieldListLayout { - fields: vec![AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "Transfer input: 0x01".to_string(), - label: "Command 1".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Transfer".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0x01".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }], - }), - }, - } - ); - } -} diff --git a/src/chain_parsers/visualsign-ethereum/src/embedded_abis.rs b/src/chain_parsers/visualsign-ethereum/src/embedded_abis.rs new file mode 100644 index 00000000..871e3df3 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/embedded_abis.rs @@ -0,0 +1,289 @@ +//! Helpers for embedding and managing compile-time ABI JSON files +//! +//! This module provides utilities for applications to register compile-time +//! embedded ABI JSON files for custom contract visualization. +//! +//! # Example: Embedding an ABI at compile-time +//! +//! ```ignore +//! use visualsign_ethereum::embedded_abis::register_embedded_abi; +//! use visualsign_ethereum::abi_registry::AbiRegistry; +//! +//! const MY_CONTRACT_ABI: &str = include_str!("contracts/MyContract.abi.json"); +//! +//! fn main() -> Result<(), Box> { +//! let mut registry = AbiRegistry::new(); +//! register_embedded_abi(&mut registry, "MyContract", MY_CONTRACT_ABI)?; +//! registry.map_address(1, "0x1234...".parse()?, "MyContract"); +//! Ok(()) +//! } +//! ``` + +use alloy_primitives::Address; + +use crate::abi_registry::AbiRegistry; + +/// Error type for ABI embedding operations +#[derive(Debug, thiserror::Error)] +pub enum AbiEmbeddingError { + /// Invalid JSON in ABI file + #[error("Invalid ABI JSON: {0}")] + InvalidJson(String), + /// File I/O error + #[error("Failed to read ABI file: {0}")] + FileError(String), +} + +/// Registers a compile-time embedded ABI JSON string with an AbiRegistry +/// +/// # Arguments +/// * `registry` - The ABI registry to register with (mutable) +/// * `name` - Canonical name for this ABI (e.g., "Uniswap V3 Router") +/// * `abi_json` - Embedded ABI JSON string from `include_str!()` +/// +/// # Returns +/// * `Ok(())` on successful registration +/// * `Err(AbiEmbeddingError)` if JSON is invalid +/// +/// # Example +/// ```ignore +/// const TOKEN_ABI: &str = include_str!("SimpleToken.abi.json"); +/// register_embedded_abi(&mut registry, "SimpleToken", TOKEN_ABI)?; +/// ``` +pub fn register_embedded_abi( + registry: &mut AbiRegistry, + name: &str, + abi_json: &str, +) -> Result<(), AbiEmbeddingError> { + registry + .register_abi(name, abi_json) + .map_err(|e| AbiEmbeddingError::InvalidJson(e.to_string())) +} + +/// Maps a contract address to a registered ABI name for a specific chain +/// +/// # Arguments +/// * `registry` - The ABI registry (mutable) +/// * `chain_id` - Blockchain network ID (1 for Ethereum mainnet, etc.) +/// * `address` - Contract address to map +/// * `abi_name` - Name of previously registered ABI +/// +/// # Example +/// ```ignore +/// let my_address: Address = "0x1234567890123456789012345678901234567890".parse()?; +/// map_abi_address(&mut registry, 1, my_address, "SimpleToken"); +/// ``` +pub fn map_abi_address( + registry: &mut AbiRegistry, + chain_id: u64, + address: Address, + abi_name: &str, +) { + registry.map_address(chain_id, address, abi_name); +} + +/// Parses an ABI address mapping string like "AbiName:0xAddress" +/// +/// # Format +/// The input should be in the format: `abi_name:0xaddress` +/// +/// # Arguments +/// * `mapping_str` - String in format "AbiName:0xAddress" +/// +/// # Returns +/// * `Some((abi_name, address))` if valid +/// * `None` if format is invalid or address fails to parse +/// +/// # Example +/// ```ignore +/// if let Some((name, addr)) = parse_abi_address_mapping("SimpleToken:0x1234...") { +/// registry.map_address(chain_id, addr, name); +/// } +/// ``` +pub fn parse_abi_address_mapping(mapping_str: &str) -> Option<(&str, Address)> { + let (abi_name, addr_str) = mapping_str.split_once(':')?; + let address = addr_str.parse().ok()?; + Some((abi_name, address)) +} + +/// Loads an ABI JSON from a file and registers it with the given name +/// +/// # Arguments +/// * `registry` - The ABI registry to register with (mutable) +/// * `name` - Name for this ABI (e.g., "MyToken") +/// * `file_path` - Path to the ABI JSON file +/// +/// # Returns +/// * `Ok(())` on successful registration +/// * `Err(AbiEmbeddingError)` if file cannot be read or JSON is invalid +pub fn load_abi_from_file( + registry: &mut AbiRegistry, + name: &str, + file_path: &str, +) -> Result<(), AbiEmbeddingError> { + let abi_json = std::fs::read_to_string(file_path) + .map_err(|e| AbiEmbeddingError::FileError(format!("{}: {}", file_path, e)))?; + register_embedded_abi(registry, name, &abi_json) +} + +/// Loads an ABI from a file and maps it to an address +/// +/// Convenience function that combines loading and address mapping +pub fn load_and_map_abi( + registry: &mut AbiRegistry, + name: &str, + file_path: &str, + chain_id: u64, + address_str: &str, +) -> Result<(), Box> { + load_abi_from_file(registry, name, file_path)?; + let address = address_str.parse::
()?; + registry.map_address(chain_id, address, name); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_ABI: &str = r#"[ + { + "type": "function", + "name": "transfer", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "amount", "type": "uint256"} + ], + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable" + } + ]"#; + + #[test] + fn test_register_embedded_abi_valid() { + let mut registry = AbiRegistry::new(); + let result = register_embedded_abi(&mut registry, "TestToken", TEST_ABI); + assert!(result.is_ok()); + } + + #[test] + fn test_register_embedded_abi_invalid_json() { + let mut registry = AbiRegistry::new(); + let result = register_embedded_abi(&mut registry, "BadToken", "not valid json"); + assert!(matches!(result, Err(AbiEmbeddingError::InvalidJson(_)))); + } + + #[test] + fn test_parse_abi_address_mapping_valid() { + let result = + parse_abi_address_mapping("TestToken:0x1234567890123456789012345678901234567890"); + assert!(result.is_some()); + let (name, _addr) = result.unwrap(); + assert_eq!(name, "TestToken"); + } + + #[test] + fn test_parse_abi_address_mapping_invalid_format() { + let result = parse_abi_address_mapping("NoColon"); + assert!(result.is_none()); + } + + #[test] + fn test_parse_abi_address_mapping_invalid_address() { + let result = parse_abi_address_mapping("TestToken:notanaddress"); + assert!(result.is_none()); + } + + #[test] + fn test_map_abi_address() { + let mut registry = AbiRegistry::new(); + register_embedded_abi(&mut registry, "TestToken", TEST_ABI).unwrap(); + + let address: Address = "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(); + map_abi_address(&mut registry, 1, address, "TestToken"); + + // Verify it was mapped + assert!(registry.get_abi_for_address(1, address).is_some()); + } + + #[test] + fn test_integration_register_and_retrieve() { + const MULTI_ABI: &str = r#"[ + { + "type": "function", + "name": "mint", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "amount", "type": "uint256"} + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "burn", + "inputs": [{"name": "amount", "type": "uint256"}], + "outputs": [], + "stateMutability": "nonpayable" + } + ]"#; + + let mut registry = AbiRegistry::new(); + + // Register multiple ABIs + register_embedded_abi(&mut registry, "SimpleToken", TEST_ABI).unwrap(); + register_embedded_abi(&mut registry, "ExtendedToken", MULTI_ABI).unwrap(); + + // Map addresses on different chains + let addr1: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let addr2: Address = "0x2222222222222222222222222222222222222222" + .parse() + .unwrap(); + + map_abi_address(&mut registry, 1, addr1, "SimpleToken"); + map_abi_address(&mut registry, 1, addr2, "ExtendedToken"); + map_abi_address(&mut registry, 137, addr1, "ExtendedToken"); + + // Verify all mappings + let abi1_on_mainnet = registry.get_abi_for_address(1, addr1); + let abi2_on_mainnet = registry.get_abi_for_address(1, addr2); + let abi1_on_polygon = registry.get_abi_for_address(137, addr1); + + assert!(abi1_on_mainnet.is_some()); + assert!(abi2_on_mainnet.is_some()); + assert!(abi1_on_polygon.is_some()); + + // Verify they're different ABIs + assert_ne!(abi1_on_mainnet, abi2_on_mainnet); + + // Verify unmapped addresses return None + let unmapped: Address = "0x9999999999999999999999999999999999999999" + .parse() + .unwrap(); + assert!(registry.get_abi_for_address(1, unmapped).is_none()); + } + + #[test] + fn test_integration_cli_abi_parsing() { + // Simulate CLI argument parsing + let mapping_strs = vec![ + "Token1:0x1111111111111111111111111111111111111111", + "Token2:0x2222222222222222222222222222222222222222", + "InvalidFormat", // Invalid mapping + "Token3:0x3333333333333333333333333333333333333333", + ]; + + let mut valid_count = 0; + for mapping_str in &mapping_strs { + if let Some((_name, _address)) = parse_abi_address_mapping(mapping_str) { + valid_count += 1; + } + } + + assert_eq!(valid_count, 3); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/fmt.rs b/src/chain_parsers/visualsign-ethereum/src/fmt.rs index 59101bfe..685e1f55 100644 --- a/src/chain_parsers/visualsign-ethereum/src/fmt.rs +++ b/src/chain_parsers/visualsign-ethereum/src/fmt.rs @@ -1,4 +1,8 @@ -use alloy_primitives::utils::{ParseUnits, format_units}; +use alloy_primitives::{ + U256, + utils::{ParseUnits, format_units}, +}; + fn trim_trailing_zeros(s: String) -> String { if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() @@ -11,10 +15,22 @@ fn trim_trailing_zeros(s: String) -> String { pub fn format_ether + ToString + Copy>(wei: T) -> String { trim_trailing_zeros(format_units(wei, "eth").unwrap_or_else(|_| wei.to_string())) } + // Helper function to format wei to gwei pub fn format_gwei + ToString + Copy>(wei: T) -> String { trim_trailing_zeros(format_units(wei, "gwei").unwrap_or_else(|_| wei.to_string())) } + +/// Checks if a U256 value represents "unlimited" (max uint256) +/// Used for permit operations and approval amounts that support type(uint).max +pub fn is_unlimited_u256(value: U256) -> bool { + value == U256::MAX +} + +/// Checks if a u128 value represents "unlimited" (max uint128) +pub fn is_unlimited_u128(value: u128) -> bool { + value == u128::MAX +} #[cfg(test)] mod tests { use super::*; @@ -80,4 +96,26 @@ mod tests { let wei = 123_456_789_000u128; assert_eq!("123.456789", format_gwei(wei)); } + + #[test] + fn test_is_unlimited_u256_max() { + assert!(is_unlimited_u256(U256::MAX)); + } + + #[test] + fn test_is_unlimited_u256_not_max() { + assert!(!is_unlimited_u256(U256::from(1_000_000u128))); + assert!(!is_unlimited_u256(U256::ZERO)); + } + + #[test] + fn test_is_unlimited_u128_max() { + assert!(is_unlimited_u128(u128::MAX)); + } + + #[test] + fn test_is_unlimited_u128_not_max() { + assert!(!is_unlimited_u128(1_000_000u128)); + assert!(!is_unlimited_u128(0u128)); + } } diff --git a/src/chain_parsers/visualsign-ethereum/src/grpc_abi.rs b/src/chain_parsers/visualsign-ethereum/src/grpc_abi.rs new file mode 100644 index 00000000..ba352ac3 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/grpc_abi.rs @@ -0,0 +1,185 @@ +//! gRPC ABI metadata extraction and validation +//! +//! This module handles extracting ABIs from gRPC metadata payloads and validating them +//! using optional secp256k1 signatures. + +use crate::abi_registry::AbiRegistry; +use crate::embedded_abis::{AbiEmbeddingError, register_embedded_abi}; + +/// Error type for gRPC ABI operations +#[derive(Debug, thiserror::Error)] +pub enum GrpcAbiError { + /// Failed to parse ABI JSON + #[error("Failed to parse ABI: {0}")] + InvalidAbi(#[from] AbiEmbeddingError), + + /// Signature validation failed + #[error("ABI signature validation failed: {0}")] + SignatureValidation(String), + + /// Missing required metadata + #[error("Missing ABI metadata")] + MissingMetadata, +} + +/// Extract and validate ABI from gRPC EthereumMetadata +/// +/// # Arguments +/// * `abi_value` - JSON ABI string from Abi.value +/// * `signature` - Optional secp256k1 signature for validation +/// +/// # Returns +/// * `Ok(AbiRegistry)` with the ABI registered as "wallet_provided" +/// * `Err(GrpcAbiError)` if ABI is invalid or signature validation fails +/// +/// # Example +/// ```ignore +/// let metadata = ParseRequest { chain_metadata: Some(ChainMetadata { ... }) }; +/// if let Some(chain) = &metadata.chain_metadata { +/// if let Some(ethereum) = &chain.ethereum { +/// if let Some(abi) = ðereum.abi { +/// let registry = extract_abi_from_metadata(&abi.value, abi.signature.as_ref())?; +/// // Use registry in visualizer context +/// } +/// } +/// } +/// ``` +pub fn extract_abi_from_metadata( + abi_value: &str, + signature: Option<&SignatureMetadata>, +) -> Result { + // Validate signature if present + if let Some(sig) = signature { + validate_abi_signature(abi_value, sig)?; + } + + // Create registry and register ABI + let mut registry = AbiRegistry::new(); + register_embedded_abi(&mut registry, "wallet_provided", abi_value)?; + + Ok(registry) +} + +/// Represents ABI signature metadata from gRPC +/// +/// This mirrors the protobuf structure but is chain-agnostic +#[derive(Debug, Clone)] +pub struct SignatureMetadata { + /// Signature value (hex-encoded) + pub value: String, + /// Algorithm used (e.g., "secp256k1-sha256") + pub algorithm: Option, + /// Issuer of the signature + pub issuer: Option, + /// Timestamp of signature + pub timestamp: Option, +} + +/// Validate ABI using secp256k1 signature +/// +/// # Arguments +/// * `abi_json` - The ABI JSON string that was signed +/// * `signature_metadata` - Signature and metadata for validation +/// +/// # Returns +/// * `Ok(())` if signature is valid +/// * `Err(GrpcAbiError)` if signature validation fails +/// +/// # Note +/// This is a placeholder for the actual signature validation logic. +/// The implementation would use: +/// - SHA256 hash of abi_json +/// - secp256k1::verify() with provided signature +/// - Recovery of public key from signature +fn validate_abi_signature( + abi_json: &str, + _signature: &SignatureMetadata, +) -> Result<(), GrpcAbiError> { + // TODO: Implement actual secp256k1 signature validation + // For now, just verify the ABI can be parsed + serde_json::from_str::(abi_json) + .map_err(|e| GrpcAbiError::SignatureValidation(format!("Invalid ABI JSON: {e}")))?; + + // TODO: When secp256k1 validation is added: + // 1. Hash the ABI JSON with SHA256 + // 2. Recover public key from signature + // 3. Verify signature matches + // 4. Log issuer and timestamp if present + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_ABI: &str = r#"[ + { + "type": "function", + "name": "transfer", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "amount", "type": "uint256"} + ], + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable" + } + ]"#; + + #[test] + fn test_extract_abi_from_metadata_valid() { + let result = extract_abi_from_metadata(VALID_ABI, None); + assert!(result.is_ok()); + + let registry = result.unwrap(); + // Verify ABI was registered + assert!(registry.list_abis().contains(&"wallet_provided")); + } + + #[test] + fn test_extract_abi_from_metadata_invalid_json() { + let result = extract_abi_from_metadata("not valid json", None); + assert!(result.is_err()); + } + + #[test] + fn test_extract_abi_from_metadata_with_signature() { + let sig = SignatureMetadata { + value: "0x123456789abcdef".to_string(), + algorithm: Some("secp256k1-sha256".to_string()), + issuer: Some("wallet.example.com".to_string()), + timestamp: Some("2024-01-01T00:00:00Z".to_string()), + }; + + let result = extract_abi_from_metadata(VALID_ABI, Some(&sig)); + // Should succeed - signature validation is placeholder + assert!(result.is_ok()); + } + + #[test] + fn test_extract_abi_from_metadata_signature_with_invalid_abi() { + let sig = SignatureMetadata { + value: "0x123456789abcdef".to_string(), + algorithm: None, + issuer: None, + timestamp: None, + }; + + let result = extract_abi_from_metadata("invalid json", Some(&sig)); + assert!(result.is_err()); + } + + #[test] + fn test_signature_metadata_struct() { + let sig = SignatureMetadata { + value: "0xabc123".to_string(), + algorithm: Some("secp256k1-sha256".to_string()), + issuer: Some("issuer.example.com".to_string()), + timestamp: Some("2024-01-01T00:00:00Z".to_string()), + }; + + assert_eq!(sig.value, "0xabc123"); + assert_eq!(sig.algorithm, Some("secp256k1-sha256".to_string())); + assert_eq!(sig.issuer, Some("issuer.example.com".to_string())); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/lib.rs b/src/chain_parsers/visualsign-ethereum/src/lib.rs index 74f901e2..f6511d23 100644 --- a/src/chain_parsers/visualsign-ethereum/src/lib.rs +++ b/src/chain_parsers/visualsign-ethereum/src/lib.rs @@ -1,4 +1,8 @@ +use std::sync::Arc; + use crate::fmt::{format_ether, format_gwei}; +use crate::registry::ContractType; +use crate::visualizer::CalldataVisualizer; use alloy_consensus::{Transaction as _, TxType, TypedTransaction}; use alloy_rlp::{Buf, Decodable}; use base64::{Engine as _, engine::general_purpose::STANDARD as b64}; @@ -6,15 +10,26 @@ use visualsign::{ SignablePayload, SignablePayloadField, SignablePayloadFieldAddressV2, SignablePayloadFieldAmountV2, SignablePayloadFieldCommon, SignablePayloadFieldTextV2, encodings::SupportedEncodings, + registry::LayeredRegistry, vsptrait::{ Transaction, TransactionParseError, VisualSignConverter, VisualSignConverterFromString, VisualSignError, VisualSignOptions, }, }; -pub mod chains; +pub mod abi_decoder; +pub mod abi_registry; +pub mod context; pub mod contracts; +pub mod embedded_abis; pub mod fmt; +pub mod grpc_abi; +pub mod networks; +pub mod protocols; +pub mod registry; +pub mod token_metadata; +pub mod utils; +pub mod visualizer; #[derive(Debug, Eq, PartialEq, thiserror::Error)] pub enum EthereumParserError { @@ -106,8 +121,66 @@ impl EthereumTransactionWrapper { } } -/// Converter that knows how to format Ethereum transactions for VisualSign -pub struct EthereumVisualSignConverter; +/// Converter that knows how to format Ethereum transactions for VisualSign. +/// +/// Uses `Arc` for efficient sharing of the global registry across requests. +/// Per-request wallet metadata is layered on top using `LayeredRegistry`, which checks +/// the request layer first before falling back to the global registry. +pub struct EthereumVisualSignConverter { + registry: Arc, + visualizer_registry: visualizer::EthereumVisualizerRegistry, +} + +impl EthereumVisualSignConverter { + /// Creates a new converter with a custom registry wrapped in Arc. + pub fn with_registry(registry: Arc) -> Self { + Self { + registry, + visualizer_registry: visualizer::EthereumVisualizerRegistryBuilder::new().build(), + } + } + + /// Creates a new converter with a default registry including all known protocols. + pub fn new() -> Self { + let (contract_registry, visualizer_builder) = + registry::ContractRegistry::with_default_protocols(); + Self { + registry: Arc::new(contract_registry), + visualizer_registry: visualizer_builder.build(), + } + } + + /// Creates a layered registry for the current request. + /// + /// The global registry is shared via Arc (O(1) clone). If wallet metadata contains + /// token information, it's loaded into a request-scoped registry that takes precedence + /// during lookups. The request registry is dropped after the request completes. + fn create_layered_registry( + &self, + _options: &VisualSignOptions, + ) -> LayeredRegistry { + // TODO: When wallet-provided ChainMetadata includes token metadata (not just ABIs), + // create a request registry and use LayeredRegistry::with_request: + // + // if let Some(ref chain_metadata) = options.metadata { + // if let Some(chain_metadata::Metadata::Ethereum(eth_metadata)) = &chain_metadata.metadata { + // let mut request_registry = registry::ContractRegistry::new(); + // // Load wallet tokens into request_registry + // // request_registry.load_wallet_tokens(ð_metadata.tokens)?; + // return LayeredRegistry::with_request(Arc::clone(&self.registry), request_registry); + // } + // } + + // No wallet metadata, use global registry only + LayeredRegistry::new(Arc::clone(&self.registry)) + } +} + +impl Default for EthereumVisualSignConverter { + fn default() -> Self { + Self::new() + } +} impl VisualSignConverter for EthereumVisualSignConverter { fn to_visual_sign_payload( @@ -116,12 +189,31 @@ impl VisualSignConverter for EthereumVisualSignConve options: VisualSignOptions, ) -> Result { let transaction = transaction_wrapper.inner().clone(); + + // Create layered registry: global (Arc-shared) + optional request-scoped wallet data. + // Lookups check request layer first, then fall back to global. + let layered_registry = self.create_layered_registry(&options); + + // Debug trace: Log registry usage for contract/token lookups (future enhancement) + if let Some(to) = transaction.to() { + if let Some(chain_id) = transaction.chain_id() { + let _contract_type = layered_registry.lookup(|r| r.get_contract_type(chain_id, to)); + let _token_symbol = layered_registry.lookup(|r| r.get_token_symbol(chain_id, to)); + // TODO: Use contract_type and token_symbol to enhance visualization + } + } + let is_supported = match transaction.tx_type() { TxType::Eip2930 | TxType::Eip4844 | TxType::Eip7702 => false, TxType::Legacy | TxType::Eip1559 => true, }; if is_supported { - return Ok(convert_to_visual_sign_payload(transaction, options)); + return Ok(convert_to_visual_sign_payload( + transaction, + options, + &layered_registry, + &self.visualizer_registry, + )); } Err(VisualSignError::DecodeError(format!( "Unsupported transaction type: {}", @@ -206,18 +298,26 @@ fn decode_transaction( fn convert_to_visual_sign_payload( transaction: TypedTransaction, options: VisualSignOptions, + layered_registry: &LayeredRegistry, + visualizer_registry: &visualizer::EthereumVisualizerRegistry, ) -> SignablePayload { // Extract chain ID to determine the network let chain_id = transaction.chain_id(); - let chain_name = chains::get_chain_name(chain_id); + // Try to extract AbiRegistry from options + let abi_registry = options + .abi_registry + .as_ref() + .and_then(|any_reg| any_reg.downcast_ref::()); + + let network_name = networks::get_network_name(chain_id); let mut fields = vec![SignablePayloadField::TextV2 { common: SignablePayloadFieldCommon { - fallback_text: chain_name.clone(), + fallback_text: network_name.clone(), label: "Network".to_string(), }, - text_v2: SignablePayloadFieldTextV2 { text: chain_name }, + text_v2: SignablePayloadFieldTextV2 { text: network_name }, }]; if let Some(to) = transaction.to() { fields.push(SignablePayloadField::AddressV2 { @@ -288,28 +388,86 @@ fn convert_to_visual_sign_payload( let input = transaction.input(); if !input.is_empty() { let mut input_fields: Vec = Vec::new(); - if options.decode_transfers { - if let Some(field) = (contracts::erc20::ERC20Visualizer {}).visualize_tx_commands(input) + + // Try to visualize using the registered visualizers + let chain_id_val = chain_id.unwrap_or(1); + if let Some(to_address) = transaction.to() { + if let Some(contract_type) = + layered_registry.lookup(|r| r.get_contract_type(chain_id_val, to_address)) { - input_fields.push(field); + if visualizer_registry.get(&contract_type).is_some() { + // Check if this is a Universal Router contract and visualize it + if contract_type + == crate::protocols::uniswap::config::UniswapUniversalRouter::short_type_id( + ) + { + if let Some(field) = (protocols::uniswap::UniversalRouterVisualizer {}) + .visualize_tx_commands( + input, + chain_id_val, + Some(layered_registry.global()), + ) + { + input_fields.push(field); + } + } + // Check if this is a Permit2 contract and visualize it + else if contract_type + == crate::protocols::uniswap::config::Permit2Contract::short_type_id() + { + if let Some(field) = (protocols::uniswap::Permit2Visualizer) + .visualize_tx_commands( + input, + chain_id_val, + Some(layered_registry.global()), + ) + { + input_fields.push(field); + } + } + // Check if this is a Morpho Bundler3 contract and visualize it + else if contract_type + == crate::protocols::morpho::config::Bundler3Contract::short_type_id() + { + if let Some(field) = (protocols::morpho::BundlerVisualizer {}) + .visualize_multicall( + input, + chain_id_val, + Some(layered_registry.global()), + ) + { + input_fields.push(field); + } + } + } } } - if let Some(field) = - (contracts::uniswap::UniswapV4Visualizer {}).visualize_tx_commands(input) - { - input_fields.push(field); + + // Try dynamic ABI visualization if available + if input_fields.is_empty() { + if let (Some(to_address), Some(abi_reg)) = (transaction.to(), abi_registry) { + let chain_id_val = chain_id.unwrap_or(1); + if let Some(abi) = abi_reg.get_abi_for_address(chain_id_val, to_address) { + if let Some(field) = (contracts::core::DynamicAbiVisualizer::new(abi)) + .visualize_calldata(input, chain_id_val, None) + { + input_fields.push(field); + } + } + } + } + + // Fallback: Try ERC20 if decode_transfers is enabled + if input_fields.is_empty() && options.decode_transfers { + if let Some(field) = (contracts::core::ERC20Visualizer {}).visualize_tx_commands(input) + { + input_fields.push(field); + } } if input_fields.is_empty() { - input_fields.push(SignablePayloadField::TextV2 { - common: SignablePayloadFieldCommon { - fallback_text: format!("0x{}", hex::encode(input)), - label: "Input Data".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { - text: format!("0x{}", hex::encode(input)), - }, - }); + input_fields.push(contracts::core::FallbackVisualizer::new().visualize_hex(input)); } + fields.append(&mut input_fields); } @@ -325,7 +483,7 @@ pub fn transaction_to_visual_sign( options: VisualSignOptions, ) -> Result { let wrapper = EthereumTransactionWrapper::new(transaction); - let converter = EthereumVisualSignConverter; + let converter = EthereumVisualSignConverter::new(); converter.to_visual_sign_payload(wrapper, options) } @@ -333,7 +491,7 @@ pub fn transaction_string_to_visual_sign( transaction_data: &str, options: VisualSignOptions, ) -> Result { - let converter = EthereumVisualSignConverter; + let converter = EthereumVisualSignConverter::new(); converter.to_visual_sign_payload_from_string(transaction_data, options) } @@ -475,7 +633,7 @@ mod tests { let options = VisualSignOptions::default(); let payload = transaction_to_visual_sign(tx, options).unwrap(); - // Check that input data field is present + // Check that input data field is present (FallbackVisualizer) assert!(payload.fields.iter().any(|f| f.label() == "Input Data")); let input_field = payload .fields @@ -503,6 +661,7 @@ mod tests { decode_transfers: false, transaction_name: Some("Custom Transaction Title".to_string()), metadata: None, + abi_registry: None, }; let payload = transaction_to_visual_sign(tx, options).unwrap(); @@ -729,6 +888,7 @@ mod tests { decode_transfers: true, transaction_name: Some("Test Transaction".to_string()), metadata: None, + abi_registry: None, } ), Ok(SignablePayload::new( diff --git a/src/chain_parsers/visualsign-ethereum/src/chains.rs b/src/chain_parsers/visualsign-ethereum/src/networks.rs similarity index 97% rename from src/chain_parsers/visualsign-ethereum/src/chains.rs rename to src/chain_parsers/visualsign-ethereum/src/networks.rs index c1023df2..19bc341a 100644 --- a/src/chain_parsers/visualsign-ethereum/src/chains.rs +++ b/src/chain_parsers/visualsign-ethereum/src/networks.rs @@ -1,15 +1,102 @@ +//! EVM chain definitions and utilities +//! +//! This module provides chain ID constants and name lookups for EVM-compatible chains. +//! +//! Chain ID source: +//! For additional chains, consult the DefiLlama chainlist repository. + +/// Chain ID constants grouped by network family +/// +/// Use these constants instead of magic numbers throughout the codebase. +/// Example: `chains::id::ethereum::MAINNET` instead of `1u64` +/// +/// Source: +pub mod id { + // L1 Chains + pub mod ethereum { + pub const MAINNET: u64 = 1; + pub const SEPOLIA: u64 = 11155111; + pub const GOERLI: u64 = 5; // deprecated + pub const HOLESKY: u64 = 17000; + } + pub mod bsc { + pub const MAINNET: u64 = 56; + pub const TESTNET: u64 = 97; + } + pub mod polygon { + pub const MAINNET: u64 = 137; + pub const AMOY: u64 = 80002; + } + pub mod avalanche { + pub const MAINNET: u64 = 43114; + pub const FUJI: u64 = 43113; + } + pub mod fantom { + pub const MAINNET: u64 = 250; + } + pub mod gnosis { + pub const MAINNET: u64 = 100; + } + pub mod celo { + pub const MAINNET: u64 = 42220; + pub const ALFAJORES: u64 = 44787; + } + + // L2 Chains - Optimistic Rollups + pub mod optimism { + pub const MAINNET: u64 = 10; + pub const SEPOLIA: u64 = 11155420; + } + pub mod arbitrum { + pub const MAINNET: u64 = 42161; + pub const SEPOLIA: u64 = 421614; + } + pub mod base { + pub const MAINNET: u64 = 8453; + pub const SEPOLIA: u64 = 84532; + } + pub mod blast { + pub const MAINNET: u64 = 81457; + } + pub mod mantle { + pub const MAINNET: u64 = 5000; + } + pub mod worldchain { + pub const MAINNET: u64 = 480; + } + + // L2 Chains - ZK Rollups + pub mod zksync { + pub const MAINNET: u64 = 324; + } + pub mod linea { + pub const MAINNET: u64 = 59144; + } + pub mod scroll { + pub const MAINNET: u64 = 534352; + } + + // App-Specific Chains + pub mod zora { + pub const MAINNET: u64 = 7777777; + } + pub mod unichain { + pub const MAINNET: u64 = 130; + } +} + // Helper function to get network name from chain ID -pub fn get_chain_name(chain_id: Option) -> String { +pub fn get_network_name(chain_id: Option) -> String { match chain_id { - Some(1) => "Ethereum Mainnet".to_string(), + Some(id::ethereum::MAINNET) => "Ethereum Mainnet".to_string(), Some(2) => "Expanse Network".to_string(), Some(3) => "Ropsten".to_string(), Some(4) => "Rinkeby".to_string(), - Some(5) => "Goerli".to_string(), + Some(id::ethereum::GOERLI) => "Goerli".to_string(), Some(7) => "ThaiChain".to_string(), Some(8) => "Ubiq".to_string(), Some(9) => "Ubiq Network Testnet".to_string(), - Some(10) => "OP Mainnet".to_string(), + Some(id::optimism::MAINNET) => "OP Mainnet".to_string(), Some(11) => "Metadium Mainnet".to_string(), Some(12) => "Metadium Testnet".to_string(), Some(13) => "Diode Testnet Staging".to_string(), @@ -54,7 +141,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(53) => "CoinEx Smart Chain Testnet".to_string(), Some(54) => "Openpiece Mainnet".to_string(), Some(55) => "Zyx Mainnet".to_string(), - Some(56) => "BNB Smart Chain Mainnet".to_string(), + Some(id::bsc::MAINNET) => "BNB Smart Chain Mainnet".to_string(), Some(57) => "Syscoin Mainnet".to_string(), Some(58) => "Ontology Mainnet".to_string(), Some(60) => "GoChain".to_string(), @@ -93,10 +180,10 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(94) => "SwissDLT".to_string(), Some(95) => "CamDL Mainnet".to_string(), Some(96) => "KUB Mainnet".to_string(), - Some(97) => "BNB Smart Chain Testnet".to_string(), + Some(id::bsc::TESTNET) => "BNB Smart Chain Testnet".to_string(), Some(98) => "Six Protocol".to_string(), Some(99) => "POA Network Core".to_string(), - Some(100) => "Gnosis".to_string(), + Some(id::gnosis::MAINNET) => "Gnosis".to_string(), Some(101) => "EtherInc".to_string(), Some(102) => "Web3Games Testnet".to_string(), Some(103) => "WorldLand Mainnet".to_string(), @@ -124,14 +211,14 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(127) => "Factory 127 Mainnet".to_string(), Some(128) => "Huobi ECO Chain Mainnet".to_string(), Some(129) => "Innovator Chain".to_string(), - Some(130) => "Unichain".to_string(), + Some(id::unichain::MAINNET) => "Unichain".to_string(), Some(131) => "Engram Testnet".to_string(), Some(132) => "Namefi Chain Mainnet".to_string(), Some(133) => "HashKey Chain Testnet".to_string(), Some(134) => "iExec Sidechain".to_string(), Some(135) => "Alyx Chain Testnet".to_string(), Some(136) => "Deamchain Mainnet".to_string(), - Some(137) => "Polygon Mainnet".to_string(), + Some(id::polygon::MAINNET) => "Polygon Mainnet".to_string(), Some(138) => "Defi Oracle Meta Mainnet".to_string(), Some(139) => "WoopChain Mainnet".to_string(), Some(140) => "Eteria Mainnet".to_string(), @@ -226,7 +313,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(246) => "Energy Web Chain".to_string(), Some(247) => "ChooChain".to_string(), Some(248) => "Oasys Mainnet".to_string(), - Some(250) => "Fantom Opera".to_string(), + Some(id::fantom::MAINNET) => "Fantom Opera".to_string(), Some(251) => "Glide L1 Protocol XP".to_string(), Some(252) => "Fraxtal".to_string(), Some(253) => "Glide L2 Protocol XP".to_string(), @@ -269,7 +356,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(321) => "KCC Mainnet".to_string(), Some(322) => "KCC Testnet".to_string(), Some(323) => "BuyCex Infinity Chain".to_string(), - Some(324) => "zkSync Mainnet".to_string(), + Some(id::zksync::MAINNET) => "zkSync Mainnet".to_string(), Some(325) => "GRVT Exchange".to_string(), Some(326) => "GRVT Exchange Testnet".to_string(), Some(331) => "Telos zkEVM Testnet".to_string(), @@ -317,7 +404,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(463) => "Areon Network Mainnet".to_string(), Some(466) => "AppChain".to_string(), Some(478) => "Form Network".to_string(), - Some(480) => "World Chain".to_string(), + Some(id::worldchain::MAINNET) => "World Chain".to_string(), Some(486) => "Standard Mainnet".to_string(), Some(488) => "BlackFort Exchange Network".to_string(), Some(495) => "Landstars".to_string(), @@ -938,7 +1025,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(4913) => "OEV Network".to_string(), Some(4918) => "Venidium Testnet".to_string(), Some(4919) => "Venidium Mainnet".to_string(), - Some(5000) => "Mantle".to_string(), + Some(id::mantle::MAINNET) => "Mantle".to_string(), Some(5001) => "Mantle Testnet".to_string(), Some(5002) => "Treasurenet Mainnet Alpha".to_string(), Some(5003) => "Mantle Sepolia Testnet".to_string(), @@ -1131,7 +1218,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(8387) => "Dracones Financial Services".to_string(), Some(8408) => "ZenChain Testnet".to_string(), Some(8428) => "THAT Mainnet".to_string(), - Some(8453) => "Base".to_string(), + Some(id::base::MAINNET) => "Base".to_string(), Some(8545) => "Chakra Testnet".to_string(), Some(8569) => "New Reality Blockchain".to_string(), Some(8654) => "Toki Network".to_string(), @@ -1496,9 +1583,9 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(42070) => "WMC Testnet".to_string(), Some(42072) => "AgentLayer Testnet".to_string(), Some(42096) => "Heurist Testnet".to_string(), - Some(42161) => "Arbitrum One".to_string(), + Some(id::arbitrum::MAINNET) => "Arbitrum One".to_string(), Some(42170) => "Arbitrum Nova".to_string(), - Some(42220) => "Celo Mainnet".to_string(), + Some(id::celo::MAINNET) => "Celo Mainnet".to_string(), Some(42261) => "Oasis Emerald Testnet".to_string(), Some(42262) => "Oasis Emerald".to_string(), Some(42355) => "GoldXChain Mainnet".to_string(), @@ -1511,7 +1598,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(43110) => "Athereum".to_string(), Some(43111) => "Hemi".to_string(), Some(43113) => "Avalanche Fuji Testnet".to_string(), - Some(43114) => "Avalanche C-Chain".to_string(), + Some(id::avalanche::MAINNET) => "Avalanche C-Chain".to_string(), Some(43419) => "GUNZ".to_string(), Some(43521) => "Formicarium".to_string(), Some(43851) => "ZKFair Testnet".to_string(), @@ -1591,7 +1678,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(58680) => "Lumoz Quidditch Testnet".to_string(), Some(59140) => "Linea Goerli".to_string(), Some(59141) => "Linea Sepolia".to_string(), - Some(59144) => "Linea".to_string(), + Some(id::linea::MAINNET) => "Linea".to_string(), Some(59902) => "Metis Sepolia Testnet".to_string(), Some(59971) => "Genesys Code Mainnet".to_string(), Some(60000) => "Thinkium Testnet Chain 0".to_string(), @@ -1684,7 +1771,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(81361) => "Mizana Testnet".to_string(), Some(81362) => "Mizana Mixnet".to_string(), Some(81363) => "Mizana Privnet".to_string(), - Some(81457) => "Blast".to_string(), + Some(id::blast::MAINNET) => "Blast".to_string(), Some(81720) => "Quantum Chain Mainnet".to_string(), Some(82459) => "Smart Layer Network Testnet".to_string(), Some(82614) => "VEMP Horizon".to_string(), @@ -1940,7 +2027,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(513100) => "EthereumFair".to_string(), Some(526916) => "DoCoin Community Chain".to_string(), Some(534351) => "Scroll Sepolia Testnet".to_string(), - Some(534352) => "Scroll".to_string(), + Some(id::scroll::MAINNET) => "Scroll".to_string(), Some(534849) => "Shinarium Beta".to_string(), Some(535037) => "BeanEco SmartChain".to_string(), Some(541764) => "OverProtocol Testnet".to_string(), @@ -2090,7 +2177,7 @@ pub fn get_chain_name(chain_id: Option) -> String { Some(7355310) => "OpenVessel".to_string(), Some(7668378) => "QL1 Testnet".to_string(), Some(7762959) => "Musicoin".to_string(), - Some(7777777) => "Zora".to_string(), + Some(id::zora::MAINNET) => "Zora".to_string(), Some(7849306) => "Ozean Poseidon Testnet".to_string(), Some(8007736) => "Plian Mainnet Subchain 1".to_string(), Some(8008135) => "Fhenix Helium".to_string(), diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs new file mode 100644 index 00000000..257d3508 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs @@ -0,0 +1,20 @@ +pub mod morpho; +pub mod uniswap; + +use crate::registry::ContractRegistry; +use crate::visualizer::EthereumVisualizerRegistryBuilder; + +/// Registers all available protocol contracts and visualizers +/// +/// # Arguments +/// * `contract_reg` - The contract registry to register addresses +/// * `visualizer_reg` - The visualizer registry to register visualizers +pub fn register_all( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + // Register Morpho protocol + morpho::register(contract_reg, visualizer_reg); + // Register Uniswap protocol + uniswap::register(contract_reg, visualizer_reg); +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/config.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/config.rs new file mode 100644 index 00000000..59409b0e --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/config.rs @@ -0,0 +1,146 @@ +use crate::registry::{ContractRegistry, ContractType}; +use alloy_primitives::Address; + +/// Re-export chain ID constants from crate::networks::id +/// +/// This provides access to chain constants like `networks::ethereum::MAINNET` +/// for use in Morpho configuration. +pub use crate::networks::id as networks; + +/// Error type for Morpho configuration operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MorphoConfigError { + /// Chain ID is not supported for Bundler3 + UnsupportedChain(u64), + /// Address string failed to parse (should never happen with hardcoded addresses) + InvalidAddress(String), +} + +impl std::fmt::Display for MorphoConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MorphoConfigError::UnsupportedChain(chain_id) => { + write!(f, "Unsupported chain ID for Morpho Bundler3: {chain_id}") + } + MorphoConfigError::InvalidAddress(addr) => { + write!(f, "Invalid address: {addr}") + } + } + } +} + +impl std::error::Error for MorphoConfigError {} + +/// Morpho Bundler3 contract type identifier +pub struct Bundler3Contract; + +impl ContractType for Bundler3Contract { + fn short_type_id() -> &'static str { + "morpho_bundler3" + } +} + +/// Configuration for Morpho protocol contracts +pub struct MorphoConfig; + +impl MorphoConfig { + /// Returns the Bundler3 contract address for a specific chain + /// + /// Morpho Bundler3 contracts are deployed at different addresses on different chains. + /// Source: https://docs.morpho.org/get-started/resources/addresses/ + /// + /// Verified deployments: + /// - Ethereum Mainnet: 0x6566194141eefa99Af43Bb5Aa71460Ca2Dc90245 + /// - Base: 0x6BFd8137e702540E7A42B74178A4a49Ba43920C4 + /// - Arbitrum One: 0x1FA4431bC113D308beE1d46B0e98Cb805FB48C13 + pub fn bundler3_address(chain_id: u64) -> Result { + let addr_str = match chain_id { + networks::ethereum::MAINNET => "0x6566194141eefa99Af43Bb5Aa71460Ca2Dc90245", + networks::base::MAINNET => "0x6BFd8137e702540E7A42B74178A4a49Ba43920C4", + networks::arbitrum::MAINNET => "0x1FA4431bC113D308beE1d46B0e98Cb805FB48C13", + _ => return Err(MorphoConfigError::UnsupportedChain(chain_id)), + }; + addr_str + .parse() + .map_err(|_| MorphoConfigError::InvalidAddress(addr_str.to_string())) + } + + /// Returns the list of chain IDs where Bundler3 is deployed + /// Source: https://docs.morpho.org/get-started/resources/addresses/ + pub fn bundler3_chains() -> &'static [u64] { + &[ + networks::ethereum::MAINNET, // 1 - Ethereum Mainnet + networks::base::MAINNET, // 8453 - Base + networks::arbitrum::MAINNET, // 42161 - Arbitrum One + ] + } + + /// Registers Morpho protocol contracts in the registry + pub fn register_contracts(registry: &mut ContractRegistry) { + for &chain_id in Self::bundler3_chains() { + if let Ok(bundler3_address) = Self::bundler3_address(chain_id) { + registry + .register_contract_typed::(chain_id, vec![bundler3_address]); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bundler3_address() { + // Test Ethereum Mainnet + let ethereum_addr = MorphoConfig::bundler3_address(networks::ethereum::MAINNET).unwrap(); + assert_eq!( + format!("{:?}", ethereum_addr).to_lowercase(), + "0x6566194141eefa99af43bb5aa71460ca2dc90245" + ); + + // Test Base + let base_addr = MorphoConfig::bundler3_address(networks::base::MAINNET).unwrap(); + assert_eq!( + format!("{:?}", base_addr).to_lowercase(), + "0x6bfd8137e702540e7a42b74178a4a49ba43920c4" + ); + + // Test Arbitrum One + let arbitrum_addr = MorphoConfig::bundler3_address(networks::arbitrum::MAINNET).unwrap(); + assert_eq!( + format!("{:?}", arbitrum_addr).to_lowercase(), + "0x1fa4431bc113d308bee1d46b0e98cb805fb48c13" + ); + } + + #[test] + fn test_bundler3_address_unsupported_chain() { + let result = MorphoConfig::bundler3_address(999999); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + MorphoConfigError::UnsupportedChain(999999) + ); + } + + #[test] + fn test_all_chains_have_valid_addresses() { + for &chain_id in MorphoConfig::bundler3_chains() { + let result = MorphoConfig::bundler3_address(chain_id); + assert!( + result.is_ok(), + "Chain {chain_id} should have a valid address" + ); + } + } + + #[test] + fn test_bundler3_chains() { + let chains = MorphoConfig::bundler3_chains(); + assert!(chains.contains(&networks::ethereum::MAINNET)); // Ethereum + assert!(chains.contains(&networks::base::MAINNET)); // Base + assert!(chains.contains(&networks::arbitrum::MAINNET)); // Arbitrum + assert_eq!(chains.len(), 3, "Should support exactly 3 chains"); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/bundler.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/bundler.rs new file mode 100644 index 00000000..af6d0c38 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/bundler.rs @@ -0,0 +1,854 @@ +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::{SolCall as _, SolValue as _, sol}; +use chrono::TimeZone; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +use crate::context::VisualizerContext; +use crate::contracts::core::erc20::IERC20; +use crate::fmt::is_unlimited_u256; +use crate::protocols::morpho::config::Bundler3Contract; +use crate::registry::{ContractRegistry, ContractType}; + +// Morpho Bundler3 interface definitions +// +// Official Documentation: +// - Technical Reference: https://docs.morpho.org/contracts/bundler +// - Contract Source: https://github.com/morpho-org/bundler3 +// - GeneralAdapter1 Operations: https://github.com/morpho-org/bundler3/blob/3b22daf606bdef4f119f168c74496f87a90ac8e5/src/adapters/GeneralAdapter1.sol#L373 +// +// The Bundler3 contract allows batching multiple operations into a single transaction. +// Key operations: +// - permit2TransferFrom: Transfer via Permit2 standard +// - erc20TransferFrom: Direct ERC20 transfer with pre-approved allowance +// - erc4626Deposit: Deposit into ERC-4626 vault +sol! { + /// @notice Struct containing all the data needed to make a call. + struct Call { + address to; + bytes data; + uint256 value; + bool skipRevert; + bytes32 callbackHash; + } + + interface IBundler3 { + /// @notice Executes multiple calls in sequence + function multicall(Call[] calldata) external payable; + } + + // Standard ERC-2612 permit interface (not bundler-specific) + interface IERC2612 { + /// @notice ERC-2612 permit function + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + } + + // Bundler-specific permit operations + interface IBundlerPermit { + /// @notice ERC-2612 permit function + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + } + + // Morpho GeneralAdapter1 operations (type-safe parameter decoding) + // Reference: https://github.com/morpho-org/bundler3/blob/3b22daf606bdef4f119f168c74496f87a90ac8e5/src/adapters/GeneralAdapter1.sol + + interface IGeneralAdapter1 { + /// @notice Direct ERC20 transfer with pre-approved allowance + function erc20TransferFrom( + address token, + address receiver, + uint256 amount + ) external; + + /// @notice Deposit into ERC-4626 vault + function erc4626Deposit( + address vault, + uint256 assets, + uint256 minShares, + address receiver + ) external; + } + + /// @notice Direct ERC20 transfer with pre-approved allowance + struct Erc20TransferFromParams { + address token; + address receiver; + uint256 amount; + } + + /// @notice Deposit into ERC-4626 vault + struct Erc4626DepositParams { + address vault; + uint256 assets; + uint256 minShares; + address receiver; + } +} + +/// Visualizer for Morpho Bundler3 contract +pub struct BundlerVisualizer {} + +impl BundlerVisualizer { + /// Visualizes Morpho Bundler3 multicall operations + /// + /// # Arguments + /// * `input` - The calldata bytes + /// * `chain_id` - The chain ID for registry lookups + /// * `registry` - Optional registry for resolving token symbols + pub fn visualize_multicall( + &self, + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + // Check multicall selector (IBundler3::multicall) + let selector = &input[0..4]; + if selector != IBundler3::multicallCall::SELECTOR { + return None; + } + + // Try decoding the multicall + let call = match IBundler3::multicallCall::abi_decode(input) { + Ok(c) => c, + Err(_) => return None, + }; + + let calls = &call.0; + let mut detail_fields = Vec::new(); + + for morpho_call in calls.iter() { + // Decode the nested call data + let nested_field = Self::decode_nested_call( + &morpho_call.to, + &morpho_call.data, + &morpho_call.value, + chain_id, + registry, + ); + + detail_fields.push(nested_field); + } + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: format!("Morpho Bundler: {} operations", calls.len()), + label: "Morpho Bundler".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Morpho Bundler Multicall".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: format!("{} operation(s)", calls.len()), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: detail_fields + .into_iter() + .map(|f| AnnotatedPayloadField { + signable_payload_field: f, + static_annotation: None, + dynamic_annotation: None, + }) + .collect(), + }), + }, + }) + } + + /// Decodes a nested call within the multicall + fn decode_nested_call( + to: &Address, + data: &Bytes, + _value: &U256, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + if data.len() < 4 { + return Self::unknown_call_field(to, data); + } + + let selector = &data[0..4]; + + // Match known Morpho Bundler3 operation selectors (type-safe from sol! macros) + match selector { + // Standard ERC-2612 permit(address,address,uint256,uint256,uint8,bytes32,bytes32) + s if s == IERC2612::permitCall::SELECTOR => { + Self::decode_permit(data, to, chain_id, registry) + } + // IBundlerPermit::permit(address,address,uint256,uint256,uint8,bytes32,bytes32) + s if s == IBundlerPermit::permitCall::SELECTOR => { + Self::decode_permit(data, to, chain_id, registry) + } + // IERC20::transferFrom(address,address,uint256) - standard ERC20 + s if s == IERC20::transferFromCall::SELECTOR => { + Self::decode_erc20_transfer_from(&data[4..], chain_id, registry) + } + // IGeneralAdapter1::erc20TransferFrom(address,address,uint256) + s if s == IGeneralAdapter1::erc20TransferFromCall::SELECTOR => { + Self::decode_morpho_transfer_from(&data[4..], chain_id, registry) + } + // IGeneralAdapter1::erc4626Deposit(address,uint256,uint256,address) + s if s == IGeneralAdapter1::erc4626DepositCall::SELECTOR => { + Self::decode_erc4626_deposit(&data[4..], chain_id, registry) + } + _ => Self::unknown_call_field(to, data), + } + } + + /// Decodes ERC-2612 permit operation + /// This handles both standard ERC-2612 and custom Morpho Bundler3 permit calls. + fn decode_permit( + bytes: &[u8], + token_address: &Address, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + // Since both permit types have identical signatures, use IERC2612 for decoding + let call = match IERC2612::permitCall::abi_decode(bytes) { + Ok(c) => c, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "Permit: Invalid data".to_string(), + label: "Permit".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode permit parameters".to_string(), + }, + }; + } + }; + + let owner = call.owner; + let spender = call.spender; + let value = call.value; + let deadline = call.deadline; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, *token_address)) + .unwrap_or_else(|| format!("{:?}", token_address)); + + let value_u128: u128 = value.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, *token_address, value_u128)) + .unwrap_or_else(|| (value.to_string(), token_symbol.clone())); + + // Check if value is unlimited (U256::MAX for permit) + let display_amount = if is_unlimited_u256(value) { + "Unlimited".to_string() + } else { + amount_str.clone() + }; + + let deadline_str = if is_unlimited_u256(deadline) { + "No expiry".to_string() + } else { + let deadline_u64: u64 = deadline.to_string().parse().unwrap_or(0); + let dt = chrono::Utc.timestamp_opt(deadline_u64 as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + let summary = format!( + "Permit {} {} to {:?} (expires: {})", + display_amount, token_symbol, spender, deadline_str + ); + + // Create detailed parameter fields for debugging + let param_fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", token_address), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, token_address), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", owner), + label: "Owner".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", owner), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", spender), + label: "Spender".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", spender), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: value.to_string(), + label: "Value".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_unlimited_u256(value) { + format!("{} (unlimited)", value) + } else { + format!("{} {} (raw: {})", amount_str, token_symbol, value) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: deadline.to_string(), + label: "Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", deadline, deadline_str), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Permit".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "ERC-2612 Permit".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { + fields: param_fields, + }), + }, + } + } + + /// Decodes erc20TransferFrom operation using shared core IERC20 interface + /// This delegates to the core ERC-20 implementation for type-safe decoding + fn decode_erc20_transfer_from( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + // Use the shared IERC20 interface for decoding + let call = match IERC20::transferFromCall::abi_decode(bytes) { + Ok(c) => c, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("ERC20 Transfer From: 0x{}", hex::encode(bytes)), + label: "ERC20 Transfer From".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let token_address = call.from; // from is the token address in the encoding + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_address)) + .unwrap_or_else(|| format!("{:?}", token_address)); + + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_address, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + + let summary = format!( + "Transfer {} {} from {:?}", + amount_str, token_symbol, call.from + ); + + // Create detailed parameter fields for debugging + let param_fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", token_address), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, token_address), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.from), + label: "From".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.from), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: call.amount.to_string(), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, call.amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Transfer From".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "ERC20 Transfer From".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { + fields: param_fields, + }), + }, + } + } + + /// Decodes Morpho-specific ERC20 transfer operation + /// From GeneralAdapter1: erc20TransferFrom(address token, address receiver, uint256 amount) + /// Reference: https://github.com/morpho-org/bundler3/blob/3b22daf606bdef4f119f168c74496f87a90ac8e5/src/adapters/GeneralAdapter1.sol#L373 + fn decode_morpho_transfer_from( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + // Decode using type-safe Erc20TransferFromParams (token, receiver, amount) + let params = match Erc20TransferFromParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Morpho Transfer: 0x{}", hex::encode(bytes)), + label: "Morpho Transfer".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let token_address = params.token; + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_address)) + .unwrap_or_else(|| format!("{:?}", token_address)); + + let amount_u128: u128 = params.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_address, amount_u128)) + .unwrap_or_else(|| (params.amount.to_string(), token_symbol.clone())); + + let summary = format!( + "Transfer {} {} to {:?}", + amount_str, token_symbol, params.receiver + ); + + // Create detailed parameter fields + let param_fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", token_address), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, token_address), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.receiver), + label: "Receiver".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.receiver), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: params.amount.to_string(), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, params.amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Morpho Transfer".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "ERC20 Transfer".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { + fields: param_fields, + }), + }, + } + } + + /// Decodes erc4626Deposit operation + fn decode_erc4626_deposit( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match Erc4626DepositParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("ERC4626 Deposit: 0x{}", hex::encode(bytes)), + label: "ERC4626 Deposit".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + // Try to get vault info from registry + let vault_symbol = registry.and_then(|r| r.get_token_symbol(chain_id, params.vault)); + + let assets_u128: u128 = params.assets.to_string().parse().unwrap_or(0); + let min_shares_u128: u128 = params.minShares.to_string().parse().unwrap_or(0); + + // Format the deposit summary + let vault_display = vault_symbol + .as_ref() + .map(|s| format!("{} vault", s)) + .unwrap_or_else(|| format!("vault {:?}", params.vault)); + + let summary = format!( + "Deposit {} assets into {} (min {} shares) for {:?}", + assets_u128, vault_display, min_shares_u128, params.receiver + ); + + // Format vault display for expanded view + let vault_text = if let Some(symbol) = &vault_symbol { + format!("{} ({:?})", symbol, params.vault) + } else { + format!("{:?}", params.vault) + }; + + // Create detailed parameter fields for debugging + let param_fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.vault), + label: "Vault".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: vault_text }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: params.assets.to_string(), + label: "Assets".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: params.assets.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: params.minShares.to_string(), + label: "Min Shares".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: params.minShares.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.receiver), + label: "Receiver".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.receiver), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Vault Deposit".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "ERC4626 Vault Deposit".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { + fields: param_fields, + }), + }, + } + } + + /// Creates a field for unknown calls + fn unknown_call_field(to: &Address, data: &Bytes) -> SignablePayloadField { + let selector = if data.len() >= 4 { + format!("0x{}", hex::encode(&data[0..4])) + } else { + "Unknown".to_string() + }; + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Call to {:?}", to), + label: "Unknown Call".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("To: {:?}, Selector: {}", to, selector), + }, + } + } +} + +/// ContractVisualizer implementation for Morpho Bundler3 +pub struct BundlerContractVisualizer { + inner: BundlerVisualizer, +} + +impl BundlerContractVisualizer { + pub fn new() -> Self { + Self { + inner: BundlerVisualizer {}, + } + } +} + +impl Default for BundlerContractVisualizer { + fn default() -> Self { + Self::new() + } +} + +impl crate::visualizer::ContractVisualizer for BundlerContractVisualizer { + fn contract_type(&self) -> &str { + Bundler3Contract::short_type_id() + } + + fn visualize( + &self, + context: &VisualizerContext, + ) -> Result>, visualsign::vsptrait::VisualSignError> { + let (contract_registry, _visualizer_reg) = ContractRegistry::with_default_protocols(); + + if let Some(field) = self.inner.visualize_multicall( + &context.calldata, + context.chain_id, + Some(&contract_registry), + ) { + let annotated = AnnotatedPayloadField { + signable_payload_field: field, + static_annotation: None, + dynamic_annotation: None, + }; + + Ok(Some(vec![annotated])) + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_visualize_multicall_real_transaction() { + // Real Morpho transaction calldata with 3 operations + let input_hex = "374f435d00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000360000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4d505accf000000000000000000000000078473fc814d2581c0e9b06efb2443ea503421cb0000000000000000000000004a6c312ec70e8747a587ee860a0353cd42be0ae000000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000000000068f67d97000000000000000000000000000000000000000000000000000000000000001b5c10d948b0e33626f5f196df389c9f8b95c85a66065bc16c5a23a5ba9dde396941a237ed342773264d7a1694bcce90bf5538ae75eab39edd0ebcb1077442df9f000000000000000000000000000000000000000000000000000000000000000000000000000000004a6c312ec70e8747a587ee860a0353cd42be0ae000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064d96ca0b9000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000004a6c312ec70e8747a587ee860a0353cd42be0ae000000000000000000000000000000000000000000000000000000000000f4240000000000000000000000000000000000000000000000000000000000000000000000000000000004a6c312ec70e8747a587ee860a0353cd42be0ae000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000846ef5eeae000000000000000000000000beef01735c132ada46aa9aa4c54623caa92a64cb00000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000003ece3bf77e9a9000000000000000000000000078473fc814d2581c0e9b06efb2443ea503421cb0000000000000000000000000000000000000000000000000000000068f661a72222da44"; + let input = hex::decode(input_hex).unwrap(); + + let (registry, _) = ContractRegistry::with_default_protocols(); + let result = BundlerVisualizer {}.visualize_multicall(&input, 1, Some(®istry)); + + assert!( + result.is_some(), + "Should successfully decode Morpho multicall" + ); + + let field = result.unwrap(); + if let SignablePayloadField::PreviewLayout { + common, + preview_layout, + } = field + { + assert!( + common.fallback_text.contains("3 operations"), + "Expected 3 operations, got: {}", + common.fallback_text + ); + + assert!( + preview_layout.expanded.is_some(), + "Expected expanded section" + ); + + if let Some(list_layout) = preview_layout.expanded { + assert_eq!(list_layout.fields.len(), 3, "Expected 3 decoded operations"); + } + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_permit() { + // Create proper ERC-2612 permit calldata with function selector + let mut calldata = Vec::new(); + + // Add ERC-2612 permit function selector: permit(address,address,uint256,uint256,uint8,bytes32,bytes32) + // Selector: 0xd505accf + calldata.extend_from_slice(&[0xd5, 0x05, 0xac, 0xcf]); + + // ABI encode the parameters + let owner = + Address::from_slice(&hex::decode("078473fc814d2581c0e9b06efb2443ea503421cb").unwrap()); + let spender = + Address::from_slice(&hex::decode("4a6c312ec70e8747a587ee860a0353cd42be0ae0").unwrap()); + let value = U256::from(1000000u64); // 1 USDC (6 decimals) + let deadline = U256::from(1758288535u64); // Future timestamp + let v = 27u8; + let r = [1u8; 32]; // Dummy signature + let s = [2u8; 32]; // Dummy signature + + // Encode parameters (each is 32 bytes in ABI encoding) + calldata.extend_from_slice(&[0u8; 12]); // padding for address + calldata.extend_from_slice(owner.as_slice()); // owner + calldata.extend_from_slice(&[0u8; 12]); // padding for address + calldata.extend_from_slice(spender.as_slice()); // spender + calldata.extend_from_slice(&value.to_be_bytes::<32>()); // value + calldata.extend_from_slice(&deadline.to_be_bytes::<32>()); // deadline + calldata.extend_from_slice(&[0u8; 31]); // padding for uint8 + calldata.push(v); // v + calldata.extend_from_slice(&r); // r + calldata.extend_from_slice(&s); // s + + let token_address: Address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + .parse() + .unwrap(); // USDC + + let (registry, _) = ContractRegistry::with_default_protocols(); + let result = + BundlerVisualizer::decode_permit(&calldata, &token_address, 1, Some(®istry)); + + match result { + SignablePayloadField::PreviewLayout { + common, + preview_layout, + } => { + assert_eq!(common.label, "Permit"); + assert!(common.fallback_text.contains("USDC")); + + // Verify expanded view has parameters + assert!(preview_layout.expanded.is_some()); + if let Some(expanded) = preview_layout.expanded { + assert_eq!(expanded.fields.len(), 5, "Should have 5 parameter fields"); + } + } + other => panic!("Expected PreviewLayout field, got: {:?}", other), + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/mod.rs new file mode 100644 index 00000000..899cdc31 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/mod.rs @@ -0,0 +1,3 @@ +pub mod bundler; + +pub use bundler::{BundlerContractVisualizer, BundlerVisualizer}; diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/mod.rs new file mode 100644 index 00000000..92d8e9bd --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/mod.rs @@ -0,0 +1,64 @@ +//! Morpho protocol implementation +//! +//! This module contains contract visualizers, configuration, and registration +//! logic for the Morpho lending protocol. +//! +//! Morpho is a decentralized lending protocol that optimizes interest rates +//! through peer-to-peer matching while maintaining liquidity pool fallbacks. + +pub mod config; +pub mod contracts; + +use crate::registry::ContractRegistry; +use crate::visualizer::EthereumVisualizerRegistryBuilder; + +pub use config::{Bundler3Contract, MorphoConfig}; +pub use contracts::{BundlerContractVisualizer, BundlerVisualizer}; + +/// Registers all Morpho protocol contracts and visualizers +/// +/// This function: +/// 1. Registers contract addresses in the ContractRegistry for address-to-type lookup +/// 2. Registers visualizers in the EthereumVisualizerRegistryBuilder for transaction visualization +/// +/// # Arguments +/// * `contract_reg` - The contract registry to register addresses +/// * `visualizer_reg` - The visualizer registry to register visualizers +pub fn register( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + // Register Bundler3 contract on all supported chains + MorphoConfig::register_contracts(contract_reg); + + // Register visualizers + visualizer_reg.register(Box::new(BundlerContractVisualizer::new())); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::ContractType; + + #[test] + fn test_register_morpho_contracts() { + let mut contract_reg = ContractRegistry::new(); + let mut visualizer_reg = EthereumVisualizerRegistryBuilder::new(); + + register(&mut contract_reg, &mut visualizer_reg); + + // Verify Bundler3 is registered on all supported chains + for chain_id in [1, 8453, 42161] { + let expected_address = MorphoConfig::bundler3_address(chain_id) + .expect(&format!("Should have valid address for chain {}", chain_id)); + + let contract_type = contract_reg + .get_contract_type(chain_id, expected_address) + .expect(&format!( + "Bundler3 should be registered on chain {}", + chain_id + )); + assert_eq!(contract_type, Bundler3Contract::short_type_id()); + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/README.md b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/README.md new file mode 100644 index 00000000..fc3f08ac --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/README.md @@ -0,0 +1,47 @@ +# Uniswap Protocol + +## Contracts + +| Contract | Address | +|-----------------------|----------------------------------------------| +| Universal Router V1.2 | `0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD` | +| Permit2 | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | + +## Networks + +| Chain | ID | +|----------|-------| +| Ethereum | 1 | +| Optimism | 10 | +| Polygon | 137 | +| Base | 8453 | +| Arbitrum | 42161 | + +## Universal Router Commands + +Reference: [Dispatcher.sol](https://github.com/Uniswap/universal-router/blob/main/contracts/base/Dispatcher.sol) + +| Cmd | Name | Parameters (Solidity) | Status | +| ---- | --------------------------- | ------------------------------------------------------- | ------- | +| 0x00 | V3_SWAP_EXACT_IN | `(address, uint256, uint256, bytes path, bool)` | Custom | +| 0x01 | V3_SWAP_EXACT_OUT | `(address, uint256, uint256, bytes path, bool)` | Custom | +| 0x02 | PERMIT2_TRANSFER_FROM | `(address, address, uint160)` | Custom | +| 0x03 | PERMIT2_PERMIT_BATCH | `(PermitBatch, bytes)` | - | +| 0x04 | SWEEP | `(address token, address recipient, uint256 amountMin)` | Custom | +| 0x05 | TRANSFER | `(address, address, uint256)` | Custom | +| 0x06 | PAY_PORTION | `(address, address, uint256 bips)` | Custom | +| 0x08 | V2_SWAP_EXACT_IN | `(address, uint256, uint256, address[] path, address)` | Custom | +| 0x09 | V2_SWAP_EXACT_OUT | `(uint256, uint256, address[] path, address)` | Custom | +| 0x0A | PERMIT2_PERMIT | `(PermitSingle, bytes sig)` | Custom | +| 0x0B | WRAP_ETH | `(address recipient, uint256 amountMin)` | Custom | +| 0x0C | UNWRAP_WETH | `(address, uint256)` | Custom | +| 0x0D | PERMIT2_TRANSFER_FROM_BATCH | `(AllowanceTransferDetails[])` | - | +| 0x0E | BALANCE_CHECK_ERC20 | `(address, address, uint256)` | - | +| 0x10 | V4_SWAP | `(bytes)` | - | +| 0x11 | V3_POSITION_MANAGER_PERMIT | `(bytes)` | Default | +| 0x12 | V3_POSITION_MANAGER_CALL | `(bytes)` | Default | +| 0x13 | V4_INITIALIZE_POOL | `(PoolKey, uint160)` | - | +| 0x14 | V4_POSITION_MANAGER_CALL | `(bytes)` | Default | +| 0x21 | EXECUTE_SUB_PLAN | `(bytes commands, bytes[] inputs)` | - | + +**Status:** `Custom` = human-readable, `Default` = raw hex, `-` = not implemented diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/config.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/config.rs new file mode 100644 index 00000000..2e2f9f41 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/config.rs @@ -0,0 +1,387 @@ +//! Uniswap protocol configuration +//! +//! Contains contract addresses, chain deployments, and protocol metadata. +//! +//! # Deployment Addresses +//! +//! Official Uniswap Universal Router deployments are documented at: +//! +//! +//! Each network has a JSON file (e.g., mainnet.json, optimism.json) containing: +//! - `UniversalRouterV1`: Legacy V1 router +//! - `UniversalRouterV1_2_V2Support`: V1.2 with V2 support +//! - `UniversalRouterV2`: Latest V2 router +//! +//! Currently, only V1.2 is implemented. Future versions should be added as separate +//! contract type markers below. + +use crate::registry::{ContractRegistry, ContractType}; +use crate::token_metadata::{ErcStandard, TokenMetadata}; +use alloy_primitives::Address; + +/// Re-export chain ID constants from crate::networks::id +/// +/// This provides access to chain constants like `networks::ethereum::MAINNET` +/// for use in Uniswap configuration. +/// +/// Note: Not all networks in `crate::networks::id` have Universal Router V1.2 deployments. +/// See `UniswapConfig::universal_router_chains()` for the list of supported networks. +pub use crate::networks::id as networks; + +/// Error type for Uniswap configuration operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UniswapConfigError { + /// Chain ID is not supported for Universal Router V1.2 + UnsupportedChain(u64), + /// Address string failed to parse (should never happen with hardcoded addresses) + InvalidAddress(String), +} + +impl std::fmt::Display for UniswapConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UniswapConfigError::UnsupportedChain(chain_id) => { + write!( + f, + "Unsupported chain ID for Universal Router V1.2: {chain_id}" + ) + } + UniswapConfigError::InvalidAddress(addr) => { + write!(f, "Invalid address: {addr}") + } + } + } +} + +impl std::error::Error for UniswapConfigError {} + +/// Contract type marker for Uniswap Universal Router V1.2 +/// +/// This is the V1.2 router with V2 support. Addresses vary by chain. +/// +/// Reference: +#[derive(Debug, Clone, Copy)] +pub struct UniswapUniversalRouter; + +impl ContractType for UniswapUniversalRouter {} + +/// Contract type marker for Permit2 +/// +/// Permit2 is a token approval contract that unifies the approval experience across all applications. +/// It is deployed at the same address (0x000000000022D473030F116dDEE9F6B43aC78BA3) on all chains. +/// +/// Reference: +#[derive(Debug, Clone, Copy)] +pub struct Permit2Contract; + +impl ContractType for Permit2Contract {} + +// TODO: Add contract type markers for other Universal Router versions +// +// /// Universal Router V1 (legacy) - 0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B +// #[derive(Debug, Clone, Copy)] +// pub struct UniswapUniversalRouterV1; +// impl ContractType for UniswapUniversalRouterV1 {} +// +// /// Universal Router V2 (latest) - 0x66a9893cc07d91d95644aedd05d03f95e1dba8af +// #[derive(Debug, Clone, Copy)] +// pub struct UniswapUniversalRouterV2; +// impl ContractType for UniswapUniversalRouterV2 {} + +// TODO: Add V4 PoolManager contract type +// +// V4 requires the PoolManager contract for liquidity pool management. +// Deployments: +// +// /// Uniswap V4 PoolManager +// #[derive(Debug, Clone, Copy)] +// pub struct UniswapV4PoolManager; +// impl ContractType for UniswapV4PoolManager {} + +/// Uniswap protocol configuration +pub struct UniswapConfig; + +impl UniswapConfig { + /// Returns the Universal Router V1.2 address for a specific chain + /// + /// Source: + pub fn universal_router_address(chain_id: u64) -> Result { + let addr_str = match chain_id { + // Mainnets + networks::ethereum::MAINNET => "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + networks::optimism::MAINNET => "0xCb1355ff08Ab38bBCE60111F1bb2B784bE25D7e8", + networks::bsc::MAINNET => "0x4Dae2f939ACf50408e13d58534Ff8c2776d45265", + networks::polygon::MAINNET => "0xec7BE89e9d109e7e3Fec59c222CF297125FEFda2", + networks::worldchain::MAINNET => "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", + networks::base::MAINNET => "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + networks::arbitrum::MAINNET => "0x5E325eDA8064b456f4781070C0738d849c824258", + networks::celo::MAINNET => "0x643770e279d5d0733f21d6dc03a8efbabf3255b4", + networks::avalanche::MAINNET => "0x4Dae2f939ACf50408e13d58534Ff8c2776d45265", + networks::blast::MAINNET => "0x643770E279d5D0733F21d6DC03A8efbABf3255B4", + // Testnets + networks::ethereum::SEPOLIA => "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + _ => return Err(UniswapConfigError::UnsupportedChain(chain_id)), + }; + addr_str + .parse() + .map_err(|_| UniswapConfigError::InvalidAddress(addr_str.to_string())) + } + + /// Returns the chain IDs where Universal Router V1.2 is deployed + /// + /// Source: + pub fn universal_router_chains() -> &'static [u64] { + &[ + // Mainnets + networks::ethereum::MAINNET, + networks::optimism::MAINNET, + networks::bsc::MAINNET, + networks::polygon::MAINNET, + networks::worldchain::MAINNET, + networks::base::MAINNET, + networks::arbitrum::MAINNET, + networks::celo::MAINNET, + networks::avalanche::MAINNET, + networks::blast::MAINNET, + // Testnets + networks::ethereum::SEPOLIA, + ] + } + + /// Returns the Permit2 contract address + /// + /// Permit2 is deployed at the same address across all chains. + /// + /// Source: + pub fn permit2_address() -> Address { + crate::utils::address_utils::WellKnownAddresses::permit2() + } + + // TODO: Add methods for other Universal Router versions + // + // Source: https://github.com/Uniswap/universal-router/tree/main/deploy-addresses + // + // pub fn universal_router_v1_address() -> Address { + // "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B".parse().unwrap() + // } + // pub fn universal_router_v1_chains() -> &'static [u64] { ... } + // + // pub fn universal_router_v2_address() -> Address { + // "0x66a9893cc07d91d95644aedd05d03f95e1dba8af".parse().unwrap() + // } + // pub fn universal_router_v2_chains() -> &'static [u64] { ... } + + // TODO: Add methods for V4 PoolManager + // + // Source: https://docs.uniswap.org/contracts/v4/deployments + // + // pub fn v4_pool_manager_address() -> Address { ... } + // pub fn v4_pool_manager_chains() -> &'static [u64] { ... } + + /// Returns the WETH address for a given chain + /// + /// WETH (Wrapped ETH) addresses vary by chain. This method returns the canonical + /// WETH address for supported chains. + pub fn weth_address(chain_id: u64) -> Option
{ + let addr_str = match chain_id { + networks::ethereum::MAINNET => "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + networks::optimism::MAINNET => "0x4200000000000000000000000000000000000006", + networks::polygon::MAINNET => "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", + networks::base::MAINNET => "0x4200000000000000000000000000000000000006", + networks::arbitrum::MAINNET => "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + _ => return None, + }; + addr_str.parse().ok() + } + + /// Registers common tokens used in Uniswap transactions + /// + /// This registers tokens like WETH across multiple chains so they can be + /// resolved by symbol during transaction visualization. + pub fn register_common_tokens(registry: &mut ContractRegistry) { + // WETH on Ethereum Mainnet (WETH9 contract) + let _ = registry.register_token( + networks::ethereum::MAINNET, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2".to_string(), + decimals: 18, + }, + ); + + // WETH on Optimism + let _ = registry.register_token( + networks::optimism::MAINNET, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x4200000000000000000000000000000000000006".to_string(), + decimals: 18, + }, + ); + + // WETH on Polygon + let _ = registry.register_token( + networks::polygon::MAINNET, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619".to_string(), + decimals: 18, + }, + ); + + // WETH on Base + let _ = registry.register_token( + networks::base::MAINNET, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x4200000000000000000000000000000000000006".to_string(), + decimals: 18, + }, + ); + + // WETH on Arbitrum + let _ = registry.register_token( + networks::arbitrum::MAINNET, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x82af49447d8a07e3bd95bd0d56f35241523fbab1".to_string(), + decimals: 18, + }, + ); + + // Add common tokens on Ethereum Mainnet + // USDC + let _ = registry.register_token( + networks::ethereum::MAINNET, + TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + decimals: 6, + }, + ); + + // USDT + let _ = registry.register_token( + networks::ethereum::MAINNET, + TokenMetadata { + symbol: "USDT".to_string(), + name: "Tether USD".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xdac17f958d2ee523a2206206994597c13d831ec7".to_string(), + decimals: 6, + }, + ); + + // DAI + let _ = registry.register_token( + networks::ethereum::MAINNET, + TokenMetadata { + symbol: "DAI".to_string(), + name: "Dai Stablecoin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x6b175474e89094c44da98b954eedeac495271d0f".to_string(), + decimals: 18, + }, + ); + + // SETH (Sonne Ethereum - or other SETH variant) + let _ = registry.register_token( + networks::ethereum::MAINNET, + TokenMetadata { + symbol: "SETH".to_string(), + name: "SETH".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xe71bdfe1df69284f00ee185cf0d95d0c7680c0d4".to_string(), + decimals: 18, + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_universal_router_address_ethereum() { + let expected: Address = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD" + .parse() + .unwrap(); + assert_eq!( + UniswapConfig::universal_router_address(networks::ethereum::MAINNET).unwrap(), + expected + ); + } + + #[test] + fn test_universal_router_address_arbitrum() { + let expected: Address = "0x5E325eDA8064b456f4781070C0738d849c824258" + .parse() + .unwrap(); + assert_eq!( + UniswapConfig::universal_router_address(networks::arbitrum::MAINNET).unwrap(), + expected + ); + } + + #[test] + fn test_universal_router_address_optimism() { + let expected: Address = "0xCb1355ff08Ab38bBCE60111F1bb2B784bE25D7e8" + .parse() + .unwrap(); + assert_eq!( + UniswapConfig::universal_router_address(networks::optimism::MAINNET).unwrap(), + expected + ); + } + + #[test] + fn test_universal_router_address_unsupported_chain() { + let result = UniswapConfig::universal_router_address(999999); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + UniswapConfigError::UnsupportedChain(999999) + ); + } + + #[test] + fn test_universal_router_chains() { + let chains = UniswapConfig::universal_router_chains(); + assert!(chains.contains(&networks::ethereum::MAINNET)); + assert!(chains.contains(&networks::optimism::MAINNET)); + assert!(chains.contains(&networks::arbitrum::MAINNET)); + assert!(chains.contains(&networks::base::MAINNET)); + assert!(chains.contains(&networks::polygon::MAINNET)); + assert!(chains.contains(&networks::ethereum::SEPOLIA)); // testnet + } + + #[test] + fn test_contract_type_id() { + let type_id = UniswapUniversalRouter::short_type_id(); + assert_eq!(type_id, "UniswapUniversalRouter"); + } + + #[test] + fn test_all_chains_have_valid_addresses() { + for &chain_id in UniswapConfig::universal_router_chains() { + let result = UniswapConfig::universal_router_address(chain_id); + assert!( + result.is_ok(), + "Chain {chain_id} should have a valid address" + ); + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/mod.rs new file mode 100644 index 00000000..55ab80d2 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/mod.rs @@ -0,0 +1,9 @@ +//! Uniswap protocol contract visualizers + +pub mod permit2; +pub mod universal_router; +pub mod v4_pool; + +pub use permit2::{Permit2ContractVisualizer, Permit2Visualizer}; +pub use universal_router::{UniversalRouterContractVisualizer, UniversalRouterVisualizer}; +pub use v4_pool::V4PoolManagerVisualizer; diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/permit2.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/permit2.rs new file mode 100644 index 00000000..9b84e0ba --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/permit2.rs @@ -0,0 +1,426 @@ +//! Permit2 Contract Visualizer +//! +//! Permit2 is Uniswap's token approval system that allows signature-based approvals +//! and transfers, improving UX by batching operations. +//! +//! Reference: + +#![allow(unused_imports)] + +use alloy_primitives::{Address, U160}; +use alloy_sol_types::{SolCall, sol}; +use chrono::{TimeZone, Utc}; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +use crate::registry::{ContractRegistry, ContractType}; + +// Permit2 interface (simplified) +sol! { + interface IPermit2 { + function approve(address token, address spender, uint160 amount, uint48 expiration) external; + function permit(address owner, PermitSingle calldata permitSingle, bytes calldata signature) external; + function transferFrom(address from, address to, uint160 amount, address token) external; + } + + struct PermitSingle { + PermitDetails details; + address spender; + uint256 sigDeadline; + } + + struct PermitDetails { + address token; + uint160 amount; + uint48 expiration; + uint48 nonce; + } +} + +/// Visualizer for Permit2 contract calls +/// +/// Permit2 address: 0x000000000022D473030F116dDEE9F6B43aC78BA3 +/// (deployed at the same address across all chains) +pub struct Permit2Visualizer; + +impl Permit2Visualizer { + /// Attempts to decode and visualize Permit2 function calls + /// + /// # Arguments + /// * `input` - The calldata bytes (with 4-byte function selector) + /// * `chain_id` - The chain ID for token lookups + /// * `registry` - Optional contract registry for token metadata + /// + /// # Returns + /// * `Some(field)` if a recognized Permit2 function is found + /// * `None` if the input doesn't match any Permit2 function + pub fn visualize_tx_commands( + &self, + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + // Try to decode as approve + if let Ok(call) = IPermit2::approveCall::abi_decode(input) { + return Some(Self::decode_approve(call, chain_id, registry)); + } + + // Try to decode as permit (standard ABI) + if let Ok(call) = IPermit2::permitCall::abi_decode(input) { + return Some(Self::decode_permit(call, chain_id, registry)); + } + + // Try custom permit encoding (used by Universal Router) + if let Ok(params) = Self::decode_custom_permit_params(input) { + let call = IPermit2::permitCall { + owner: Address::ZERO, + permitSingle: params, + signature: alloy_primitives::Bytes::default(), + }; + return Some(Self::decode_permit(call, chain_id, registry)); + } + + // Try to decode as transferFrom + if let Ok(call) = IPermit2::transferFromCall::abi_decode(input) { + return Some(Self::decode_transfer_from(call, chain_id, registry)); + } + + None + } + + /// Decodes custom permit parameter layout (used by Uniswap Universal Router) + /// Universal Router encodes PermitSingle as inline 192 bytes (no ABI encoding with offsets) + pub(crate) fn decode_custom_permit_params( + bytes: &[u8], + ) -> Result> { + use alloy_sol_types::SolValue; + + if bytes.len() < 192 { + return Err("bytes too short for PermitSingle (need 192 bytes minimum)".into()); + } + + // Extract the 192-byte inline struct and decode as PermitSingle + let permit_single_bytes = &bytes[0..192]; + PermitSingle::abi_decode(permit_single_bytes) + .map_err(|e| format!("Failed to decode PermitSingle: {e}").into()) + } + + /// Decodes approve function call + fn decode_approve( + call: IPermit2::approveCall, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.token)) + .unwrap_or_else(|| format!("{:?}", call.token)); + + // Format amount with proper decimals + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.token, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + + // Format expiration timestamp + let expiration_u64: u64 = call.expiration.to_string().parse().unwrap_or(0); + let expiration_str = if expiration_u64 == u64::MAX { + "never".to_string() + } else { + let dt = Utc.timestamp_opt(expiration_u64 as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + let text = format!( + "Approve {} {} {} to spend {} (expires: {})", + call.spender, amount_str, token_symbol, token_symbol, expiration_str + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Permit2 Approve".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } + + /// Decodes permit function call + fn decode_permit( + call: IPermit2::permitCall, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let token = call.permitSingle.details.token; + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token)) + .unwrap_or_else(|| format!("{token:?}")); + + // Format amount with proper decimals + let amount_u128: u128 = call + .permitSingle + .details + .amount + .to_string() + .parse() + .unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token, amount_u128)) + .unwrap_or_else(|| { + ( + call.permitSingle.details.amount.to_string(), + token_symbol.clone(), + ) + }); + + // Format expiration timestamp + let expiration_u64: u64 = call + .permitSingle + .details + .expiration + .to_string() + .parse() + .unwrap_or(0); + let expiration_str = if expiration_u64 == u64::MAX { + "never".to_string() + } else { + let dt = Utc.timestamp_opt(expiration_u64 as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + // Format sig deadline timestamp + let sig_deadline_u64: u64 = call + .permitSingle + .sigDeadline + .to_string() + .parse() + .unwrap_or(0); + let sig_deadline_str = if sig_deadline_u64 == u64::MAX { + "never".to_string() + } else { + let dt = Utc.timestamp_opt(sig_deadline_u64 as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + // Determine if amount is "unlimited" (max u160) + let amount_display = if call.permitSingle.details.amount == U160::MAX { + "Unlimited Amount".to_string() + } else { + amount_str.clone() + }; + + let token_lowercase = token.to_string().to_lowercase(); + let subtitle_text = format!( + "Permit {} to spend {} of {}", + call.permitSingle.spender, amount_display, token_lowercase + ); + + let title_text = "Permit2 Permit".to_string(); + + // Build expanded fields + let expanded_fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_lowercase.clone(), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_lowercase.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: call.permitSingle.details.amount.to_string(), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: call.permitSingle.details.amount.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: call.permitSingle.spender.to_string().to_lowercase(), + label: "Spender".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: call.permitSingle.spender.to_string().to_lowercase(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: expiration_str.clone(), + label: "Expires".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: expiration_str, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: sig_deadline_str.clone(), + label: "Sig Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: sig_deadline_str, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: subtitle_text.clone(), + label: title_text.clone(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { text: title_text }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: subtitle_text, + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: expanded_fields, + }), + }, + } + } + + /// Decodes transferFrom function call + fn decode_transfer_from( + call: IPermit2::transferFromCall, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.token)) + .unwrap_or_else(|| format!("{:?}", call.token)); + + // Format amount with proper decimals + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.token, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + + let text = format!( + "Transfer {} {} from {} to {}", + amount_str, token_symbol, call.from, call.to + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Permit2 Transfer".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } +} + +/// CalldataVisualizer implementation for Permit2 +/// Allows delegating calldata directly to Permit2Visualizer +impl crate::visualizer::CalldataVisualizer for Permit2Visualizer { + fn visualize_calldata( + &self, + calldata: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + self.visualize_tx_commands(calldata, chain_id, registry) + } +} + +/// ContractVisualizer implementation for Permit2 +pub struct Permit2ContractVisualizer { + inner: Permit2Visualizer, +} + +impl Permit2ContractVisualizer { + pub fn new() -> Self { + Self { + inner: Permit2Visualizer, + } + } +} + +impl Default for Permit2ContractVisualizer { + fn default() -> Self { + Self::new() + } +} + +impl crate::visualizer::ContractVisualizer for Permit2ContractVisualizer { + fn contract_type(&self) -> &str { + crate::protocols::uniswap::config::Permit2Contract::short_type_id() + } + + fn visualize( + &self, + context: &crate::context::VisualizerContext, + ) -> Result>, visualsign::vsptrait::VisualSignError> + { + let (contract_registry, _visualizer_builder) = + crate::registry::ContractRegistry::with_default_protocols(); + + if let Some(field) = self.inner.visualize_tx_commands( + &context.calldata, + context.chain_id, + Some(&contract_registry), + ) { + let annotated = visualsign::AnnotatedPayloadField { + signable_payload_field: field, + static_annotation: None, + dynamic_annotation: None, + }; + + Ok(Some(vec![annotated])) + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_visualize_empty_input() { + let visualizer = Permit2Visualizer; + assert_eq!(visualizer.visualize_tx_commands(&[], 1, None), None); + } + + #[test] + fn test_visualize_too_short() { + let visualizer = Permit2Visualizer; + assert_eq!( + visualizer.visualize_tx_commands(&[0x01, 0x02], 1, None), + None + ); + } + + // TODO: Add tests for Permit2 functions once implemented +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/universal_router.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/universal_router.rs new file mode 100644 index 00000000..814b7b6c --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/universal_router.rs @@ -0,0 +1,2251 @@ +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::{SolCall as _, SolType, SolValue, sol}; +use chrono::{TimeZone, Utc}; +use num_enum::TryFromPrimitive; +use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; + +use crate::protocols::uniswap::contracts::permit2::Permit2Visualizer; +use crate::registry::{ContractRegistry, ContractType}; +use crate::visualizer::CalldataVisualizer; + +// Uniswap Universal Router interface definitions +// +// Official Documentation: +// - Technical Reference: https://docs.uniswap.org/contracts/universal-router/technical-reference +// - Contract Source: https://github.com/Uniswap/universal-router/blob/main/contracts/interfaces/IUniversalRouter.sol +// +// The Universal Router supports function overloading with two execute variants: +// 1. execute(bytes,bytes[],uint256) - with deadline parameter for time-bound execution +// 2. execute(bytes,bytes[]) - without deadline for flexible execution +// +// Each function gets a unique 4-byte selector based on its signature. +sol! { + interface IUniversalRouter { + /// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired. + /// @param commands A set of concatenated commands, each 1 byte in length + /// @param inputs An array of byte strings containing abi encoded inputs for each command + /// @param deadline The deadline by which the transaction must be executed + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; + + /// @notice Executes encoded commands along with provided inputs (no deadline check) + /// @param commands A set of concatenated commands, each 1 byte in length + /// @param inputs An array of byte strings containing abi encoded inputs for each command + function execute(bytes calldata commands, bytes[] calldata inputs) external payable; + } +} + +// Command parameter structures +// +// These structs define the ABI-encoded parameters for each command type. +// Reference: https://docs.uniswap.org/contracts/universal-router/technical-reference +// Source: https://github.com/Uniswap/universal-router/blob/main/contracts/modules/uniswap/v3/V3SwapRouter.sol +sol! { + /// Parameters for V3_SWAP_EXACT_IN command + struct V3SwapExactInputParams { + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + bytes path; + bool payerIsUser; + } + + /// Parameters for V3_SWAP_EXACT_OUT command + struct V3SwapExactOutputParams { + address recipient; + uint256 amountOut; + uint256 amountInMaximum; + bytes path; + bool payerIsUser; + } + + /// Parameters for PAY_PORTION command + struct PayPortionParams { + address token; + address recipient; + uint256 bips; + } + + /// Parameters for UNWRAP_WETH command + struct UnwrapWethParams { + address recipient; + uint256 amountMinimum; + } + + /// Parameters for V2_SWAP_EXACT_IN command + /// Source: https://github.com/Uniswap/universal-router/blob/main/contracts/modules/uniswap/v2/V2SwapRouter.sol + /// function v2SwapExactInput(address recipient, uint256 amountIn, uint256 amountOutMinimum, address[] calldata path, address payer) + struct V2SwapExactInputParams { + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + address[] path; + address payer; + } + + /// Parameters for V2_SWAP_EXACT_OUT command + struct V2SwapExactOutputParams { + uint256 amountOut; + uint256 amountInMaximum; + address[] path; + address recipient; + } + + /// Parameters for WRAP_ETH command + /// Source: https://github.com/Uniswap/universal-router/blob/main/contracts/libraries/Dispatcher.sol + /// (address recipient, uint256 amountMin) = abi.decode(inputs, (address, uint256)); + struct WrapEthParams { + address recipient; + uint256 amountMin; + } + + /// Parameters for SWEEP command + /// Source: https://github.com/Uniswap/universal-router/blob/main/contracts/libraries/Dispatcher.sol + /// (address token, address recipient, uint256 amountMin) = abi.decode(inputs, (address, address, uint256)); + struct SweepParams { + address token; + address recipient; + uint256 amountMinimum; + } + + /// Parameters for TRANSFER command + struct TransferParams { + address from; + address to; + uint160 amount; + } + + /// Parameters for PERMIT2_TRANSFER_FROM command + struct Permit2TransferFromParams { + address from; + address to; + uint160 amount; + address token; + } + + /// Parameters for PERMIT2_PERMIT command + struct PermitDetails { + address token; + uint160 amount; + uint48 expiration; + uint48 nonce; + } + + struct PermitSingle { + PermitDetails details; + address spender; + uint256 sigDeadline; + } + + struct Permit2PermitParams { + PermitSingle permitSingle; + bytes signature; + } +} + +// Command IDs for Universal Router +// +// Reference: https://docs.uniswap.org/contracts/universal-router/technical-reference +// Source: https://github.com/Uniswap/universal-router/blob/main/contracts/libraries/Commands.sol +// +// Commands are encoded as single bytes and define the operation to execute. +// The Universal Router processes these commands sequentially. +#[derive(Copy, Clone, Debug, Eq, PartialEq, TryFromPrimitive)] +#[repr(u8)] +pub enum Command { + V3SwapExactIn = 0x00, + V3SwapExactOut = 0x01, + Permit2TransferFrom = 0x02, + Permit2PermitBatch = 0x03, + Sweep = 0x04, + Transfer = 0x05, + PayPortion = 0x06, + + V2SwapExactIn = 0x08, + V2SwapExactOut = 0x09, + Permit2Permit = 0x0a, + WrapEth = 0x0b, + UnwrapWeth = 0x0c, + Permit2TransferFromBatch = 0x0d, + BalanceCheckErc20 = 0x0e, + + V4Swap = 0x10, + V3PositionManagerPermit = 0x11, + V3PositionManagerCall = 0x12, + V4InitializePool = 0x13, + V4PositionManagerCall = 0x14, + + ExecuteSubPlan = 0x21, +} + +fn map_commands(raw: &[u8]) -> Vec { + let mut out = Vec::with_capacity(raw.len()); + for &b in raw { + if let Ok(cmd) = Command::try_from(b) { + out.push(cmd); + } + } + out +} + +/// Visualizer for Uniswap Universal Router +/// +/// Handles the `execute` function from IUniversalRouter interface: +/// +pub struct UniversalRouterVisualizer {} + +impl UniversalRouterVisualizer { + /// Visualizes Uniswap Universal Router Execute commands + /// + /// # Arguments + /// * `input` - The calldata bytes + /// * `chain_id` - The chain ID for registry lookups + /// * `registry` - Optional registry for resolving token symbols + pub fn visualize_tx_commands( + &self, + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + // Try decoding with deadline first (3-parameter version) + if let Ok(call) = IUniversalRouter::execute_0Call::abi_decode(input) { + let deadline_val: i64 = match call.deadline.try_into() { + Ok(val) => val, + Err(_) => return None, + }; + let deadline = if deadline_val > 0 { + Utc.timestamp_opt(deadline_val, 0) + .single() + .map(|dt| dt.to_string()) + } else { + None + }; + return Self::visualize_commands( + &call.commands.0, + &call.inputs, + deadline, + chain_id, + registry, + ); + } + + // Try decoding without deadline (2-parameter version) + if let Ok(call) = IUniversalRouter::execute_1Call::abi_decode(input) { + return Self::visualize_commands( + &call.commands.0, + &call.inputs, + None, + chain_id, + registry, + ); + } + + None + } + + /// Helper function to visualize commands (shared by both execute variants) + fn visualize_commands( + commands: &[u8], + inputs: &[alloy_primitives::Bytes], + deadline: Option, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let mapped = map_commands(commands); + let mut detail_fields = Vec::new(); + + for (i, cmd) in mapped.iter().enumerate() { + let input_bytes = inputs.get(i).map(|b| &b.0[..]); + + // Decode command-specific parameters + let field = if let Some(bytes) = input_bytes { + match cmd { + Command::V3SwapExactIn => { + Self::decode_v3_swap_exact_in(bytes, chain_id, registry) + } + Command::V3SwapExactOut => { + Self::decode_v3_swap_exact_out(bytes, chain_id, registry) + } + Command::V2SwapExactIn => { + Self::decode_v2_swap_exact_in(bytes, chain_id, registry) + } + Command::V2SwapExactOut => { + Self::decode_v2_swap_exact_out(bytes, chain_id, registry) + } + Command::PayPortion => Self::decode_pay_portion(bytes, chain_id, registry), + Command::WrapEth => Self::decode_wrap_eth(bytes, chain_id, registry), + Command::UnwrapWeth => Self::decode_unwrap_weth(bytes, chain_id, registry), + Command::Sweep => Self::decode_sweep(bytes, chain_id, registry), + Command::Transfer => Self::decode_transfer(bytes, chain_id, registry), + Command::Permit2TransferFrom => { + Self::decode_permit2_transfer_from(bytes, chain_id, registry) + } + Command::Permit2Permit => { + Self::decode_permit2_permit(bytes, chain_id, registry) + } + _ => { + // For unimplemented commands, show hex + let input_hex = format!("0x{}", hex::encode(bytes)); + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{cmd:?} input: {input_hex}"), + label: format!("{cmd:?}"), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Input: {input_hex}"), + }, + } + } + } + } else { + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{cmd:?} input: None"), + label: format!("{cmd:?}"), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Input: None".to_string(), + }, + } + }; + + // Wrap the field in a PreviewLayout for consistency + let label = format!("Command {}", i + 1); + let wrapped_field = match field { + SignablePayloadField::TextV2 { common, text_v2 } => { + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: common.fallback_text, + label, + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: common.label, + }), + subtitle: Some(text_v2), + condensed: None, + expanded: None, + }, + } + } + _ => field, + }; + + detail_fields.push(wrapped_field); + } + + // Deadline field (optional) + if let Some(dl) = &deadline { + detail_fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: dl.clone(), + label: "Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: dl.clone() }, + }); + } + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: if let Some(dl) = &deadline { + format!( + "Uniswap Universal Router Execute: {} commands ({:?}), deadline {}", + mapped.len(), + mapped, + dl + ) + } else { + format!( + "Uniswap Universal Router Execute: {} commands ({:?})", + mapped.len(), + mapped + ) + }, + label: "Universal Router".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: if let Some(dl) = &deadline { + Some(visualsign::SignablePayloadFieldTextV2 { + text: format!("{} commands, deadline {}", mapped.len(), dl), + }) + } else { + Some(visualsign::SignablePayloadFieldTextV2 { + text: format!("{} commands", mapped.len()), + }) + }, + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { + fields: detail_fields + .into_iter() + .map(|f| visualsign::AnnotatedPayloadField { + signable_payload_field: f, + static_annotation: None, + dynamic_annotation: None, + }) + .collect(), + }), + }, + }) + } + + /// Decodes V3_SWAP_EXACT_IN command parameters + /// Uses abi_decode_params for proper ABI decoding of raw calldata bytes + fn decode_v3_swap_exact_in( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + // Define the parameter types for V3SwapExactIn + // (address recipient, uint256 amountIn, uint256 amountOutMinimum, bytes path, bool payerIsUser) + type V3SwapParams = (Address, U256, U256, Bytes, bool); + + // Decode the ABI-encoded parameters + let params = match V3SwapParams::abi_decode_params(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("V3 Swap Exact In: 0x{}", hex::encode(bytes)), + label: "V3 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let (_recipient, amount_in, amount_out_min, path, _payer_is_user) = params; + + // Validate path length (minimum 43 bytes for single hop: token + fee + token) + if path.len() < 43 { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "V3 Swap Exact In: Invalid path".to_string(), + label: "V3 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Path length: {} bytes (expected >=43)", path.len()), + }, + }; + } + + // Extract token addresses and fee from path + let token_in = Address::from_slice(&path[0..20]); + let fee = u32::from_be_bytes([0, path[20], path[21], path[22]]); + let token_out = Address::from_slice(&path[23..43]); + + // Resolve token symbols + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{token_in:?}")); + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{token_out:?}")); + + // Format amounts + let amount_in_u128: u128 = amount_in.to_string().parse().unwrap_or(0); + let amount_out_min_u128: u128 = amount_out_min.to_string().parse().unwrap_or(0); + + let (amount_in_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_in, amount_in_u128)) + .unwrap_or_else(|| (amount_in.to_string(), token_in_symbol.clone())); + + let (amount_out_min_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_out, amount_out_min_u128)) + .unwrap_or_else(|| (amount_out_min.to_string(), token_out_symbol.clone())); + + // Calculate fee percentage + let fee_pct = fee as f64 / 10000.0; + let text = format!( + "Swap {amount_in_str} {token_in_symbol} for >={amount_out_min_str} {token_out_symbol} via V3 ({fee_pct}% fee)" + ); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_in_symbol.clone(), + label: "Input Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_in_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_in_str.clone(), + label: "Input Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_in_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_out_symbol.clone(), + label: "Output Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_out_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!(">={amount_out_min_str}"), + label: "Minimum Output".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!(">={amount_out_min_str}"), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{fee_pct}%"), + label: "Fee Tier".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{fee_pct}%"), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V3 Swap Exact In".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "V3 Swap Exact In".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes PAY_PORTION command parameters + fn decode_pay_portion( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Pay Portion: 0x{}", hex::encode(bytes)), + label: "Pay Portion".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + // Convert bips to percentage (10000 bips = 100%) + let bips_value: u128 = params.bips.to_string().parse().unwrap_or(0); + let bips_pct = (bips_value as f64) / 100.0; + let percentage_str = if bips_pct >= 1.0 { + format!("{bips_pct:.2}%") + } else { + format!("{bips_pct:.4}%") + }; + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_symbol.clone(), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: percentage_str.clone(), + label: "Percentage".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: percentage_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.recipient), + label: "Recipient".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.recipient), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + let text = format!( + "Pay {} of {} to {}", + percentage_str, token_symbol, params.recipient + ); + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Pay Portion".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Pay Portion".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes UNWRAP_WETH command parameters + fn decode_unwrap_weth( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Unwrap WETH: 0x{}", hex::encode(bytes)), + label: "Unwrap WETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + // Get WETH address for this chain and format the amount + // WETH is registered in the token registry via UniswapConfig::register_common_tokens + let amount_min_str = + crate::protocols::uniswap::config::UniswapConfig::weth_address(chain_id) + .and_then(|weth_addr| { + let amount_min_u128: u128 = + params.amountMinimum.to_string().parse().unwrap_or(0); + registry + .and_then(|r| r.format_token_amount(chain_id, weth_addr, amount_min_u128)) + }) + .map(|(amt, _)| amt) + .unwrap_or_else(|| params.amountMinimum.to_string()); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_min_str.clone(), + label: "Minimum Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!(">={amount_min_str} WETH"), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.recipient), + label: "Recipient".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.recipient), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + let text = format!( + "Unwrap >={} WETH to ETH for {}", + amount_min_str, params.recipient + ); + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Unwrap WETH".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Unwrap WETH".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes V3_SWAP_EXACT_OUT command parameters + /// Uses abi_decode_params for proper ABI decoding of raw calldata bytes + fn decode_v3_swap_exact_out( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + // Define the parameter types for V3SwapExactOut + // (address recipient, uint256 amountOut, uint256 amountInMaximum, bytes path, bool payerIsUser) + type V3SwapOutParams = (Address, U256, U256, Bytes, bool); + + // Decode the ABI-encoded parameters + let params = match V3SwapOutParams::abi_decode_params(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("V3 Swap Exact Out: 0x{}", hex::encode(bytes)), + label: "V3 Swap Exact Out".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let (_recipient, amount_out, amount_in_max, path, _payer_is_user) = params; + + // Validate path length (minimum 43 bytes for single hop: token + fee + token) + if path.len() < 43 { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "V3 Swap Exact Out: Invalid path".to_string(), + label: "V3 Swap Exact Out".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Path length: {} bytes (expected >=43)", path.len()), + }, + }; + } + + // Extract token addresses and fee from path + let token_in = Address::from_slice(&path[0..20]); + let fee = u32::from_be_bytes([0, path[20], path[21], path[22]]); + let token_out = Address::from_slice(&path[23..43]); + + // Resolve token symbols + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{token_in:?}")); + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{token_out:?}")); + + // Convert amounts to u128 for formatting + let amount_out_u128: u128 = amount_out.to_string().parse().unwrap_or(0); + let amount_in_max_u128: u128 = amount_in_max.to_string().parse().unwrap_or(0); + + // Format amounts with token decimals + let (amount_out_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_out, amount_out_u128)) + .unwrap_or_else(|| (amount_out.to_string(), token_out_symbol.clone())); + + let (amount_in_max_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_in, amount_in_max_u128)) + .unwrap_or_else(|| (amount_in_max.to_string(), token_in_symbol.clone())); + + // Calculate fee percentage + let fee_pct = fee as f64 / 10000.0; + let text = format!( + "Swap <={amount_in_max_str} {token_in_symbol} for {amount_out_str} {token_out_symbol} via V3 ({fee_pct}% fee)" + ); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_in_symbol.clone(), + label: "Input Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_in_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("<={amount_in_max_str}"), + label: "Maximum Input".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("<={amount_in_max_str}"), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_out_symbol.clone(), + label: "Output Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_out_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_out_str.clone(), + label: "Output Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_out_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{fee_pct}%"), + label: "Fee Tier".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{fee_pct}%"), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V3 Swap Exact Out".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "V3 Swap Exact Out".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes V2_SWAP_EXACT_IN command parameters + /// (address recipient, uint256 amountIn, uint256 amountOutMinimum, address[] path, address payerIsUser) + fn decode_v2_swap_exact_in( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + use alloy_sol_types::sol_data; + + type V2SwapParams = ( + sol_data::Address, + sol_data::Uint<256>, + sol_data::Uint<256>, + sol_data::Array, + sol_data::Address, + ); + + let params = match V2SwapParams::abi_decode_params(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("V2 Swap Exact In: 0x{}", hex::encode(bytes)), + label: "V2 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let (_recipient, amount_in, amount_out_minimum, path_array, _payer) = params; + let path = path_array.as_slice(); + + if path.is_empty() { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "V2 Swap Exact In: Empty path".to_string(), + label: "V2 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Swap path is empty".to_string(), + }, + }; + } + + let token_in = path[0]; + let token_out = path[path.len() - 1]; + + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{token_in:?}")); + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{token_out:?}")); + + let amount_in_u128: u128 = amount_in.to_string().parse().unwrap_or(0); + let amount_out_min_u128: u128 = amount_out_minimum.to_string().parse().unwrap_or(0); + + let (amount_in_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_in, amount_in_u128)) + .unwrap_or_else(|| (amount_in.to_string(), token_in_symbol.clone())); + + let (amount_out_min_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_out, amount_out_min_u128)) + .unwrap_or_else(|| (amount_out_minimum.to_string(), token_out_symbol.clone())); + + let hops = path.len() - 1; + let text = format!( + "Swap {amount_in_str} {token_in_symbol} for >={amount_out_min_str} {token_out_symbol} via V2 ({hops} hops)" + ); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_in_symbol.clone(), + label: "Input Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_in_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_in_str.clone(), + label: "Input Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_in_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_out_symbol.clone(), + label: "Output Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_out_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!(">={amount_out_min_str}"), + label: "Minimum Output".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!(">={amount_out_min_str}"), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: hops.to_string(), + label: "Hops".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: hops.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V2 Swap Exact In".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "V2 Swap Exact In".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes V2_SWAP_EXACT_OUT command parameters + /// (uint256 amountOut, uint256 amountInMaximum, address[] path, address recipient) + fn decode_v2_swap_exact_out( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + use alloy_sol_types::sol_data; + + type V2SwapOutParams = ( + sol_data::Uint<256>, + sol_data::Uint<256>, + sol_data::Array, + sol_data::Address, + ); + + let params = match V2SwapOutParams::abi_decode_params(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("V2 Swap Exact Out: 0x{}", hex::encode(bytes)), + label: "V2 Swap Exact Out".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let (amount_out, amount_in_maximum, path_array, _recipient) = params; + let path = path_array.as_slice(); + + if path.is_empty() { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "V2 Swap Exact Out: Empty path".to_string(), + label: "V2 Swap Exact Out".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Swap path is empty".to_string(), + }, + }; + } + + let token_in = path[0]; + let token_out = path[path.len() - 1]; + + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{token_in:?}")); + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{token_out:?}")); + + let amount_out_u128: u128 = amount_out.to_string().parse().unwrap_or(0); + let amount_in_max_u128: u128 = amount_in_maximum.to_string().parse().unwrap_or(0); + + let (amount_out_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_out, amount_out_u128)) + .unwrap_or_else(|| (amount_out.to_string(), token_out_symbol.clone())); + + let (amount_in_max_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_in, amount_in_max_u128)) + .unwrap_or_else(|| (amount_in_maximum.to_string(), token_in_symbol.clone())); + + let hops = path.len() - 1; + let text = format!( + "Swap <={amount_in_max_str} {token_in_symbol} for {amount_out_str} {token_out_symbol} via V2 ({hops} hops)" + ); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_in_symbol.clone(), + label: "Input Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_in_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("<={amount_in_max_str}"), + label: "Maximum Input".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("<={amount_in_max_str}"), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_out_symbol.clone(), + label: "Output Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_out_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_out_str.clone(), + label: "Output Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_out_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: hops.to_string(), + label: "Hops".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: hops.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V2 Swap Exact Out".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "V2 Swap Exact Out".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes WRAP_ETH command parameters + /// Note: WRAP_ETH wraps msg.value and checks that it's >= amountMin. + /// The amountMin is a minimum check, not the actual amount being wrapped. + fn decode_wrap_eth( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Wrap ETH: 0x{}", hex::encode(bytes)), + label: "Wrap ETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + // Format amount with ETH decimals (18) + // Get WETH address for this chain to use its decimals + let amount_min_str = + crate::protocols::uniswap::config::UniswapConfig::weth_address(chain_id) + .and_then(|weth_addr| { + let amount_min_u128: u128 = params.amountMin.to_string().parse().unwrap_or(0); + registry + .and_then(|r| r.format_token_amount(chain_id, weth_addr, amount_min_u128)) + }) + .map(|(amt, _)| amt) + .unwrap_or_else(|| { + // Fallback: format as ETH with 18 decimals manually + crate::fmt::format_ether(params.amountMin) + }); + + let text = format!("Wrap >={amount_min_str} ETH to WETH"); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Wrap ETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } + + /// Decodes SWEEP command parameters + fn decode_sweep( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Sweep: 0x{}", hex::encode(bytes)), + label: "Sweep".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + // Format amount with token decimals + let amount_min_u128: u128 = params.amountMinimum.to_string().parse().unwrap_or(0); + let (amount_min_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, params.token, amount_min_u128)) + .unwrap_or_else(|| (params.amountMinimum.to_string(), token_symbol.clone())); + + let text = format!( + "Sweep >={amount_min_str} {token_symbol} to {:?}", + params.recipient + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Sweep".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } + + /// Decodes TRANSFER command parameters + fn decode_transfer( + bytes: &[u8], + _chain_id: u64, + _registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Transfer: 0x{}", hex::encode(bytes)), + label: "Transfer".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let text = format!( + "Transfer {} tokens from {:?} to {:?}", + params.amount, params.from, params.to + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Transfer".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } + + /// Decodes PERMIT2_TRANSFER_FROM command by delegating to Permit2Visualizer + fn decode_permit2_transfer_from( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let visualizer = Permit2Visualizer; + visualizer + .visualize_calldata(bytes, chain_id, registry) + .unwrap_or_else(|| SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Permit2 Transfer From: 0x{}", hex::encode(bytes)), + label: "Permit2 Transfer From".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }) + } + + /// Decodes PERMIT2_PERMIT (0x0a) command by delegating to Permit2Visualizer + fn decode_permit2_permit( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let visualizer = Permit2Visualizer; + visualizer + .visualize_calldata(bytes, chain_id, registry) + .unwrap_or_else(|| Self::show_decode_error(bytes, &"Failed to decode parameters")) + } + + /// Helper function to display decoding error with raw hex slots + fn show_decode_error(bytes: &[u8], err: &dyn std::fmt::Display) -> SignablePayloadField { + let hex_data = format!("0x{}", hex::encode(bytes)); + let chunk_size = 32; + let mut fields = vec![]; + + for (i, chunk) in bytes.chunks(chunk_size).enumerate() { + let chunk_hex = format!("0x{}", hex::encode(chunk)); + fields.push(visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: chunk_hex.clone(), + label: format!("Slot {i}"), + }, + text_v2: SignablePayloadFieldTextV2 { text: chunk_hex }, + }, + static_annotation: None, + dynamic_annotation: None, + }); + } + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: hex_data.clone(), + label: "Permit2 Permit".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Permit2 Permit (Failed to Decode)".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { + text: format!("Error: {}, Length: {} bytes", err, bytes.len()), + }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } +} + +/// ContractVisualizer implementation for Uniswap Universal Router +pub struct UniversalRouterContractVisualizer { + inner: UniversalRouterVisualizer, +} + +impl UniversalRouterContractVisualizer { + pub fn new() -> Self { + Self { + inner: UniversalRouterVisualizer {}, + } + } +} + +impl Default for UniversalRouterContractVisualizer { + fn default() -> Self { + Self::new() + } +} + +impl crate::visualizer::ContractVisualizer for UniversalRouterContractVisualizer { + fn contract_type(&self) -> &str { + crate::protocols::uniswap::config::UniswapUniversalRouter::short_type_id() + } + + fn visualize( + &self, + context: &crate::context::VisualizerContext, + ) -> Result>, visualsign::vsptrait::VisualSignError> + { + let (contract_registry, _visualizer_builder) = + crate::registry::ContractRegistry::with_default_protocols(); + + if let Some(field) = self.inner.visualize_tx_commands( + &context.calldata, + context.chain_id, + Some(&contract_registry), + ) { + let annotated = visualsign::AnnotatedPayloadField { + signable_payload_field: field, + static_annotation: None, + dynamic_annotation: None, + }; + + Ok(Some(vec![annotated])) + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Bytes, U256}; + use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, + SignablePayloadFieldTextV2, + }; + + fn encode_execute_call(commands: &[u8], inputs: Vec>, deadline: u64) -> Vec { + let inputs_bytes = inputs.into_iter().map(Bytes::from).collect::>(); + IUniversalRouter::execute_0Call { + commands: Bytes::from(commands.to_vec()), + inputs: inputs_bytes, + deadline: U256::from(deadline), + } + .abi_encode() + } + + #[test] + fn test_visualize_tx_commands_empty_input() { + assert_eq!( + UniversalRouterVisualizer {}.visualize_tx_commands(&[], 1, None), + None + ); + assert_eq!( + UniversalRouterVisualizer {}.visualize_tx_commands(&[0x01, 0x02, 0x03], 1, None), + None + ); + } + + #[test] + fn test_visualize_tx_commands_invalid_deadline() { + // deadline is not convertible to i64 (u64::MAX) + let input = encode_execute_call(&[0x00], vec![vec![0x01, 0x02]], u64::MAX); + assert_eq!( + UniversalRouterVisualizer {}.visualize_tx_commands(&input, 1, None), + None + ); + } + + #[test] + fn test_visualize_tx_commands_single_command_with_deadline() { + let commands = vec![Command::V3SwapExactIn as u8]; + let inputs = vec![vec![0xde, 0xad, 0xbe, 0xef]]; + let deadline = 1_700_000_000u64; // 2023-11-13T12:26:40Z + let input = encode_execute_call(&commands, inputs.clone(), deadline); + + // Build expected field + let dt = chrono::Utc.timestamp_opt(deadline as i64, 0).unwrap(); + let deadline_str = dt.to_string(); + + assert_eq!( + UniversalRouterVisualizer {} + .visualize_tx_commands(&input, 1, None) + .unwrap(), + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: format!( + "Uniswap Universal Router Execute: 1 commands ([V3SwapExactIn]), deadline {deadline_str}" + ), + label: "Universal Router".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: format!("1 commands, deadline {deadline_str}"), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "V3 Swap Exact In: 0xdeadbeef".to_string(), + label: "Command 1".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "V3 Swap Exact In".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: deadline_str.clone(), + label: "Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: deadline_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ], + }), + }, + } + ); + } + + #[test] + fn test_visualize_tx_commands_multiple_commands_no_deadline() { + let commands = vec![ + Command::V3SwapExactIn as u8, + Command::Transfer as u8, + Command::WrapEth as u8, + ]; + let inputs = vec![vec![0x01, 0x02], vec![0x03, 0x04, 0x05], vec![0x06]]; + let deadline = 0u64; + let input = encode_execute_call(&commands, inputs.clone(), deadline); + + assert_eq!( + UniversalRouterVisualizer {} + .visualize_tx_commands(&input, 1, None) + .unwrap(), + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: + "Uniswap Universal Router Execute: 3 commands ([V3SwapExactIn, Transfer, WrapEth])" + .to_string(), + label: "Universal Router".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "3 commands".to_string(), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "V3 Swap Exact In: 0x0102".to_string(), + label: "Command 1".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "V3 Swap Exact In".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Transfer: 0x030405".to_string(), + label: "Command 2".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Transfer".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Wrap ETH: 0x06".to_string(), + label: "Command 3".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Wrap ETH".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ], + }), + }, + } + ); + } + + #[test] + fn test_visualize_tx_commands_command_without_input() { + // Only one command, but no input for it + let commands = vec![Command::Sweep as u8]; + let inputs = vec![]; // No input + let deadline = 1_700_000_000u64; + let input = encode_execute_call(&commands, inputs.clone(), deadline); + + let dt = chrono::Utc.timestamp_opt(deadline as i64, 0).unwrap(); + let deadline_str = dt.to_string(); + + assert_eq!( + UniversalRouterVisualizer {} + .visualize_tx_commands(&input, 1, None) + .unwrap(), + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: format!( + "Uniswap Universal Router Execute: 1 commands ([Sweep]), deadline {deadline_str}", + ), + label: "Universal Router".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: format!("1 commands, deadline {deadline_str}"), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Sweep input: None".to_string(), + label: "Command 1".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Sweep".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Input: None".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: deadline_str.clone(), + label: "Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: deadline_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ], + }), + }, + } + ); + } + + #[test] + fn test_visualize_tx_commands_real_transaction() { + // Real transaction from Etherscan with 4 commands: + // 1. V3SwapExactIn (0x00) + // 2. V3SwapExactIn (0x00) + // 3. PayPortion (0x06) + // 4. UnwrapWeth (0x0c) + let (registry, _) = crate::registry::ContractRegistry::with_default_protocols(); + + // Transaction input data (execute function call) + let input_hex = "3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006918f83f00000000000000000000000000000000000000000000000000000000000000040000060c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c000000000000000000000000000000000000000000000000000000000000003400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000d02ab486cedc00000000000000000000000000000000000000000000000000000000cb274a57755e600000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002be71bdfe1df69284f00ee185cf0d95d0c7680c0d4000bb8c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000340aad21b3b70000000000000000000000000000000000000000000000000000000032e42284d704100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002be71bdfe1df69284f00ee185cf0d95d0c7680c0d4002710c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000fe0b6cdc4c628c0"; + let input = hex::decode(input_hex).unwrap(); + + let result = UniversalRouterVisualizer {}.visualize_tx_commands(&input, 1, Some(®istry)); + assert!(result.is_some(), "Should decode transaction successfully"); + + // Verify the result contains decoded information + let field = result.unwrap(); + if let SignablePayloadField::PreviewLayout { + common, + preview_layout, + } = field + { + // Check that the fallback text mentions 4 commands + assert!( + common.fallback_text.contains("4 commands"), + "Expected '4 commands' in: {}", + common.fallback_text + ); + + // Check that expanded section exists + assert!( + preview_layout.expanded.is_some(), + "Expected expanded section" + ); + + if let Some(list_layout) = preview_layout.expanded { + // Should have 5 fields: 4 commands + 1 deadline + assert_eq!( + list_layout.fields.len(), + 5, + "Expected 5 fields (4 commands + deadline)" + ); + + // Print decoded commands to verify they're human-readable + println!("\n=== Decoded Transaction ==="); + println!("Fallback text: {}", common.fallback_text); + for (i, annotated_field) in list_layout.fields.iter().enumerate() { + match &annotated_field.signable_payload_field { + SignablePayloadField::PreviewLayout { + common: field_common, + preview_layout: field_preview, + } => { + println!("\nCommand {}: {}", i + 1, field_common.label); + if let Some(title) = &field_preview.title { + println!(" Title: {}", title.text); + } + if let Some(subtitle) = &field_preview.subtitle { + println!(" Detail: {}", subtitle.text); + + // Verify that decoded commands contain tokens, amounts, or decode failures + if i < 2 { + // First two are swaps - should mention WETH, address, or decode failure + assert!( + subtitle.text.contains("WETH") + || subtitle.text.contains("0x") + || subtitle.text.contains("Failed to decode"), + "Swap command should mention WETH, token address, or decode failure" + ); + } + } + } + SignablePayloadField::TextV2 { + common: field_common, + text_v2, + } => { + println!("\n{}: {}", field_common.label, text_v2.text); + } + _ => {} + } + } + println!("\n=== End Decoded Transaction ===\n"); + } + } else { + panic!("Expected PreviewLayout, got different field type"); + } + } + + #[test] + fn test_visualize_tx_commands_unrecognized_command() { + // 0xff is not a valid Command, so it should be skipped + let commands = vec![0xff, Command::Transfer as u8]; + let inputs = vec![vec![0x01], vec![0x02]]; + let deadline = 0u64; + let input = encode_execute_call(&commands, inputs.clone(), deadline); + + assert_eq!( + UniversalRouterVisualizer {} + .visualize_tx_commands(&input, 1, None) + .unwrap(), + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Uniswap Universal Router Execute: 1 commands ([Transfer])" + .to_string(), + label: "Universal Router".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "1 commands".to_string(), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: vec![AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Transfer: 0x01".to_string(), + label: "Command 1".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Transfer".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }], + }), + }, + } + ); + } + + #[test] + fn test_decode_permit2_permit_custom_decoder() { + // Unit test for the custom Permit2 Permit decoder + // This tests the byte-level decoding without going through ABI + + // Construct a minimal PermitSingle structure (192 bytes) + let mut permit_single = vec![0u8; 192]; + + // Set token at bytes 12-31 (Slot 0, left-padded address) + let token_bytes = hex::decode("72b658bd674f9c2b4954682f517c17d14476e417").unwrap(); + permit_single[0..12].fill(0); // Clear padding + permit_single[12..32].copy_from_slice(&token_bytes); + + // Set amount at bytes 44-63 (Slot 1, max uint160, left-padded) + let amount_bytes = hex::decode("ffffffffffffffffffffffffffffffffffffffff").unwrap(); + permit_single[32..44].fill(0); // Clear padding for slot 1 + permit_single[44..64].copy_from_slice(&amount_bytes); + + // Set expiration at bytes 90-95 (Slot 2, 1765824281 = 0x69405719) + permit_single[90..96].copy_from_slice(&[0u8, 0, 0x69, 0x40, 0x57, 0x19]); + + // Set spender at bytes 140-159 (Slot 4, left-padded address) + let spender_bytes = hex::decode("3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad").unwrap(); + permit_single[128..140].fill(0); // Clear padding for slot 4 + permit_single[140..160].copy_from_slice(&spender_bytes); + + // Set sigDeadline at bytes 160-191 (Slot 5, 1763234081 = 0x6918d121) + permit_single[160..188].copy_from_slice(&[0u8; 28]); + permit_single[188..192].copy_from_slice(&[0x69, 0x18, 0xd1, 0x21]); + + let result = Permit2Visualizer::decode_custom_permit_params(&permit_single); + assert!( + result.is_ok(), + "Should decode custom permit2 params successfully" + ); + + let params = result.unwrap(); + + // Verify token + let expected_token: Address = "0x72b658bd674f9c2b4954682f517c17d14476e417" + .parse() + .unwrap(); + assert_eq!(params.details.token, expected_token); + + // Verify amount (max uint160) + let expected_amount = alloy_primitives::Uint::<160, 3>::from_str_radix( + "ffffffffffffffffffffffffffffffffffffffff", + 16, + ) + .unwrap(); + assert_eq!(params.details.amount, expected_amount); + + // Verify expiration + let expected_expiration = alloy_primitives::Uint::<48, 1>::from(1765824281u64); + assert_eq!(params.details.expiration, expected_expiration); + + // Verify spender + let expected_spender: Address = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad" + .parse() + .unwrap(); + assert_eq!(params.spender, expected_spender); + + // Verify sigDeadline + let expected_sig_deadline = alloy_primitives::U256::from(1763234081u64); + assert_eq!(params.sigDeadline, expected_sig_deadline); + } + + #[test] + fn test_decode_permit2_permit_field_visualization() { + // Unit test for Permit2 Permit field visualization + let (registry, _) = ContractRegistry::with_default_protocols(); + + // Construct the same PermitSingle structure + let mut permit_single = vec![0u8; 192]; + + let token_bytes = hex::decode("72b658bd674f9c2b4954682f517c17d14476e417").unwrap(); + permit_single[0..12].fill(0); + permit_single[12..32].copy_from_slice(&token_bytes); + + let amount_bytes = hex::decode("ffffffffffffffffffffffffffffffffffffffff").unwrap(); + permit_single[32..44].fill(0); + permit_single[44..64].copy_from_slice(&amount_bytes); + + permit_single[90..96].copy_from_slice(&[0u8, 0, 0x69, 0x40, 0x57, 0x19]); + + let spender_bytes = hex::decode("3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad").unwrap(); + permit_single[128..140].fill(0); + permit_single[140..160].copy_from_slice(&spender_bytes); + + permit_single[160..188].copy_from_slice(&[0u8; 28]); + permit_single[188..192].copy_from_slice(&[0x69, 0x18, 0xd1, 0x21]); + + let field = + UniversalRouterVisualizer::decode_permit2_permit(&permit_single, 1, Some(®istry)); + + // Verify the field has the correct label + match field { + SignablePayloadField::TextV2 { common, .. } => { + // Permit2Visualizer now returns TextV2 for permit + assert_eq!(common.label, "Permit2 Permit"); + } + SignablePayloadField::PreviewLayout { common, .. } => { + // Also accept PreviewLayout for backwards compatibility + assert_eq!(common.label, "Permit2 Permit"); + } + _ => panic!("Expected TextV2 or PreviewLayout, got different field type"), + } + } + + #[test] + fn test_permit2_permit_integration_with_fixture_transaction() { + // Integration test using the actual transaction fixture provided by the user + // The user provided a full EIP-1559 transaction, but we can only test with the calldata + let (registry, _) = ContractRegistry::with_default_protocols(); + + // Extract just the execute() calldata from the transaction data + let input_hex = "3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006918f83f00000000000000000000000000000000000000000000000000000000000000040a08060c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000003a0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000072b658bd674f9c2b4954682f517c17d14476e417000000000000000000000000ffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000006940571900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad000000000000000000000000000000000000000000000000000000006918d12100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000412eb0933411b0970637515316fb50511bea7908d3f85808074ceed3bf881562bc06da5178104470e54fb5be96075169b30799c30f30975317ae14113ffdb84bc81c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000285aaa58c1a1a183d0000000000000000000000000000000000000000000000000009cf200e607a0800000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000072b658bd674f9c2b4954682f517c17d14476e417000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000008419e7eda8577dfc49591a49cad965a0fc6716cf0000000000000000000000000000000000000000000000000009c8d8ef9ef49bc0"; + let input = hex::decode(input_hex).unwrap(); + + let result = UniversalRouterVisualizer {}.visualize_tx_commands(&input, 1, Some(®istry)); + assert!(result.is_some(), "Should decode transaction successfully"); + + let field = result.unwrap(); + + // Verify the main transaction field + match field { + SignablePayloadField::PreviewLayout { common, .. } => { + // Check that it mentions commands + assert!( + common.fallback_text.contains("commands"), + "Expected 'commands' in fallback text: {}", + common.fallback_text + ); + } + _ => panic!("Expected PreviewLayout for main field"), + } + } + + #[test] + fn test_permit2_permit_timestamp_boundaries() { + // Test edge cases for timestamp handling + let (registry, _) = ContractRegistry::with_default_protocols(); + let mut permit_single = vec![0u8; 192]; + + let token_bytes = hex::decode("72b658bd674f9c2b4954682f517c17d14476e417").unwrap(); + permit_single[0..12].fill(0); + permit_single[12..32].copy_from_slice(&token_bytes); + + let amount_bytes = hex::decode("ffffffffffffffffffffffffffffffffffffffff").unwrap(); + permit_single[32..44].fill(0); + permit_single[44..64].copy_from_slice(&amount_bytes); + + let spender_bytes = hex::decode("3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad").unwrap(); + permit_single[128..140].fill(0); + permit_single[140..160].copy_from_slice(&spender_bytes); + + // Test with a future timestamp (year 2030) + // 1893456000 = Friday, January 1, 2030 2:40:00 AM + permit_single[90..96].copy_from_slice(&[0u8, 0, 0x70, 0x94, 0x4b, 0x80]); + permit_single[160..192].copy_from_slice(&[0u8; 32]); + + let field = + UniversalRouterVisualizer::decode_permit2_permit(&permit_single, 1, Some(®istry)); + + if let SignablePayloadField::PreviewLayout { preview_layout, .. } = field { + if let Some(expanded) = &preview_layout.expanded { + for f in &expanded.fields { + if let SignablePayloadField::PreviewLayout { + common, + preview_layout: inner_preview, + } = &f.signable_payload_field + { + if common.label.contains("Expires") { + if let Some(subtitle) = &inner_preview.subtitle { + // Should show a valid date in 2030 + assert!(subtitle.text.contains("2030")); + } + } + } + } + } + } + } + + #[test] + fn test_permit2_permit_invalid_input_too_short() { + // Test that short input is properly rejected + let short_input = vec![0u8; 100]; // Too short + let result = Permit2Visualizer::decode_custom_permit_params(&short_input); + assert!( + result.is_err(), + "Should reject input shorter than 192 bytes" + ); + } + + #[test] + fn test_permit2_permit_empty_input() { + // Test that empty input is properly rejected + let empty_input = vec![]; + let result = Permit2Visualizer::decode_custom_permit_params(&empty_input); + assert!(result.is_err(), "Should reject empty input"); + } + + #[test] + fn test_decode_wrap_eth_params_order() { + // WRAP_ETH params: (address recipient, uint256 amountMin) + // This test verifies we decode (recipient, amountMin) not just (amountMin) + let recipient: Address = "0xd27f4bbd67bd4ad1674c9c2c5a75ca8c3e389f3b" + .parse() + .unwrap(); + let amount_min = U256::from(3_200_000_000_000_000u64); // 0.0032 ETH in wei + + // ABI encode: (address, uint256) + let encoded = (recipient, amount_min).abi_encode(); + + let field = UniversalRouterVisualizer::decode_wrap_eth(&encoded, 1, None); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + // Should show ~0.0032 ETH, not a huge number from misinterpreting address as amount + assert!( + text_v2.text.contains("0.0032"), + "Expected 0.0032 ETH, got: {}", + text_v2.text + ); + assert!( + !text_v2.text.contains("1201726854"), // This was the buggy value + "Should not contain buggy large number" + ); + } + _ => panic!("Expected TextV2 field"), + } + } + + #[test] + fn test_decode_wrap_eth_formats_amount_with_decimals() { + // Verify amount is formatted with 18 decimals (ETH) + let recipient: Address = "0x0000000000000000000000000000000000000001" + .parse() + .unwrap(); + let amount_min = U256::from(1_000_000_000_000_000_000u64); // 1 ETH + + let encoded = (recipient, amount_min).abi_encode(); + let field = UniversalRouterVisualizer::decode_wrap_eth(&encoded, 1, None); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + assert!( + text_v2.text.contains("1.0") || text_v2.text.contains("1 ETH"), + "Expected ~1 ETH formatted, got: {}", + text_v2.text + ); + } + _ => panic!("Expected TextV2 field"), + } + } + + #[test] + fn test_decode_sweep_params_order() { + // SWEEP params: (address token, address recipient, uint256 amountMin) + // This test verifies correct field order - NOT (token, amountMin, recipient) + let token: Address = "0x255494b830bd4fe7220b3ec4842cba75600b6c80" + .parse() + .unwrap(); + let recipient: Address = "0xd27f4bbd67bd4ad1674c9c2c5a75ca8c3e389f3b" + .parse() + .unwrap(); + let amount_min = U256::from(2264700707120u64); // ~2264 tokens (if 9 decimals) + + // ABI encode: (address, address, uint256) + let encoded = (token, recipient, amount_min).abi_encode(); + + let field = UniversalRouterVisualizer::decode_sweep(&encoded, 1, None); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + // Should contain the correct amount, not a huge number from wrong field order + assert!( + text_v2.text.contains("2264700707120"), + "Expected amount 2264700707120, got: {}", + text_v2.text + ); + // Should contain correct recipient + assert!( + text_v2.text.to_lowercase().contains("d27f4bbd"), + "Expected recipient address, got: {}", + text_v2.text + ); + // Should NOT contain astronomically large numbers from wrong decoding + assert!( + !text_v2.text.contains("120172685438592526"), + "Should not contain buggy large number from wrong field order" + ); + } + _ => panic!("Expected TextV2 field"), + } + } + + #[test] + fn test_decode_sweep_with_known_token() { + // Test SWEEP with WETH (which is in registry) to verify amount formatting + let (registry, _) = crate::registry::ContractRegistry::with_default_protocols(); + + // WETH on mainnet + let token: Address = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + .parse() + .unwrap(); + let recipient: Address = "0x0000000000000000000000000000000000000001" + .parse() + .unwrap(); + let amount_min = U256::from(500_000_000_000_000_000u64); // 0.5 WETH + + let encoded = (token, recipient, amount_min).abi_encode(); + + let field = UniversalRouterVisualizer::decode_sweep(&encoded, 1, Some(®istry)); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + // With registry, should format as 0.5 WETH + assert!( + text_v2.text.contains("0.5") || text_v2.text.contains("WETH"), + "Expected formatted WETH amount, got: {}", + text_v2.text + ); + } + _ => panic!("Expected TextV2 field"), + } + } + + #[test] + fn test_decode_wrap_eth_invalid_input() { + // Test with invalid/short input + let short_input = vec![0u8; 10]; + let field = UniversalRouterVisualizer::decode_wrap_eth(&short_input, 1, None); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + assert!( + text_v2.text.contains("Failed to decode"), + "Expected decode failure message" + ); + } + _ => panic!("Expected TextV2 field"), + } + } + + #[test] + fn test_decode_sweep_invalid_input() { + // Test with invalid/short input + let short_input = vec![0u8; 10]; + let field = UniversalRouterVisualizer::decode_sweep(&short_input, 1, None); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + assert!( + text_v2.text.contains("Failed to decode"), + "Expected decode failure message" + ); + } + _ => panic!("Expected TextV2 field"), + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/v4_pool.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/v4_pool.rs new file mode 100644 index 00000000..096fbe18 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/v4_pool.rs @@ -0,0 +1,94 @@ +//! Uniswap V4 Pool Manager Visualizer +//! +//! Visualizes interactions with the Uniswap V4 PoolManager contract. +//! +//! Reference: +//! Deployments: + +#![allow(unused_imports)] + +use alloy_sol_types::{SolCall, sol}; +use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; + +// Simplified V4 PoolManager interface +sol! { + interface IPoolManager { + function initialize(PoolKey memory key, uint160 sqrtPriceX96, bytes calldata hookData) external returns (int24 tick); + function modifyLiquidity(PoolKey memory key, ModifyLiquidityParams memory params, bytes calldata hookData) external returns (BalanceDelta callerDelta, BalanceDelta feesAccrued); + function swap(PoolKey memory key, SwapParams memory params, bytes calldata hookData) external returns (BalanceDelta); + function donate(PoolKey memory key, uint256 amount0, uint256 amount1, bytes calldata hookData) external returns (BalanceDelta); + } + + struct PoolKey { + address currency0; + address currency1; + uint24 fee; + int24 tickSpacing; + address hooks; + } + + struct ModifyLiquidityParams { + int24 tickLower; + int24 tickUpper; + int256 liquidityDelta; + bytes32 salt; + } + + struct SwapParams { + bool zeroForOne; + int256 amountSpecified; + uint160 sqrtPriceLimitX96; + } + + struct BalanceDelta { + int128 amount0; + int128 amount1; + } +} + +/// Visualizer for Uniswap V4 PoolManager contract calls +pub struct V4PoolManagerVisualizer; + +impl V4PoolManagerVisualizer { + /// Attempts to decode and visualize V4 PoolManager function calls + /// + /// # Arguments + /// * `input` - The calldata bytes + /// + /// # Returns + /// * `Some(field)` if a recognized V4 function is found + /// * `None` if the input doesn't match any V4 function + pub fn visualize_tx_commands(&self, input: &[u8]) -> Option { + if input.len() < 4 { + return None; + } + + // TODO: Implement V4 PoolManager function decoding + // - initialize(PoolKey,uint160,bytes) + // - modifyLiquidity(PoolKey,ModifyLiquidityParams,bytes) + // - swap(PoolKey,SwapParams,bytes) + // - donate(PoolKey,uint256,uint256,bytes) + // + // For now, return None to use fallback visualizer + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_visualize_empty_input() { + let visualizer = V4PoolManagerVisualizer; + assert_eq!(visualizer.visualize_tx_commands(&[]), None); + } + + #[test] + fn test_visualize_too_short() { + let visualizer = V4PoolManagerVisualizer; + assert_eq!(visualizer.visualize_tx_commands(&[0x01, 0x02]), None); + } + + // TODO: Add tests for V4 PoolManager functions once implemented +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/mod.rs new file mode 100644 index 00000000..86501140 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/mod.rs @@ -0,0 +1,99 @@ +//! Uniswap protocol implementation +//! +//! This module contains contract visualizers, configuration, and registration +//! logic for the Uniswap decentralized exchange protocol. + +pub mod config; +pub mod contracts; + +use crate::registry::ContractRegistry; +use crate::visualizer::EthereumVisualizerRegistryBuilder; + +pub use config::UniswapConfig; +pub use contracts::{ + Permit2ContractVisualizer, Permit2Visualizer, UniversalRouterContractVisualizer, + UniversalRouterVisualizer, V4PoolManagerVisualizer, +}; + +/// Registers all Uniswap protocol contracts and visualizers +/// +/// This function: +/// 1. Registers contract addresses in the ContractRegistry for address-to-type lookup +/// 2. Registers visualizers in the EthereumVisualizerRegistryBuilder for transaction visualization +/// +/// # Arguments +/// * `contract_reg` - The contract registry to register addresses +/// * `visualizer_reg` - The visualizer registry to register visualizers +pub fn register( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + use config::{Permit2Contract, UniswapUniversalRouter}; + + // Register Universal Router on each supported chain with correct address + for &chain_id in UniswapConfig::universal_router_chains() { + let addr = UniswapConfig::universal_router_address(chain_id) + .expect("universal_router_chains should only contain valid chains"); + contract_reg.register_contract_typed::(chain_id, vec![addr]); + } + + // Register Permit2 (same address on all chains) + let permit2_address = UniswapConfig::permit2_address(); + for &chain_id in UniswapConfig::universal_router_chains() { + contract_reg.register_contract_typed::(chain_id, vec![permit2_address]); + } + + // Register common tokens (WETH, USDC, USDT, DAI, etc.) + UniswapConfig::register_common_tokens(contract_reg); + + // Register visualizers + visualizer_reg.register(Box::new(UniversalRouterContractVisualizer::new())); + visualizer_reg.register(Box::new(Permit2ContractVisualizer::new())); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::uniswap::config::{UniswapUniversalRouter, networks}; + use crate::registry::ContractType; + + #[test] + fn test_register_uniswap_contracts() { + let mut contract_reg = ContractRegistry::new(); + let mut visualizer_reg = EthereumVisualizerRegistryBuilder::new(); + + register(&mut contract_reg, &mut visualizer_reg); + + // Verify Universal Router is registered on all supported chains with correct addresses + for &chain_id in UniswapConfig::universal_router_chains() { + let expected_addr = UniswapConfig::universal_router_address(chain_id) + .expect("Chain should have valid address"); + let contract_type = contract_reg + .get_contract_type(chain_id, expected_addr) + .unwrap_or_else(|| { + panic!("Universal Router should be registered on chain {chain_id}") + }); + assert_eq!(contract_type, UniswapUniversalRouter::short_type_id()); + } + } + + #[test] + fn test_different_addresses_per_chain() { + // Verify that some chains have different addresses + let eth_addr = + UniswapConfig::universal_router_address(networks::ethereum::MAINNET).unwrap(); + let arb_addr = + UniswapConfig::universal_router_address(networks::arbitrum::MAINNET).unwrap(); + let opt_addr = + UniswapConfig::universal_router_address(networks::optimism::MAINNET).unwrap(); + + // Ethereum and Base share the same address + let base_addr = UniswapConfig::universal_router_address(networks::base::MAINNET).unwrap(); + assert_eq!(eth_addr, base_addr); + + // But Arbitrum and Optimism have different addresses + assert_ne!(eth_addr, arb_addr); + assert_ne!(eth_addr, opt_addr); + assert_ne!(arb_addr, opt_addr); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/registry.rs b/src/chain_parsers/visualsign-ethereum/src/registry.rs index e69de29b..7be2f57d 100644 --- a/src/chain_parsers/visualsign-ethereum/src/registry.rs +++ b/src/chain_parsers/visualsign-ethereum/src/registry.rs @@ -0,0 +1,689 @@ +use crate::token_metadata::{ChainMetadata, TokenMetadata, parse_network_id}; +use alloy_primitives::{Address, utils::format_units}; +use std::collections::HashMap; + +/// Type alias for chain ID to avoid depending on external chain types +pub type ChainId = u64; + +/// Trait for contract type markers +/// +/// Implement this trait on unit structs to create compile-time unique contract type identifiers. +/// The type name is automatically used as the contract type string. +/// +/// # Example +/// ```ignore +/// pub struct UniswapUniversalRouter; +/// impl ContractType for UniswapUniversalRouter {} +/// +/// // The type_id is automatically "UniswapUniversalRouter" +/// ``` +/// +/// # Compile-time Uniqueness +/// Because Rust doesn't allow duplicate type names in the same scope, this provides +/// compile-time guarantees that contract types are unique. If someone copies a protocol +/// directory and forgets to rename the type, the code won't compile. +pub trait ContractType: 'static { + /// Returns the unique identifier for this contract type + /// + /// By default, uses the Rust type name. Can be overridden for custom strings. + fn type_id() -> &'static str { + std::any::type_name::() + } + + /// Returns a shortened type ID without module path + /// + /// Strips the module path to get just the struct name. + /// Example: "visualsign_ethereum::protocols::uniswap::UniswapUniversalRouter" -> "UniswapUniversalRouter" + fn short_type_id() -> &'static str { + let full_name = Self::type_id(); + full_name.rsplit("::").next().unwrap_or(full_name) + } +} + +/// Registry for managing Ethereum contract types and token metadata +/// +/// Maintains two types of mappings: +/// 1. Contract type registry: Maps (chain_id, address) to contract type (e.g., "UniswapV3Router") +/// 2. Token metadata registry: Maps (chain_id, token_address) to token information +/// +/// # TODO +/// Extract a ChainRegistry trait that all chains can implement for handling token metadata, +/// contract types, and other chain-specific information. This will allow Solana, Tron, Sui, +/// and other chains to use the same interface pattern. +#[derive(Clone)] +pub struct ContractRegistry { + /// Maps (chain_id, address) to contract type + address_to_type: HashMap<(ChainId, Address), String>, + /// Maps (chain_id, contract_type) to list of addresses + type_to_addresses: HashMap<(ChainId, String), Vec
>, + /// Maps (chain_id, token_address) to token metadata + token_metadata: HashMap<(ChainId, Address), TokenMetadata>, +} + +impl ContractRegistry { + /// Creates a new empty registry + pub fn new() -> Self { + Self { + address_to_type: HashMap::new(), + type_to_addresses: HashMap::new(), + token_metadata: HashMap::new(), + } + } + + /// Creates a new registry with default protocols registered + /// + /// This is the recommended way to create a ContractRegistry with + /// built-in support for known protocols like Uniswap, Aave, etc. + /// + /// Returns both the ContractRegistry and EthereumVisualizerRegistryBuilder since + /// protocol registration populates both registries. Discarding either would be wasteful. + pub fn with_default_protocols() -> (Self, crate::visualizer::EthereumVisualizerRegistryBuilder) + { + let mut registry = Self::new(); + let mut visualizer_builder = crate::visualizer::EthereumVisualizerRegistryBuilder::new(); + crate::protocols::register_all(&mut registry, &mut visualizer_builder); + (registry, visualizer_builder) + } + + /// Registers a contract type on a specific chain (type-safe version) + /// + /// This is the preferred method for registering contracts. It uses the ContractType + /// trait to ensure compile-time uniqueness of contract type identifiers. + /// + /// # Arguments + /// * `chain_id` - The chain ID (1 for Ethereum, 137 for Polygon, etc.) + /// * `addresses` - List of contract addresses on this chain + /// + /// # Example + /// ```ignore + /// pub struct UniswapUniversalRouter; + /// impl ContractType for UniswapUniversalRouter {} + /// + /// registry.register_contract_typed::(1, vec![address]); + /// ``` + pub fn register_contract_typed( + &mut self, + chain_id: ChainId, + addresses: Vec
, + ) { + let contract_type_str = T::short_type_id().to_string(); + + for address in &addresses { + self.address_to_type + .insert((chain_id, *address), contract_type_str.clone()); + } + + self.type_to_addresses + .insert((chain_id, contract_type_str), addresses); + } + + /// Registers a contract type on a specific chain (string version) + /// + /// This method is kept for backward compatibility and dynamic registration. + /// Prefer `register_contract_typed` for compile-time safety. + /// + /// # Arguments + /// * `chain_id` - The chain ID (1 for Ethereum, 137 for Polygon, etc.) + /// * `contract_type` - The contract type identifier (e.g., "UniswapV3Router", "Aave") + /// * `addresses` - List of contract addresses on this chain + pub fn register_contract( + &mut self, + chain_id: ChainId, + contract_type: impl Into, + addresses: Vec
, + ) { + let contract_type_str = contract_type.into(); + + for address in &addresses { + self.address_to_type + .insert((chain_id, *address), contract_type_str.clone()); + } + + self.type_to_addresses + .insert((chain_id, contract_type_str), addresses); + } + + /// Registers token metadata for a specific token + /// + /// # Arguments + /// * `chain_id` - The chain ID + /// * `metadata` - The TokenMetadata containing all token information + /// + /// # Errors + /// Returns an error if the contract address cannot be parsed as a valid Ethereum address + pub fn register_token( + &mut self, + chain_id: ChainId, + metadata: TokenMetadata, + ) -> Result<(), String> { + let address: Address = metadata + .contract_address + .parse() + .map_err(|_| format!("Invalid contract address: {}", metadata.contract_address))?; + self.token_metadata.insert((chain_id, address), metadata); + Ok(()) + } + + /// Gets the contract type for a specific address on a chain + /// + /// # Arguments + /// * `chain_id` - The chain ID + /// * `address` - The contract address + /// + /// # Returns + /// `Some(contract_type)` if the address is registered, `None` otherwise + pub fn get_contract_type(&self, chain_id: ChainId, address: Address) -> Option { + self.address_to_type.get(&(chain_id, address)).cloned() + } + + /// Gets the symbol for a specific token on a chain + /// + /// # Arguments + /// * `chain_id` - The chain ID + /// * `token` - The token's contract address + /// + /// # Returns + /// `Some(symbol)` if the token is registered, `None` otherwise + pub fn get_token_symbol(&self, chain_id: ChainId, token: Address) -> Option { + self.token_metadata + .get(&(chain_id, token)) + .map(|m| m.symbol.clone()) + } + + /// Formats a raw token amount with the proper number of decimal places + /// + /// This method: + /// 1. Looks up the token metadata for the given address + /// 2. Uses Alloy's format_units to convert raw amount to decimal representation + /// 3. Returns (formatted_amount, symbol) tuple + /// + /// # Arguments + /// * `chain_id` - The chain ID + /// * `token` - The token's contract address + /// * `raw_amount` - The raw amount in the token's smallest units + /// + /// # Returns + /// `Some((formatted_amount, symbol))` if token is registered and format succeeds + /// `None` if token is not registered + /// + /// # Examples + /// ```ignore + /// // USDC with 6 decimals + /// registry.format_token_amount(1, usdc_addr, 1_500_000); + /// // Returns: Some(("1.5", "USDC")) + /// + /// // WETH with 18 decimals + /// registry.format_token_amount(1, weth_addr, 1_000_000_000_000_000_000); + /// // Returns: Some(("1", "WETH")) + /// ``` + pub fn format_token_amount( + &self, + chain_id: ChainId, + token: Address, + raw_amount: u128, + ) -> Option<(String, String)> { + let metadata = self.token_metadata.get(&(chain_id, token))?; + + // Use Alloy's format_units to format the amount + let formatted = format_units(raw_amount, metadata.decimals).ok()?; + + Some((formatted, metadata.symbol.clone())) + } + + /// Loads token metadata from wallet ChainMetadata structure + /// + /// This method parses network_id to determine the chain ID and registers + /// all tokens from the metadata's assets collection. + /// + /// # Arguments + /// * `chain_metadata` - Reference to ChainMetadata containing token information + /// + /// # Returns + /// `Ok(())` on success, `Err(String)` if network_id is unknown or any token registration fails + pub fn load_chain_metadata(&mut self, chain_metadata: &ChainMetadata) -> Result<(), String> { + let chain_id = parse_network_id(&chain_metadata.network_id).map_err(|e| e.to_string())?; + + let errors: Vec = chain_metadata + .assets + .values() + .filter_map(|token_metadata| { + self.register_token(chain_id, token_metadata.clone()).err() + }) + .collect(); + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } +} + +impl Default for ContractRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::token_metadata::ErcStandard; + + fn usdc_address() -> Address { + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + .parse() + .unwrap() + } + + fn weth_address() -> Address { + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + .parse() + .unwrap() + } + + fn dai_address() -> Address { + "0x6b175474e89094c44da98b954eedeac495271d0f" + .parse() + .unwrap() + } + + fn create_token_metadata( + symbol: &str, + name: &str, + address: &str, + decimals: u8, + ) -> TokenMetadata { + TokenMetadata { + symbol: symbol.to_string(), + name: name.to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: address.to_string(), + decimals, + } + } + + #[test] + fn test_registry_new() { + let registry = ContractRegistry::new(); + assert_eq!(registry.address_to_type.len(), 0); + assert_eq!(registry.type_to_addresses.len(), 0); + assert_eq!(registry.token_metadata.len(), 0); + } + + #[test] + fn test_register_contract() { + let mut registry = ContractRegistry::new(); + let addresses = vec![ + "0x68b3465833fb72B5A828cCEEaAF60b9Ab78ad723" + .parse() + .unwrap(), + "0xE592427A0AEce92De3Edee1F18E0157C05861564" + .parse() + .unwrap(), + ]; + + registry.register_contract(1, "UniswapV3Router", addresses.clone()); + + assert_eq!(registry.address_to_type.len(), 2); + assert_eq!(registry.type_to_addresses.len(), 1); + + for addr in &addresses { + assert_eq!( + registry.get_contract_type(1, *addr), + Some("UniswapV3Router".to_string()) + ); + } + } + + #[test] + fn test_register_token() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc).unwrap(); + + assert_eq!(registry.token_metadata.len(), 1); + assert_eq!( + registry.get_token_symbol(1, usdc_address()), + Some("USDC".to_string()) + ); + } + + #[test] + fn test_format_token_amount_6_decimals() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc).unwrap(); + + // Test: 1.5 USDC = 1_500_000 in raw units + let result = registry.format_token_amount(1, usdc_address(), 1_500_000); + assert_eq!(result, Some(("1.500000".to_string(), "USDC".to_string()))); + } + + #[test] + fn test_format_token_amount_18_decimals() { + let mut registry = ContractRegistry::new(); + let weth = create_token_metadata( + "WETH", + "Wrapped Ether", + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + 18, + ); + registry.register_token(1, weth).unwrap(); + + // Test: 1 WETH = 1_000_000_000_000_000_000 in raw units + let result = registry.format_token_amount(1, weth_address(), 1_000_000_000_000_000_000); + assert_eq!( + result, + Some(("1.000000000000000000".to_string(), "WETH".to_string())) + ); + } + + #[test] + fn test_format_token_amount_with_trailing_zeros() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc).unwrap(); + + // Test: 1 USDC = 1_000_000 in raw units + let result = registry.format_token_amount(1, usdc_address(), 1_000_000); + assert_eq!(result, Some(("1.000000".to_string(), "USDC".to_string()))); + } + + #[test] + fn test_format_token_amount_multiple_decimals() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc).unwrap(); + + // Test: 12.345678 USDC (should trim to 6 decimals: 12.345678) + let result = registry.format_token_amount(1, usdc_address(), 12_345_678); + assert_eq!(result, Some(("12.345678".to_string(), "USDC".to_string()))); + } + + #[test] + fn test_format_token_amount_unknown_token() { + let registry = ContractRegistry::new(); + + // Test: Unknown token returns None + let result = registry.format_token_amount(1, usdc_address(), 1_000_000); + assert_eq!(result, None); + } + + #[test] + fn test_format_token_amount_zero_amount() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc).unwrap(); + + // Test: 0 USDC + let result = registry.format_token_amount(1, usdc_address(), 0); + assert_eq!(result, Some(("0.000000".to_string(), "USDC".to_string()))); + } + + #[test] + fn test_load_chain_metadata() { + let mut registry = ContractRegistry::new(); + + let mut assets = HashMap::new(); + assets.insert( + "USDC".to_string(), + create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ), + ); + assets.insert( + "DAI".to_string(), + create_token_metadata( + "DAI", + "Dai Stablecoin", + "0x6b175474e89094c44da98b954eedeac495271d0f", + 18, + ), + ); + + let metadata = ChainMetadata { + network_id: "ETHEREUM_MAINNET".to_string(), + assets, + }; + + registry.load_chain_metadata(&metadata).unwrap(); + + assert_eq!(registry.token_metadata.len(), 2); + assert_eq!( + registry.get_token_symbol(1, usdc_address()), + Some("USDC".to_string()) + ); + assert_eq!( + registry.get_token_symbol(1, dai_address()), + Some("DAI".to_string()) + ); + } + + #[test] + fn test_get_contract_type_not_found() { + let registry = ContractRegistry::new(); + + let result = registry.get_contract_type(1, usdc_address()); + assert_eq!(result, None); + } + + #[test] + fn test_get_token_symbol_not_found() { + let registry = ContractRegistry::new(); + + let result = registry.get_token_symbol(1, usdc_address()); + assert_eq!(result, None); + } + + #[test] + fn test_register_multiple_tokens() { + let mut registry = ContractRegistry::new(); + + registry + .register_token( + 1, + create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ), + ) + .unwrap(); + registry + .register_token( + 1, + create_token_metadata( + "WETH", + "Wrapped Ether", + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + 18, + ), + ) + .unwrap(); + registry + .register_token( + 1, + create_token_metadata( + "DAI", + "Dai Stablecoin", + "0x6b175474e89094c44da98b954eedeac495271d0f", + 18, + ), + ) + .unwrap(); + + assert_eq!(registry.token_metadata.len(), 3); + + // Verify each token was registered correctly + let usdc_result = registry.format_token_amount(1, usdc_address(), 1_500_000); + assert_eq!( + usdc_result, + Some(("1.500000".to_string(), "USDC".to_string())) + ); + + let weth_result = + registry.format_token_amount(1, weth_address(), 2_000_000_000_000_000_000); + assert_eq!( + weth_result, + Some(("2.000000000000000000".to_string(), "WETH".to_string())) + ); + + let dai_result = registry.format_token_amount(1, dai_address(), 3_500_000_000_000_000_000); + assert_eq!( + dai_result, + Some(("3.500000000000000000".to_string(), "DAI".to_string())) + ); + } + + #[test] + fn test_same_token_different_chains() { + let mut registry = ContractRegistry::new(); + + // Register USDC on Ethereum (chain 1) + registry + .register_token( + 1, + create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ), + ) + .unwrap(); + + // Register USDC on Polygon (chain 137) with different address + registry + .register_token( + 137, + create_token_metadata( + "USDC", + "USD Coin", + "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + 6, + ), + ) + .unwrap(); + + let eth_result = registry.format_token_amount(1, usdc_address(), 1_000_000); + assert_eq!( + eth_result, + Some(("1.000000".to_string(), "USDC".to_string())) + ); + + let poly_usdc = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174" + .parse() + .unwrap(); + let poly_result = registry.format_token_amount(137, poly_usdc, 1_000_000); + assert_eq!( + poly_result, + Some(("1.000000".to_string(), "USDC".to_string())) + ); + } + + #[test] + fn test_load_chain_metadata_with_invalid_addresses() { + let mut registry = ContractRegistry::new(); + + let mut assets = HashMap::new(); + // Valid token + assets.insert( + "USDC".to_string(), + create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ), + ); + // Invalid address - too short + assets.insert( + "BAD1".to_string(), + TokenMetadata { + symbol: "BAD1".to_string(), + name: "Bad Token 1".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xinvalid".to_string(), + decimals: 18, + }, + ); + // Invalid address - not hex + assets.insert( + "BAD2".to_string(), + TokenMetadata { + symbol: "BAD2".to_string(), + name: "Bad Token 2".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "not_an_address".to_string(), + decimals: 18, + }, + ); + + let metadata = ChainMetadata { + network_id: "ETHEREUM_MAINNET".to_string(), + assets, + }; + + let result = registry.load_chain_metadata(&metadata); + assert!(result.is_err()); + + let err = result.unwrap_err(); + // Verify both invalid addresses are mentioned in the error + assert!(err.contains("0xinvalid"), "Error should mention 0xinvalid"); + assert!( + err.contains("not_an_address"), + "Error should mention not_an_address" + ); + + // Valid token should still be registered + assert_eq!(registry.token_metadata.len(), 1); + assert_eq!( + registry.get_token_symbol(1, usdc_address()), + Some("USDC".to_string()) + ); + } + + #[test] + fn test_load_chain_metadata_unknown_network() { + let mut registry = ContractRegistry::new(); + + let metadata = ChainMetadata { + network_id: "UNKNOWN_NETWORK".to_string(), + assets: HashMap::new(), + }; + + let result = registry.load_chain_metadata(&metadata); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("UNKNOWN_NETWORK")); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/token_metadata.rs b/src/chain_parsers/visualsign-ethereum/src/token_metadata.rs new file mode 100644 index 00000000..baabd2f7 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/token_metadata.rs @@ -0,0 +1,285 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; + +/// Standard for ERC token types +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ErcStandard { + /// ERC20 fungible token standard + #[serde(rename = "ERC20")] + Erc20, + /// ERC721 non-fungible token standard + #[serde(rename = "ERC721")] + Erc721, + /// ERC1155 multi-token standard + #[serde(rename = "ERC1155")] + Erc1155, +} + +impl std::fmt::Display for ErcStandard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ErcStandard::Erc20 => write!(f, "ERC20"), + ErcStandard::Erc721 => write!(f, "ERC721"), + ErcStandard::Erc1155 => write!(f, "ERC1155"), + } + } +} + +/// Information about a token asset +/// +/// This represents a single token in the blockchain, with its metadata. +/// Used in both the Anchorage format (gRPC ChainMetadata) and internally +/// by the ContractRegistry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TokenMetadata { + /// Token symbol (e.g., "USDC", "WETH") + pub symbol: String, + /// Token name (e.g., "USD Coin") + pub name: String, + /// ERC standard this token implements + pub erc_standard: ErcStandard, + /// Contract address of the token + pub contract_address: String, + /// Number of decimal places for token amounts + pub decimals: u8, +} + +/// Chain metadata representing network and token information +/// +/// This is the canonical format for wallets to send token metadata. +/// Network ID is sent as a string (e.g., "ETHEREUM_MAINNET") and is converted +/// to a numeric chain ID by parse_network_id(). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChainMetadata { + /// Network identifier as string (e.g., "ETHEREUM_MAINNET") + pub network_id: String, + /// Map of token symbol to token metadata + pub assets: HashMap, +} + +/// Error type for token metadata operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TokenMetadataError { + /// Unknown network ID + UnknownNetworkId(String), + /// Hash computation error + HashError(String), +} + +impl std::fmt::Display for TokenMetadataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenMetadataError::UnknownNetworkId(id) => write!(f, "Unknown network ID: {id}"), + TokenMetadataError::HashError(msg) => write!(f, "Hash error: {msg}"), + } + } +} + +impl std::error::Error for TokenMetadataError {} + +/// Parses a network ID string to its corresponding chain ID +/// +/// # Arguments +/// * `network_id` - The network identifier string (e.g., "ETHEREUM_MAINNET") +/// +/// # Returns +/// `Ok(chain_id)` for known networks, `Err(TokenMetadataError)` otherwise +/// +/// # Supported Networks +/// - "ETHEREUM_MAINNET" -> 1 +/// - "POLYGON_MAINNET" -> 137 +/// - "ARBITRUM_MAINNET" -> 42161 +/// - "OPTIMISM_MAINNET" -> 10 +/// - "BASE_MAINNET" -> 8453 +/// +/// # Examples +/// ``` +/// use visualsign_ethereum::token_metadata::parse_network_id; +/// +/// assert_eq!(parse_network_id("ETHEREUM_MAINNET"), Ok(1)); +/// assert_eq!(parse_network_id("POLYGON_MAINNET"), Ok(137)); +/// ``` +pub fn parse_network_id(network_id: &str) -> Result { + match network_id { + "ETHEREUM_MAINNET" => Ok(1), + "POLYGON_MAINNET" => Ok(137), + "ARBITRUM_MAINNET" => Ok(42161), + "OPTIMISM_MAINNET" => Ok(10), + "BASE_MAINNET" => Ok(8453), + _ => Err(TokenMetadataError::UnknownNetworkId(network_id.to_string())), + } +} + +/// Computes a deterministic SHA256 hash of protobuf bytes +/// +/// This function takes the raw protobuf bytes directly (as received from gRPC) +/// and computes a SHA256 hash. The same bytes will always produce the same hash, +/// making this deterministic without needing to reserialize. +/// +/// # Arguments +/// * `protobuf_bytes` - The raw protobuf bytes representing ChainMetadata +/// +/// # Returns +/// A hex-encoded SHA256 hash string +/// +/// # Examples +/// ``` +/// use visualsign_ethereum::token_metadata::compute_metadata_hash; +/// +/// let bytes = b"example protobuf bytes"; +/// let hash1 = compute_metadata_hash(bytes); +/// let hash2 = compute_metadata_hash(bytes); +/// assert_eq!(hash1, hash2); // Same bytes = same hash +/// ``` +pub fn compute_metadata_hash(protobuf_bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(protobuf_bytes); + let hash = hasher.finalize(); + format!("{hash:x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_network_id_ethereum() { + assert_eq!(parse_network_id("ETHEREUM_MAINNET"), Ok(1)); + } + + #[test] + fn test_parse_network_id_polygon() { + assert_eq!(parse_network_id("POLYGON_MAINNET"), Ok(137)); + } + + #[test] + fn test_parse_network_id_arbitrum() { + assert_eq!(parse_network_id("ARBITRUM_MAINNET"), Ok(42161)); + } + + #[test] + fn test_parse_network_id_optimism() { + assert_eq!(parse_network_id("OPTIMISM_MAINNET"), Ok(10)); + } + + #[test] + fn test_parse_network_id_base() { + assert_eq!(parse_network_id("BASE_MAINNET"), Ok(8453)); + } + + #[test] + fn test_parse_network_id_unknown() { + let result = parse_network_id("UNKNOWN_NETWORK"); + assert!(result.is_err()); + assert_eq!( + result, + Err(TokenMetadataError::UnknownNetworkId( + "UNKNOWN_NETWORK".to_string() + )) + ); + } + + #[test] + fn test_parse_network_id_empty() { + let result = parse_network_id(""); + assert!(result.is_err()); + } + + #[test] + fn test_compute_metadata_hash_deterministic() { + let bytes = b"example protobuf bytes"; + let hash1 = compute_metadata_hash(bytes); + let hash2 = compute_metadata_hash(bytes); + assert_eq!(hash1, hash2); + } + + #[test] + fn test_compute_metadata_hash_different_bytes() { + let bytes1 = b"protobuf bytes 1"; + let bytes2 = b"protobuf bytes 2"; + + let hash1 = compute_metadata_hash(bytes1); + let hash2 = compute_metadata_hash(bytes2); + + assert_ne!(hash1, hash2); + } + + #[test] + fn test_compute_metadata_hash_format() { + let bytes = b"example protobuf bytes"; + let hash = compute_metadata_hash(bytes); + + // SHA256 produces 256 bits = 32 bytes = 64 hex characters + assert_eq!(hash.len(), 64); + // Verify it's valid hex + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_compute_metadata_hash_empty_bytes() { + let bytes = b""; + let hash = compute_metadata_hash(bytes); + + // Empty bytes should still produce valid SHA256 hash + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_token_metadata_serialization() { + let token = TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + decimals: 6, + }; + + let json = serde_json::to_string(&token).expect("Failed to serialize"); + let deserialized: TokenMetadata = + serde_json::from_str(&json).expect("Failed to deserialize"); + + assert_eq!(token, deserialized); + } + + #[test] + fn test_chain_metadata_serialization() { + let mut metadata = ChainMetadata { + network_id: "ETHEREUM_MAINNET".to_string(), + assets: HashMap::new(), + }; + + let usdc = TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + decimals: 6, + }; + + metadata.assets.insert("USDC".to_string(), usdc); + + let json = serde_json::to_string(&metadata).expect("Failed to serialize"); + let deserialized: ChainMetadata = + serde_json::from_str(&json).expect("Failed to deserialize"); + + assert_eq!(metadata, deserialized); + } + + #[test] + fn test_erc_standard_display() { + assert_eq!(ErcStandard::Erc20.to_string(), "ERC20"); + assert_eq!(ErcStandard::Erc721.to_string(), "ERC721"); + assert_eq!(ErcStandard::Erc1155.to_string(), "ERC1155"); + } + + #[test] + fn test_erc_standard_serialization() { + let erc20 = ErcStandard::Erc20; + let json = serde_json::to_string(&erc20).expect("Failed to serialize"); + let deserialized: ErcStandard = serde_json::from_str(&json).expect("Failed to deserialize"); + assert_eq!(erc20, deserialized); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/utils/address_utils.rs b/src/chain_parsers/visualsign-ethereum/src/utils/address_utils.rs new file mode 100644 index 00000000..d8ca4daf --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/utils/address_utils.rs @@ -0,0 +1,84 @@ +//! Ethereum address utilities and well-known contract addresses +//! +//! This module provides canonical addresses for contracts like WETH and USDC +//! that may not be in the registry. For most tokens, prefer using the registry. +//! +//! # Example +//! +//! ```rust,ignore +//! use visualsign_ethereum::utils::address_utils::WellKnownAddresses; +//! +//! let weth = WellKnownAddresses::weth(1)?; // Ethereum mainnet WETH +//! ``` + +use alloy_primitives::Address; +use std::collections::HashMap; + +/// Well-known contract addresses by token name and chain ID +/// +/// These are contracts that may not be in a custom registry but are canonical +/// across all chains (e.g., WETH, USDC). For protocol-specific tokens, prefer +/// using the ContractRegistry instead. +pub struct WellKnownAddresses; + +impl WellKnownAddresses { + /// Get WETH address for a chain + pub fn weth(chain_id: u64) -> Option
{ + WETH_ADDRESSES + .get(&chain_id) + .and_then(|addr_str| addr_str.parse().ok()) + } + + /// Get USDC address for a chain + pub fn usdc(chain_id: u64) -> Option
{ + USDC_ADDRESSES + .get(&chain_id) + .and_then(|addr_str| addr_str.parse().ok()) + } + + /// Get Permit2 address (same on all chains) + pub fn permit2() -> Address { + // Permit2 is deployed at the same address on all chains + "0x000000000022d473030f116ddee9f6b43ac78ba3" + .parse() + .expect("Valid PERMIT2 address") + } + + /// Get all addresses for a token across all chains + pub fn all_addresses(token: &str) -> HashMap { + let mut map = HashMap::new(); + match token { + "WETH" => { + for (&chain_id, &addr) in WETH_ADDRESSES.entries() { + map.insert(chain_id, addr.to_string()); + } + } + "USDC" => { + for (&chain_id, &addr) in USDC_ADDRESSES.entries() { + map.insert(chain_id, addr.to_string()); + } + } + _ => {} + } + map + } +} + +// WETH addresses by chain ID +// Sourced from official Uniswap documentation and chain explorers +pub static WETH_ADDRESSES: phf::Map = phf::phf_map! { + 1u64 => "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // Ethereum Mainnet + 10u64 => "0x4200000000000000000000000000000000000006", // Optimism + 137u64 => "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", // Polygon + 8453u64 => "0x4200000000000000000000000000000000000006", // Base + 42161u64 => "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", // Arbitrum +}; + +// USDC addresses by chain ID (using the canonical USDC Bridge) +pub static USDC_ADDRESSES: phf::Map = phf::phf_map! { + 1u64 => "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // Ethereum Mainnet + 10u64 => "0x0b2c639c533813f4aa9d7837caf62653d097ff85", // Optimism + 137u64 => "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", // Polygon + 8453u64 => "0x833589fcd6edb6e08f4c7c32d4f71b1566469c3d", // Base + 42161u64 => "0xff970a61a04b1ca14834a43f5de4533ebddb5f86", // Arbitrum +}; diff --git a/src/chain_parsers/visualsign-ethereum/src/utils/mod.rs b/src/chain_parsers/visualsign-ethereum/src/utils/mod.rs new file mode 100644 index 00000000..eb64f58b --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/utils/mod.rs @@ -0,0 +1,9 @@ +//! Reusable Ethereum decoder utilities for DApp protocols +//! +//! This module provides shared utilities for decoding Solidity contract calls and creating +//! visualizations. These utilities are designed to be reusable across any DApp that uses +//! Solidity contracts, making it easy to add support for new protocols (e.g., Aave, Curve, etc). + +pub mod address_utils; + +pub use address_utils::*; diff --git a/src/chain_parsers/visualsign-ethereum/src/visualizer.rs b/src/chain_parsers/visualsign-ethereum/src/visualizer.rs new file mode 100644 index 00000000..49d4ba14 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/visualizer.rs @@ -0,0 +1,272 @@ +use crate::context::VisualizerContext; +use std::collections::HashMap; +use visualsign::AnnotatedPayloadField; +use visualsign::vsptrait::VisualSignError; + +/// Trait for visualizing specific contract types +/// We're using Arc so that visualizers can be shared across threads +/// (we don't have guarantee it's only going to be one thread in tokio) +pub trait ContractVisualizer: Send + Sync { + /// Returns the contract type this visualizer handles + fn contract_type(&self) -> &str; + + /// Visualizes a call to this contract type + /// + /// # Arguments + /// * `context` - The visualizer context containing transaction information + /// + /// # Returns + /// * `Ok(Some(fields))` - Successfully visualized into annotated fields + /// * `Ok(None)` - This visualizer cannot handle this call + /// * `Err(error)` - Error during visualization + /// + /// # TODO + /// Return hashed data of chain metadata as part of the response + fn visualize( + &self, + context: &VisualizerContext, + ) -> Result>, VisualSignError>; +} + +/// Trait for visualizers that can handle raw calldata inputs +/// +/// Some visualizers can work directly with calldata bytes (with or without +/// function selectors), automatically detecting which function was called. +/// Visualizers that require specific structured input don't implement this trait. +pub trait CalldataVisualizer: Send + Sync { + /// Attempts to decode and visualize calldata + /// + /// Implementations should accept calldata in flexible formats: + /// - With 4-byte function selector + /// - Without selector (raw parameters) + /// - Custom encodings + /// + /// # Arguments + /// * `calldata` - The raw calldata bytes + /// * `chain_id` - The chain ID for lookups + /// * `registry` - Optional contract registry for metadata + /// + /// # Returns + /// * `Some(SignablePayloadField)` if decoding succeeds + /// * `None` if the input doesn't match any known function + fn visualize_calldata( + &self, + calldata: &[u8], + chain_id: u64, + registry: Option<&crate::registry::ContractRegistry>, + ) -> Option; +} + +/// Registry for managing Ethereum contract visualizers (Immutable) +/// +/// This registry is designed to be built once and shared immutably (e.g., in an Arc). +/// Use `EthereumVisualizerRegistryBuilder` to construct a registry. +pub struct EthereumVisualizerRegistry { + visualizers: HashMap>, +} + +impl EthereumVisualizerRegistry { + /// Retrieves a visualizer by contract type + /// + /// # Arguments + /// * `contract_type` - The contract type to look up + /// + /// # Returns + /// * `Some(&dyn ContractVisualizer)` - The visualizer if found + /// * `None` - No visualizer registered for this type + pub fn get(&self, contract_type: &str) -> Option<&dyn ContractVisualizer> { + self.visualizers.get(contract_type).map(Box::as_ref) + } +} + +// Implement VisualizerRegistry trait for EthereumVisualizerRegistry +impl crate::context::VisualizerRegistry for EthereumVisualizerRegistry { + fn get_visualizer(&self, contract_type: &str) -> Option<&dyn ContractVisualizer> { + self.get(contract_type) + } +} + +/// Builder for creating a new EthereumVisualizerRegistry (Mutable) +/// +/// This builder is used during the setup phase to register visualizers. +/// Once all visualizers are registered, call `build()` to create an immutable registry. +#[derive(Default)] +pub struct EthereumVisualizerRegistryBuilder { + visualizers: HashMap>, +} + +impl EthereumVisualizerRegistryBuilder { + /// Creates a new empty builder + pub fn new() -> Self { + Self { + visualizers: HashMap::new(), + } + } + + /// Creates a new builder pre-populated with default protocols + /// + /// Returns both the EthereumVisualizerRegistryBuilder and ContractRegistry since + /// protocol registration populates both registries. Discarding either would be wasteful. + pub fn with_default_protocols() -> (Self, crate::registry::ContractRegistry) { + let mut builder = Self::new(); + let mut contract_reg = crate::registry::ContractRegistry::new(); + crate::protocols::register_all(&mut contract_reg, &mut builder); + (builder, contract_reg) + } + + /// Registers a visualizer for a specific contract type + /// + /// # Arguments + /// * `visualizer` - The visualizer to register + /// + /// # Returns + /// * `None` - If this is a new registration + /// * `Some(old_visualizer)` - If an existing visualizer was replaced + pub fn register( + &mut self, + visualizer: Box, + ) -> Option> { + let contract_type = visualizer.contract_type().to_string(); + self.visualizers.insert(contract_type, visualizer) + } + + /// Consumes the builder and returns the immutable registry + pub fn build(self) -> EthereumVisualizerRegistry { + EthereumVisualizerRegistry { + visualizers: self.visualizers, + } + } +} + +impl Default for EthereumVisualizerRegistry { + fn default() -> Self { + EthereumVisualizerRegistryBuilder::default().build() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mock visualizer for testing + struct MockVisualizer { + contract_type: String, + } + + impl ContractVisualizer for MockVisualizer { + fn contract_type(&self) -> &str { + &self.contract_type + } + + fn visualize( + &self, + _context: &VisualizerContext, + ) -> Result>, VisualSignError> { + Ok(Some(vec![])) + } + } + + #[test] + fn test_builder_new() { + let builder = EthereumVisualizerRegistryBuilder::new(); + assert_eq!(builder.visualizers.len(), 0); + } + + #[test] + fn test_builder_register() { + let mut builder = EthereumVisualizerRegistryBuilder::new(); + let visualizer = Box::new(MockVisualizer { + contract_type: "TestToken".to_string(), + }); + + let old = builder.register(visualizer); + assert!(old.is_none()); + assert_eq!(builder.visualizers.len(), 1); + } + + #[test] + fn test_builder_register_returns_old() { + let mut builder = EthereumVisualizerRegistryBuilder::new(); + + let visualizer1 = Box::new(MockVisualizer { + contract_type: "Token".to_string(), + }); + let old1 = builder.register(visualizer1); + assert!(old1.is_none()); + + let visualizer2 = Box::new(MockVisualizer { + contract_type: "Token".to_string(), + }); + let old2 = builder.register(visualizer2); + assert!(old2.is_some()); + assert_eq!(old2.unwrap().contract_type(), "Token"); + } + + #[test] + fn test_builder_build() { + let mut builder = EthereumVisualizerRegistryBuilder::new(); + let visualizer = Box::new(MockVisualizer { + contract_type: "ERC20".to_string(), + }); + builder.register(visualizer); + + let registry = builder.build(); + assert!(registry.get("ERC20").is_some()); + assert_eq!(registry.get("ERC20").unwrap().contract_type(), "ERC20"); + } + + #[test] + fn test_registry_get_not_found() { + let registry = EthereumVisualizerRegistry::default(); + assert!(registry.get("NonExistent").is_none()); + } + + #[test] + fn test_registry_multiple_visualizers() { + let mut builder = EthereumVisualizerRegistryBuilder::new(); + + let erc20 = Box::new(MockVisualizer { + contract_type: "ERC20".to_string(), + }); + let uniswap = Box::new(MockVisualizer { + contract_type: "UniswapV3".to_string(), + }); + let aave = Box::new(MockVisualizer { + contract_type: "Aave".to_string(), + }); + + builder.register(erc20); + builder.register(uniswap); + builder.register(aave); + + let registry = builder.build(); + assert!(registry.get("ERC20").is_some()); + assert!(registry.get("UniswapV3").is_some()); + assert!(registry.get("Aave").is_some()); + assert!(registry.get("Unknown").is_none()); + } + + #[test] + fn test_builder_default() { + let builder = EthereumVisualizerRegistryBuilder::default(); + let registry = builder.build(); + // Default creates empty registry (no default protocols registered in tests) + assert!(registry.get("ERC20").is_none()); + } + + #[test] + fn test_registry_default() { + let registry = EthereumVisualizerRegistry::default(); + // Default calls builder default and builds empty registry + assert!(registry.get("ERC20").is_none()); + } + + #[test] + fn test_builder_with_default_protocols() { + let (builder, _contract_reg) = EthereumVisualizerRegistryBuilder::with_default_protocols(); + let registry = builder.build(); + // Even though with_default_protocols is called, no protocols are registered + // because crate::protocols::register_all is a placeholder + assert!(registry.get("ERC20").is_none()); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/1559.expected b/src/chain_parsers/visualsign-ethereum/tests/fixtures/1559.expected index 3312c174..69961246 100644 --- a/src/chain_parsers/visualsign-ethereum/tests/fixtures/1559.expected +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/1559.expected @@ -1 +1 @@ -{"Fields":[{"FallbackText":"Ethereum Mainnet","Label":"Network","TextV2":{"Text":"Ethereum Mainnet"},"Type":"text_v2"},{"AddressV2":{"Address":"0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"0.005"},"FallbackText":"0.005 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"262716","Label":"Gas Limit","TextV2":{"Text":"262716"},"Type":"text_v2"},{"FallbackText":"1.767030437 gwei","Label":"Gas Price","TextV2":{"Text":"1.767030437 gwei"},"Type":"text_v2"},{"FallbackText":"1.264743777 gwei","Label":"Max Priority Fee Per Gas","TextV2":{"Text":"1.264743777 gwei"},"Type":"text_v2"},{"FallbackText":"562","Label":"Nonce","TextV2":{"Text":"562"},"Type":"text_v2"},{"FallbackText":"Universal Router Execute: 4 commands ([WrapEth, V2SwapExactIn, PayPortion, Sweep]), deadline 2025-07-24 21:15:28 UTC","Label":"Universal Router","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"WrapEth input: 0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000","Label":"Command 1","PreviewLayout":{"Subtitle":{"Text":"Input: 0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000"},"Title":{"Text":"WrapEth"}},"Type":"preview_layout"},{"FallbackText":"V2SwapExactIn input: 0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f","Label":"Command 2","PreviewLayout":{"Subtitle":{"Text":"Input: 0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f"},"Title":{"Text":"V2SwapExactIn"}},"Type":"preview_layout"},{"FallbackText":"PayPortion input: 0x000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c0000000000000000000000000000000000000000000000000000000000000019","Label":"Command 3","PreviewLayout":{"Subtitle":{"Text":"Input: 0x000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c0000000000000000000000000000000000000000000000000000000000000019"},"Title":{"Text":"PayPortion"}},"Type":"preview_layout"},{"FallbackText":"Sweep input: 0x000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000006b95d095598e1a080cb62e8ccd99dd64853f1b9900000000000000000000000000000000000000000000000000000e2ab638514b","Label":"Command 4","PreviewLayout":{"Subtitle":{"Text":"Input: 0x000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000006b95d095598e1a080cb62e8ccd99dd64853f1b9900000000000000000000000000000000000000000000000000000e2ab638514b"},"Title":{"Text":"Sweep"}},"Type":"preview_layout"},{"FallbackText":"2025-07-24 21:15:28 UTC","Label":"Deadline","TextV2":{"Text":"2025-07-24 21:15:28 UTC"},"Type":"text_v2"}]},"Subtitle":{"Text":"4 commands, deadline 2025-07-24 21:15:28 UTC"},"Title":{"Text":"Universal Router Execute"}},"Type":"preview_layout"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} +{"Fields":[{"FallbackText":"Ethereum Mainnet","Label":"Network","TextV2":{"Text":"Ethereum Mainnet"},"Type":"text_v2"},{"AddressV2":{"Address":"0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"0.005"},"FallbackText":"0.005 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"262716","Label":"Gas Limit","TextV2":{"Text":"262716"},"Type":"text_v2"},{"FallbackText":"1.767030437 gwei","Label":"Gas Price","TextV2":{"Text":"1.767030437 gwei"},"Type":"text_v2"},{"FallbackText":"1.264743777 gwei","Label":"Max Priority Fee Per Gas","TextV2":{"Text":"1.264743777 gwei"},"Type":"text_v2"},{"FallbackText":"562","Label":"Nonce","TextV2":{"Text":"562"},"Type":"text_v2"},{"FallbackText":"0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006882a27000000000000000000000000000000000000000000000000000000000000000040b080604000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c00000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000060000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000006b95d095598e1a080cb62e8ccd99dd64853f1b9900000000000000000000000000000000000000000000000000000e2ab638514b0b","Label":"Input Data","TextV2":{"Text":"0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006882a27000000000000000000000000000000000000000000000000000000000000000040b080604000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c00000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000060000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000006b95d095598e1a080cb62e8ccd99dd64853f1b9900000000000000000000000000000000000000000000000000000e2ab638514b0b"},"Type":"text_v2"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v2swap.expected b/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v2swap.expected new file mode 100644 index 00000000..12731183 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v2swap.expected @@ -0,0 +1 @@ +{"Fields":[{"FallbackText":"Ethereum Mainnet","Label":"Network","TextV2":{"Text":"Ethereum Mainnet"},"Type":"text_v2"},{"AddressV2":{"Address":"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"0"},"FallbackText":"0 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"283399","Label":"Gas Limit","TextV2":{"Text":"283399"},"Type":"text_v2"},{"FallbackText":"2.081928163 gwei","Label":"Gas Price","TextV2":{"Text":"2.081928163 gwei"},"Type":"text_v2"},{"FallbackText":"2 gwei","Label":"Max Priority Fee Per Gas","TextV2":{"Text":"2 gwei"},"Type":"text_v2"},{"FallbackText":"183","Label":"Nonce","TextV2":{"Text":"183"},"Type":"text_v2"},{"FallbackText":"Uniswap Universal Router Execute: 4 commands ([Permit2Permit, V2SwapExactIn, PayPortion, UnwrapWeth])","Label":"Universal Router","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"Permit 0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD to spend Unlimited Amount of 0x72b658bd674f9c2b4954682f517c17d14476e417","Label":"Permit2 Permit","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"0x72b658bd674f9c2b4954682f517c17d14476e417","Label":"Token","TextV2":{"Text":"0x72b658bd674f9c2b4954682f517c17d14476e417"},"Type":"text_v2"},{"FallbackText":"1461501637330902918203684832716283019655932542975","Label":"Amount","TextV2":{"Text":"1461501637330902918203684832716283019655932542975"},"Type":"text_v2"},{"FallbackText":"0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad","Label":"Spender","TextV2":{"Text":"0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad"},"Type":"text_v2"},{"FallbackText":"2025-12-15 18:44 UTC","Label":"Expires","TextV2":{"Text":"2025-12-15 18:44 UTC"},"Type":"text_v2"},{"FallbackText":"2025-11-15 19:14 UTC","Label":"Sig Deadline","TextV2":{"Text":"2025-11-15 19:14 UTC"},"Type":"text_v2"}]},"Subtitle":{"Text":"Permit 0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD to spend Unlimited Amount of 0x72b658bd674f9c2b4954682f517c17d14476e417"},"Title":{"Text":"Permit2 Permit"}},"Type":"preview_layout"},{"FallbackText":"Swap 46525180921656252477 0x72b658bd674f9c2b4954682f517c17d14476e417 for >=0.002761011377502728 WETH via V2 (1 hops)","Label":"V2 Swap Exact In","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"0x72b658bd674f9c2b4954682f517c17d14476e417","Label":"Input Token","TextV2":{"Text":"0x72b658bd674f9c2b4954682f517c17d14476e417"},"Type":"text_v2"},{"FallbackText":"46525180921656252477","Label":"Input Amount","TextV2":{"Text":"46525180921656252477"},"Type":"text_v2"},{"FallbackText":"WETH","Label":"Output Token","TextV2":{"Text":"WETH"},"Type":"text_v2"},{"FallbackText":">=0.002761011377502728","Label":"Minimum Output","TextV2":{"Text":">=0.002761011377502728"},"Type":"text_v2"},{"FallbackText":"1","Label":"Hops","TextV2":{"Text":"1"},"Type":"text_v2"}]},"Subtitle":{"Text":"Swap 46525180921656252477 0x72b658bd674f9c2b4954682f517c17d14476e417 for >=0.002761011377502728 WETH via V2 (1 hops)"},"Title":{"Text":"V2 Swap Exact In"}},"Type":"preview_layout"},{"FallbackText":"Pay 0.2500% of WETH to 0x000000fee13a103A10D593b9AE06b3e05F2E7E1c","Label":"Pay Portion","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"WETH","Label":"Token","TextV2":{"Text":"WETH"},"Type":"text_v2"},{"FallbackText":"0.2500%","Label":"Percentage","TextV2":{"Text":"0.2500%"},"Type":"text_v2"},{"FallbackText":"0x000000fee13a103a10d593b9ae06b3e05f2e7e1c","Label":"Recipient","TextV2":{"Text":"0x000000fee13a103a10d593b9ae06b3e05f2e7e1c"},"Type":"text_v2"}]},"Subtitle":{"Text":"Pay 0.2500% of WETH to 0x000000fee13a103A10D593b9AE06b3e05F2E7E1c"},"Title":{"Text":"Pay Portion"}},"Type":"preview_layout"},{"FallbackText":"Unwrap >=0.002754108849058971 WETH to ETH for 0x8419e7Eda8577Dfc49591a49CAd965a0Fc6716cF","Label":"Unwrap WETH","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"0.002754108849058971","Label":"Minimum Amount","TextV2":{"Text":">=0.002754108849058971 WETH"},"Type":"text_v2"},{"FallbackText":"0x8419e7eda8577dfc49591a49cad965a0fc6716cf","Label":"Recipient","TextV2":{"Text":"0x8419e7eda8577dfc49591a49cad965a0fc6716cf"},"Type":"text_v2"}]},"Subtitle":{"Text":"Unwrap >=0.002754108849058971 WETH to ETH for 0x8419e7Eda8577Dfc49591a49CAd965a0Fc6716cF"},"Title":{"Text":"Unwrap WETH"}},"Type":"preview_layout"}]},"Subtitle":{"Text":"4 commands"},"Title":{"Text":"Uniswap Universal Router Execute"}},"Type":"preview_layout"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v2swap.input b/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v2swap.input new file mode 100644 index 00000000..876491e6 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v2swap.input @@ -0,0 +1 @@ +0x02f904cf0181b78477359400847c17b3e383045307943fc91a3afd70395cd496c647d5a6cc9d4b2b7fad80b904a424856bc30000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000040a08060c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000003a0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000072b658bd674f9c2b4954682f517c17d14476e417000000000000000000000000ffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000006940571900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad000000000000000000000000000000000000000000000000000000006918d12100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000412eb0933411b0970637515316fb50511bea7908d3f85808074ceed3bf881562bc06da5178104470e54fb5be96075169b30799c30f30975317ae14113ffdb84bc81c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000285aaa58c1a1a183d0000000000000000000000000000000000000000000000000009cf200e607a0800000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000072b658bd674f9c2b4954682f517c17d14476e417000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000008419e7eda8577dfc49591a49cad965a0fc6716cf0000000000000000000000000000000000000000000000000009c8d8ef9ef49bc0 diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v3swap.expected b/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v3swap.expected new file mode 100644 index 00000000..2ca66855 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v3swap.expected @@ -0,0 +1 @@ +{"Fields":[{"FallbackText":"Ethereum Mainnet","Label":"Network","TextV2":{"Text":"Ethereum Mainnet"},"Type":"text_v2"},{"AddressV2":{"Address":"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"0"},"FallbackText":"0 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"281329","Label":"Gas Limit","TextV2":{"Text":"281329"},"Type":"text_v2"},{"FallbackText":"1 gwei","Label":"Gas Price","TextV2":{"Text":"1 gwei"},"Type":"text_v2"},{"FallbackText":"0.01 gwei","Label":"Max Priority Fee Per Gas","TextV2":{"Text":"0.01 gwei"},"Type":"text_v2"},{"FallbackText":"64","Label":"Nonce","TextV2":{"Text":"64"},"Type":"text_v2"},{"FallbackText":"Uniswap Universal Router Execute: 4 commands ([V3SwapExactIn, V3SwapExactIn, PayPortion, UnwrapWeth]), deadline 2025-11-15 22:01:35 UTC","Label":"Universal Router","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"Swap 240.000000000000000000 SETH for >=0.003573913782539750 WETH via V3 (0.3% fee)","Label":"V3 Swap Exact In","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"SETH","Label":"Input Token","TextV2":{"Text":"SETH"},"Type":"text_v2"},{"FallbackText":"240.000000000000000000","Label":"Input Amount","TextV2":{"Text":"240.000000000000000000"},"Type":"text_v2"},{"FallbackText":"WETH","Label":"Output Token","TextV2":{"Text":"WETH"},"Type":"text_v2"},{"FallbackText":">=0.003573913782539750","Label":"Minimum Output","TextV2":{"Text":">=0.003573913782539750"},"Type":"text_v2"},{"FallbackText":"0.3%","Label":"Fee Tier","TextV2":{"Text":"0.3%"},"Type":"text_v2"}]},"Subtitle":{"Text":"Swap 240.000000000000000000 SETH for >=0.003573913782539750 WETH via V3 (0.3% fee)"},"Title":{"Text":"V3 Swap Exact In"}},"Type":"preview_layout"},{"FallbackText":"Swap 60.000000000000000000 SETH for >=0.000895286609014849 WETH via V3 (1% fee)","Label":"V3 Swap Exact In","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"SETH","Label":"Input Token","TextV2":{"Text":"SETH"},"Type":"text_v2"},{"FallbackText":"60.000000000000000000","Label":"Input Amount","TextV2":{"Text":"60.000000000000000000"},"Type":"text_v2"},{"FallbackText":"WETH","Label":"Output Token","TextV2":{"Text":"WETH"},"Type":"text_v2"},{"FallbackText":">=0.000895286609014849","Label":"Minimum Output","TextV2":{"Text":">=0.000895286609014849"},"Type":"text_v2"},{"FallbackText":"1%","Label":"Fee Tier","TextV2":{"Text":"1%"},"Type":"text_v2"}]},"Subtitle":{"Text":"Swap 60.000000000000000000 SETH for >=0.000895286609014849 WETH via V3 (1% fee)"},"Title":{"Text":"V3 Swap Exact In"}},"Type":"preview_layout"},{"FallbackText":"Pay 0.2500% of WETH to 0x000000fee13a103A10D593b9AE06b3e05F2E7E1c","Label":"Pay Portion","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"WETH","Label":"Token","TextV2":{"Text":"WETH"},"Type":"text_v2"},{"FallbackText":"0.2500%","Label":"Percentage","TextV2":{"Text":"0.2500%"},"Type":"text_v2"},{"FallbackText":"0x000000fee13a103a10d593b9ae06b3e05f2e7e1c","Label":"Recipient","TextV2":{"Text":"0x000000fee13a103a10d593b9ae06b3e05f2e7e1c"},"Type":"text_v2"}]},"Subtitle":{"Text":"Pay 0.2500% of WETH to 0x000000fee13a103A10D593b9AE06b3e05F2E7E1c"},"Title":{"Text":"Pay Portion"}},"Type":"preview_layout"},{"FallbackText":"Unwrap >=0.004469200391554600 WETH to ETH for 0x0000000000000000000000000000000000000001","Label":"Unwrap WETH","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"0.004469200391554600","Label":"Minimum Amount","TextV2":{"Text":">=0.004469200391554600 WETH"},"Type":"text_v2"},{"FallbackText":"0x0000000000000000000000000000000000000001","Label":"Recipient","TextV2":{"Text":"0x0000000000000000000000000000000000000001"},"Type":"text_v2"}]},"Subtitle":{"Text":"Unwrap >=0.004469200391554600 WETH to ETH for 0x0000000000000000000000000000000000000001"},"Title":{"Text":"Unwrap WETH"}},"Type":"preview_layout"},{"FallbackText":"2025-11-15 22:01:35 UTC","Label":"Deadline","TextV2":{"Text":"2025-11-15 22:01:35 UTC"},"Type":"text_v2"}]},"Subtitle":{"Text":"4 commands, deadline 2025-11-15 22:01:35 UTC"},"Title":{"Text":"Uniswap Universal Router Execute"}},"Type":"preview_layout"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v3swap.input b/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v3swap.input new file mode 100644 index 00000000..f530b36e --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/uniswap-v3swap.input @@ -0,0 +1 @@ +0x02f9048d014083989680843b9aca0083044af1943fc91a3afd70395cd496c647d5a6cc9d4b2b7fad80b904643593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006918f83f00000000000000000000000000000000000000000000000000000000000000040000060c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c000000000000000000000000000000000000000000000000000000000000003400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000d02ab486cedc00000000000000000000000000000000000000000000000000000000cb274a57755e600000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002be71bdfe1df69284f00ee185cf0d95d0c7680c0d4000bb8c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000340aad21b3b70000000000000000000000000000000000000000000000000000000032e42284d704100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002be71bdfe1df69284f00ee185cf0d95d0c7680c0d4002710c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000fe0b6cdc4c628c0 diff --git a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs index 6f8b97b8..2bfebc7a 100644 --- a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs +++ b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs @@ -12,7 +12,7 @@ fn fixture_path(name: &str) -> PathBuf { path } -static FIXTURES: [&str; 2] = ["1559", "legacy"]; +static FIXTURES: [&str; 4] = ["1559", "legacy", "uniswap-v2swap", "uniswap-v3swap"]; #[test] fn test_with_fixtures() { @@ -34,6 +34,7 @@ fn test_with_fixtures() { decode_transfers: true, transaction_name: None, metadata: None, + abi_registry: None, }; let result = transaction_string_to_visual_sign(transaction_hex, options); @@ -78,6 +79,7 @@ fn test_ethereum_charset_validation() { decode_transfers: true, transaction_name: None, metadata: None, + abi_registry: None, }; let result = transaction_string_to_visual_sign(transaction_hex, options); diff --git a/src/chain_parsers/visualsign-solana/Cargo.toml b/src/chain_parsers/visualsign-solana/Cargo.toml index 50c3b251..2b862c56 100644 --- a/src/chain_parsers/visualsign-solana/Cargo.toml +++ b/src/chain_parsers/visualsign-solana/Cargo.toml @@ -20,6 +20,8 @@ spl-token = "7.0.0" spl-associated-token-account = "6.0" spl-stake-pool = "2.0.2" solana-system-interface = "1.0" +spl-token-2022 = "10.0.0" +spl-token-2022-interface = "2.1.0" [dev-dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/src/chain_parsers/visualsign-solana/src/core/visualsign.rs b/src/chain_parsers/visualsign-solana/src/core/visualsign.rs index 259a5971..9212bc2a 100644 --- a/src/chain_parsers/visualsign-solana/src/core/visualsign.rs +++ b/src/chain_parsers/visualsign-solana/src/core/visualsign.rs @@ -372,6 +372,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("Solana Transaction".to_string()), + abi_registry: None, }, ); @@ -454,6 +455,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("V0 Transaction".to_string()), + abi_registry: None, }, ); @@ -620,6 +622,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("Legacy Transfer Test".to_string()), + abi_registry: None, }, ); @@ -663,6 +666,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("V0 Transfer Test".to_string()), + abi_registry: None, }, ); @@ -789,6 +793,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("Manual V0 Transfer Test".to_string()), + abi_registry: None, }, ); @@ -939,6 +944,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("TokenKeg Test".to_string()), + abi_registry: None, }, ); diff --git a/src/chain_parsers/visualsign-solana/src/lib.rs b/src/chain_parsers/visualsign-solana/src/lib.rs index 1ae51ab3..2aeea883 100644 --- a/src/chain_parsers/visualsign-solana/src/lib.rs +++ b/src/chain_parsers/visualsign-solana/src/lib.rs @@ -32,6 +32,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some(description.to_string()), + abi_registry: None, }, ) .unwrap_or_else(|e| panic!("Failed to convert {description} to payload: {e:?}")); @@ -88,6 +89,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("Unicode Escape Test".to_string()), + abi_registry: None, }, ) .expect("Should convert to payload successfully"); diff --git a/src/chain_parsers/visualsign-solana/src/presets/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/mod.rs index 11c52d78..abde613f 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/mod.rs @@ -3,4 +3,5 @@ pub mod compute_budget; pub mod jupiter_swap; pub mod stakepool; pub mod system; +pub mod token_2022; pub mod unknown_program; diff --git a/src/chain_parsers/visualsign-solana/src/presets/token_2022/config.rs b/src/chain_parsers/visualsign-solana/src/presets/token_2022/config.rs new file mode 100644 index 00000000..076f2585 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/token_2022/config.rs @@ -0,0 +1,27 @@ +//! Configuration for Token 2022 program integration + +use crate::core::{SolanaIntegrationConfig, SolanaIntegrationConfigData}; +use std::collections::HashMap; + +pub struct Token2022Config; + +impl SolanaIntegrationConfig for Token2022Config { + fn new() -> Self { + Self + } + + fn data(&self) -> &SolanaIntegrationConfigData { + static DATA: std::sync::OnceLock = std::sync::OnceLock::new(); + DATA.get_or_init(|| { + let mut programs = HashMap::new(); + let mut token2022_instructions = HashMap::new(); + token2022_instructions.insert("*", vec!["*"]); + // Token 2022 program ID + programs.insert( + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + token2022_instructions, + ); + SolanaIntegrationConfigData { programs } + }) + } +} diff --git a/src/chain_parsers/visualsign-solana/src/presets/token_2022/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/token_2022/mod.rs new file mode 100644 index 00000000..e0b094e6 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/token_2022/mod.rs @@ -0,0 +1,209 @@ +//! Token 2022 preset implementation for Solana + +mod config; + +use crate::core::{ + InstructionVisualizer, SolanaIntegrationConfig, VisualizerContext, VisualizerKind, +}; +use crate::utils::format_token_amount; +use config::Token2022Config; +use solana_sdk::instruction::AccountMeta; +use spl_token_2022::instruction::TokenInstruction; +use visualsign::errors::VisualSignError; +use visualsign::field_builders::{create_number_field, create_raw_data_field, create_text_field}; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +static TOKEN_2022_CONFIG: Token2022Config = Token2022Config; + +pub struct Token2022Visualizer; + +impl InstructionVisualizer for Token2022Visualizer { + fn visualize_tx_commands( + &self, + context: &VisualizerContext, + ) -> Result { + let instruction = context + .current_instruction() + .ok_or_else(|| VisualSignError::MissingData("No instruction found".into()))?; + + // Parse the Token 2022 instruction + let token_2022_instruction = + parse_token_2022_instruction(&instruction.data, &instruction.accounts) + .map_err(|e| VisualSignError::DecodeError(e.to_string()))?; + + // Generate proper preview layout + create_token_2022_preview_layout(&token_2022_instruction, instruction, context) + } + + fn get_config(&self) -> Option<&dyn SolanaIntegrationConfig> { + Some(&TOKEN_2022_CONFIG) + } + + fn kind(&self) -> VisualizerKind { + VisualizerKind::Payments("Token2022") + } +} + +enum Token2022Instruction { + MintToChecked { + amount: u64, + decimals: u8, + mint: String, + account: String, + mint_authority: String, + }, + BurnChecked { + amount: u64, + decimals: u8, + account: String, + mint: String, + authority: String, + }, +} + +fn parse_token_2022_instruction( + data: &[u8], + accounts: &[AccountMeta], +) -> Result { + let sdk_instruction = TokenInstruction::unpack(data) + .map_err(|e| format!("Failed to parse Token 2022 instruction: {e}"))?; + + match sdk_instruction { + TokenInstruction::MintToChecked { amount, decimals } => { + if accounts.len() < 3 { + return Err("Invalid mintToChecked: insufficient accounts".to_string()); + } + + Ok(Token2022Instruction::MintToChecked { + amount, + decimals, + mint: accounts[0].pubkey.to_string(), + account: accounts[1].pubkey.to_string(), + mint_authority: accounts[2].pubkey.to_string(), + }) + } + TokenInstruction::BurnChecked { amount, decimals } => { + if accounts.len() < 3 { + return Err("Invalid burnChecked: insufficient accounts".to_string()); + } + + Ok(Token2022Instruction::BurnChecked { + amount, + decimals, + account: accounts[0].pubkey.to_string(), + mint: accounts[1].pubkey.to_string(), + authority: accounts[2].pubkey.to_string(), + }) + } + other => Err(format!("Unsupported Token 2022 instruction: {other:?}")), + } +} + +fn create_token_2022_preview_layout( + parsed: &Token2022Instruction, + instruction: &solana_sdk::instruction::Instruction, + context: &VisualizerContext, +) -> Result { + let (title, condensed_fields, expanded_fields) = match parsed { + Token2022Instruction::MintToChecked { + amount, + decimals, + mint, + account, + mint_authority, + } => { + let formatted_amount = format_token_amount(*amount, *decimals); + let title = format!("Mint To Checked: {formatted_amount} tokens"); + + let condensed = vec![ + create_text_field("Action", "Mint To Checked")?, + create_text_field("Amount", &formatted_amount)?, + ]; + + let expanded = vec![ + create_text_field("Instruction", "Mint To Checked")?, + create_text_field("Amount", &formatted_amount)?, + create_number_field("Raw Amount", &amount.to_string(), "")?, + create_number_field("Decimals", &decimals.to_string(), "")?, + create_text_field("Mint", mint)?, + create_text_field("Destination Account", account)?, + create_text_field("Mint Authority", mint_authority)?, + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_raw_data_field(&instruction.data, Some(hex::encode(&instruction.data)))?, + ]; + + (title, condensed, expanded) + } + Token2022Instruction::BurnChecked { + amount, + decimals, + account, + mint, + authority, + } => { + let formatted_amount = format_token_amount(*amount, *decimals); + let title = format!("Burn Checked: {formatted_amount} tokens"); + + let condensed = vec![ + create_text_field("Action", "Burn Checked")?, + create_text_field("Amount", &formatted_amount)?, + ]; + + let expanded = vec![ + create_text_field("Instruction", "Burn Checked")?, + create_text_field("Amount", &formatted_amount)?, + create_number_field("Raw Amount", &amount.to_string(), "")?, + create_number_field("Decimals", &decimals.to_string(), "")?, + create_text_field("Token Account", account)?, + create_text_field("Mint", mint)?, + create_text_field("Authority", authority)?, + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_raw_data_field(&instruction.data, Some(hex::encode(&instruction.data)))?, + ]; + + (title, condensed, expanded) + } + }; + + let preview_layout = SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: title.clone(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: String::new(), + }), + condensed: Some(SignablePayloadFieldListLayout { + fields: condensed_fields, + }), + expanded: Some(SignablePayloadFieldListLayout { + fields: expanded_fields, + }), + }; + + Ok(AnnotatedPayloadField { + static_annotation: None, + dynamic_annotation: None, + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + label: { + let instruction_num = context.instruction_index() + 1; + format!("Instruction {instruction_num}") + }, + fallback_text: { + let program_id = instruction.program_id; + format!("Token 2022: {title}\nProgram ID: {program_id}") + }, + }, + preview_layout, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + mod fixture_test; +} diff --git a/src/chain_parsers/visualsign-solana/src/presets/token_2022/tests/fixture_test.rs b/src/chain_parsers/visualsign-solana/src/presets/token_2022/tests/fixture_test.rs new file mode 100644 index 00000000..a3b85802 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/token_2022/tests/fixture_test.rs @@ -0,0 +1,274 @@ +// Fixture-based tests for Token 2022 instruction parsing +// See /src/chain_parsers/visualsign-solana/TESTING.md for documentation +// +// To add these tests to the existing tests module in mod.rs, add this line at the end +// of the existing `mod tests` block (before the closing brace): +// +// mod fixture_test; +// +// This file will then be compiled as `tests::fixture_test` + +use super::*; +use crate::core::VisualizerContext; +use solana_parser::solana::structs::SolanaAccount; +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; +use std::str::FromStr; +use visualsign::SignablePayloadField; + +#[derive(Debug, serde::Deserialize)] +struct TestFixture { + description: String, + source: String, + signature: String, + cluster: String, + #[serde(default)] + full_transaction_note: Option, + #[allow(dead_code)] + instruction_index: usize, + instruction_data: String, + program_id: String, + accounts: Vec, + #[serde(default)] + expected_fields: Option>, + #[serde(default)] + expected_error: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct TestAccount { + pubkey: String, + signer: bool, + writable: bool, + #[allow(dead_code)] + description: String, +} + +fn load_fixture(name: &str) -> TestFixture { + let fixture_path = format!( + "{}/tests/fixtures/token_2022/{}.json", + env!("CARGO_MANIFEST_DIR"), + name + ); + let fixture_content = std::fs::read_to_string(&fixture_path) + .unwrap_or_else(|e| panic!("Failed to read fixture {fixture_path}: {e}")); + serde_json::from_str(&fixture_content) + .unwrap_or_else(|e| panic!("Failed to parse fixture {fixture_path}: {e}")) +} + +fn create_instruction_from_fixture(fixture: &TestFixture) -> Instruction { + let program_id = Pubkey::from_str(&fixture.program_id).unwrap(); + let accounts: Vec = fixture + .accounts + .iter() + .map(|acc| { + let pubkey = Pubkey::from_str(&acc.pubkey).unwrap(); + AccountMeta { + pubkey, + is_signer: acc.signer, + is_writable: acc.writable, + } + }) + .collect(); + + // Instruction data from JSON RPC responses is base58 encoded + let data = bs58::decode(&fixture.instruction_data) + .into_vec() + .expect("Failed to decode base58 instruction data"); + + Instruction { + program_id, + accounts, + data, + } +} + +fn test_real_transaction(fixture_name: &str, test_name: &str) { + let fixture: TestFixture = load_fixture(fixture_name); + println!("\n=== Testing {test_name} Transaction ==="); + println!("Description: {}", fixture.description); + println!("Source: {}", fixture.source); + println!("Signature: {}", fixture.signature); + println!("Cluster: {}", fixture.cluster); + if let Some(note) = &fixture.full_transaction_note { + println!("Transaction Context: {note}"); + } + println!(); + + let instruction = create_instruction_from_fixture(&fixture); + let instructions = vec![instruction.clone()]; + + // Create a context - using index 0 since we only loaded the one relevant instruction + // In reality, the fixture.instruction_index would be used with all transaction instructions + let sender = SolanaAccount { + account_key: fixture.accounts.first().unwrap().pubkey.clone(), + signer: false, + writable: false, + }; + let context = VisualizerContext::new(&sender, 0, &instructions); + + // Visualize + let visualizer = Token2022Visualizer; + + // Check if this is an unhappy path test (expected to fail) + if let Some(expected_error) = &fixture.expected_error { + let result = visualizer.visualize_tx_commands(&context); + assert!( + result.is_err(), + "Expected error for unsupported instruction, but parsing succeeded" + ); + let error_msg = result.unwrap_err().to_string(); + // The error message is wrapped, so check if it contains the expected text + assert!( + error_msg.contains(expected_error), + "Expected error message to contain '{expected_error}', but got: {error_msg}" + ); + println!("✓ Correctly rejected unsupported instruction: {error_msg}"); + return; + } + + let result = visualizer + .visualize_tx_commands(&context) + .expect("Failed to visualize instruction"); + + // Extract the preview layout + if let SignablePayloadField::PreviewLayout { + common, + preview_layout, + } = result.signable_payload_field + { + println!("\n=== Extracted Fields ==="); + println!("Label: {}", common.label); + if let Some(title) = &preview_layout.title { + println!("Title: {}", title.text); + } + + if let Some(expanded) = &preview_layout.expanded { + println!("\nExpanded Fields:"); + for field in &expanded.fields { + match &field.signable_payload_field { + SignablePayloadField::TextV2 { common, text_v2 } => { + println!(" {}: {}", common.label, text_v2.text); + } + SignablePayloadField::Number { common, number } => { + println!(" {}: {}", common.label, number.number); + } + SignablePayloadField::AmountV2 { common, amount_v2 } => { + println!(" {}: {}", common.label, amount_v2.amount); + } + _ => {} + } + } + } + + // Validate against expected fields + println!("\n=== Validation ==="); + let expected_fields = fixture + .expected_fields + .as_ref() + .expect("Expected fields not provided for happy path test"); + for (key, expected_value) in expected_fields { + let expected_str = expected_value + .as_str() + .unwrap_or_else(|| panic!("Expected field '{key}' is not a string")); + + if let Some(expanded) = &preview_layout.expanded { + let found = + expanded + .fields + .iter() + .any(|field| match &field.signable_payload_field { + SignablePayloadField::TextV2 { common, text_v2 } => { + let label_normalized = + common.label.to_lowercase().replace(" ", "_"); + let key_normalized = key.to_lowercase(); + let label_matches = label_normalized == key_normalized; + let value_matches = text_v2.text == expected_str; + + if label_matches { + if value_matches { + println!("✓ {key}: {expected_str} (matches)"); + } else { + println!( + "✗ {}: expected '{}', got '{}'", + key, expected_str, text_v2.text + ); + } + return value_matches; + } + false + } + SignablePayloadField::Number { common, number } => { + let label_normalized = + common.label.to_lowercase().replace(" ", "_"); + let key_normalized = key.to_lowercase(); + let label_matches = label_normalized == key_normalized; + let value_matches = number.number == expected_str; + + if label_matches { + if value_matches { + println!("✓ {key}: {expected_str} (matches)"); + } else { + println!( + "✗ {}: expected '{}', got '{}'", + key, expected_str, number.number + ); + } + return value_matches; + } + false + } + SignablePayloadField::AmountV2 { common, amount_v2 } => { + let label_normalized = + common.label.to_lowercase().replace(" ", "_"); + let key_normalized = key.to_lowercase(); + let label_matches = label_normalized == key_normalized; + let value_matches = amount_v2.amount == expected_str; + + if label_matches { + if value_matches { + println!("✓ {key}: {expected_str} (matches)"); + } else { + println!( + "✗ {}: expected '{}', got '{}'", + key, expected_str, amount_v2.amount + ); + } + return value_matches; + } + false + } + _ => false, + }); + + if !found { + println!("✗ {key}: field not found in output"); + } + + assert!( + found, + "Expected field '{key}' with value '{expected_str}' not found in visualization" + ); + } + } + } else { + panic!("Expected PreviewLayout field type"); + } +} + +#[test] +fn test_mint_to_checked_real_transaction() { + test_real_transaction("mint_to_checked", "MintToChecked"); +} + +#[test] +fn test_burn_checked_real_transaction() { + test_real_transaction("burn_checked", "BurnChecked"); +} + +#[test] +fn test_transfer_checked_unsupported() { + test_real_transaction("transfer_checked", "TransferChecked (Unsupported)"); +} diff --git a/src/chain_parsers/visualsign-solana/src/utils/mod.rs b/src/chain_parsers/visualsign-solana/src/utils/mod.rs index 50c9adf5..2b268973 100644 --- a/src/chain_parsers/visualsign-solana/src/utils/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/utils/mod.rs @@ -143,6 +143,7 @@ pub mod test_utils { metadata: None, decode_transfers: true, transaction_name: None, + abi_registry: None, }, ) .expect("Failed to visualize tx commands") diff --git a/src/chain_parsers/visualsign-solana/tests/fixtures/token_2022/burn_checked.json b/src/chain_parsers/visualsign-solana/tests/fixtures/token_2022/burn_checked.json new file mode 100644 index 00000000..0965cd50 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fixtures/token_2022/burn_checked.json @@ -0,0 +1,40 @@ +{ + "description": "Token 2022 BurnChecked instruction - burning 500,000 tokens with 9 decimals", + "source": "http://localnet/tx", + "signature": "AcNTokOnWozotk+jqg3tnfLJirv+wCjUkZVW548jC/zZ6ixIFSHn0ytH2IS4WhblfAA5Bu6xcKaROzEGTkf2zg0BAAIFtWJBq4hctUN6Co6qxyHR+mzV7HesyzHO49cRlWUgP4uq+ftSJxlCHfdbp8OL9K8GF3sj2OH7CVr566gpsSdZaPDae/N9iZHHaJjkv5a26zkZh15KBX0EHFE8sGMRYPeSAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAG3fbh7nWP3hhCXbzkbM3athr8TYO5DSf+vfko2KGL/KZl8zdww6hyLBn5zofDiQmo+bEtdb9DYpDE/9C1TA3GAgQDAQIACg8A5AtUAgAAAAkDAAUCxgYAAA==", + "cluster": "mainnet-beta", + "full_transaction_note": "This is a test fixture for Token 2022 BurnChecked instruction. Amount: 500,000 raw units, 9 decimals = 0.0005 tokens.", + "instruction_index": 0, + "instruction_data": "rJ2MEcBsXsr1v", + "program_id": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "accounts": [ + { + "pubkey": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + "signer": false, + "writable": true, + "description": "Token account to burn from" + }, + { + "pubkey": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "signer": false, + "writable": true, + "description": "Mint account" + }, + { + "pubkey": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + "signer": true, + "writable": false, + "description": "Authority" + } + ], + "expected_fields": { + "instruction": "Burn Checked", + "amount": "0.0005", + "raw_amount": "500000", + "decimals": "9", + "token_account": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "authority": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + "program_id": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + } +} diff --git a/src/chain_parsers/visualsign-solana/tests/fixtures/token_2022/mint_to_checked.json b/src/chain_parsers/visualsign-solana/tests/fixtures/token_2022/mint_to_checked.json new file mode 100644 index 00000000..c2dff7e9 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fixtures/token_2022/mint_to_checked.json @@ -0,0 +1,40 @@ +{ + "description": "Token 2022 MintToChecked instruction - minting 1,000,000 tokens with 6 decimals", + "source": "http://localnet/tx", + "signature": "AX4hGLPOpFsZpSowGaVvAk5q4n7TkTyJWEX3YPaNjr9vpsw/fA/+ssJ/R6XQ6nKND3uZwWxet8TwwuMjnGf0JA8BAAIFtWJBq4hctUN6Co6qxyHR+mzV7HesyzHO49cRlWUgP4uiIfwk3BJjcWXA1RgjIHelkb6I5e/tKF+A7q3x3nIvRfDae/N9iZHHaJjkv5a26zkZh15KBX0EHFE8sGMRYPeSAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAG3fbh7nWP3hhCXbzkbM3athr8TYO5DSf+vfko2KGL/CuAIcvnnNDFEcbV6JUVUI/f4r5+ARLQ10taUPDSf6UDAgQDAgEACg4AyBeoBAAAAAkDAAUCbwYAAA==", + "cluster": "mainnet-beta", + "full_transaction_note": "This is a test fixture for Token 2022 MintToChecked instruction. Amount: 1,000,000 raw units, 6 decimals = 1.0 tokens.", + "instruction_index": 0, + "instruction_data": "oSNwHdcqW8m6D", + "program_id": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "accounts": [ + { + "pubkey": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "signer": false, + "writable": true, + "description": "Mint account" + }, + { + "pubkey": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + "signer": false, + "writable": true, + "description": "Destination token account" + }, + { + "pubkey": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + "signer": true, + "writable": false, + "description": "Mint authority" + } + ], + "expected_fields": { + "instruction": "Mint To Checked", + "amount": "1", + "raw_amount": "1000000", + "decimals": "6", + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "destination_account": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + "mint_authority": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + "program_id": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + } +} diff --git a/src/chain_parsers/visualsign-solana/tests/fixtures/token_2022/transfer_checked.json b/src/chain_parsers/visualsign-solana/tests/fixtures/token_2022/transfer_checked.json new file mode 100644 index 00000000..3a000c91 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fixtures/token_2022/transfer_checked.json @@ -0,0 +1,38 @@ +{ + "description": "Token 2022 TransferChecked instruction - transferring tokens with decimals check (UNSUPPORTED - should fail)", + "source": "https://solscan.io/tx/pDxnsJ8RAucAfGKD54D9khP1GShcUQehyqAEhwbdogbxsD3UGdH2iFpyV2FXHDjV84WSvdXhrWYfW6vfjwy1vSe", + "signature": "pDxnsJ8RAucAfGKD54D9khP1GShcUQehyqAEhwbdogbxsD3UGdH2iFpyV2FXHDjV84WSvdXhrWYfW6vfjwy1vSe", + "cluster": "mainnet-beta", + "full_transaction_note": "This is a test fixture for Token 2022 TransferChecked instruction from a real mainnet transaction. Amount: 50,000,000 tokens (50000000000000000 raw units, 9 decimals). This instruction is NOT YET SUPPORTED and should fail parsing with an appropriate error message.", + "instruction_index": 0, + "instruction_data": "g6x5zqCAw5JB2", + "program_id": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "accounts": [ + { + "pubkey": "FzHhqxHPNXrzoNRwVmDRcNprTcx5YAdLyuRNC5FYthi8", + "signer": false, + "writable": true, + "description": "Source token account" + }, + { + "pubkey": "pc3gLpoZCe79SZAbABtes2fiWAaiTJuTk9NsNxR2ZSj", + "signer": false, + "writable": false, + "description": "Mint account" + }, + { + "pubkey": "BE5Mi1nnQzxpuRWUUvWjEjsjB7sHGPNhS7TDM9PAR56j", + "signer": false, + "writable": true, + "description": "Destination token account" + }, + { + "pubkey": "J46G7r1XKDyyw1sFzh8EPPf4nCxewBxudJNLojGnPLVS", + "signer": true, + "writable": false, + "description": "Authority (multisig)" + } + ], + "expected_error": "Unsupported Token 2022 instruction: TransferChecked" +} + diff --git a/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs b/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs index 9a7c592d..3353b2e2 100644 --- a/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs +++ b/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs @@ -72,6 +72,7 @@ pub fn payload_from_b64(data: &str) -> SignablePayload { decode_transfers: true, transaction_name: None, metadata: None, + abi_registry: None, }, ) .expect("Failed to visualize tx commands") @@ -85,6 +86,7 @@ pub fn payload_from_b64_with_context(data: &str, context: &str) -> SignablePaylo decode_transfers: true, transaction_name: None, metadata: None, + abi_registry: None, }, ) { Ok(payload) => payload, diff --git a/src/parser/app/src/registry.rs b/src/parser/app/src/registry.rs index 9f8551d9..7b112c08 100644 --- a/src/parser/app/src/registry.rs +++ b/src/parser/app/src/registry.rs @@ -7,9 +7,11 @@ #[must_use] pub fn create_registry() -> visualsign::registry::TransactionConverterRegistry { let mut registry = visualsign::registry::TransactionConverterRegistry::new(); + // TODO: Create a ChainRegistry trait that all chains can implement for token metadata, + // contract types, etc. Currently only Ethereum has a ContractRegistry. registry.register::( visualsign::registry::Chain::Ethereum, - visualsign_ethereum::EthereumVisualSignConverter, + visualsign_ethereum::EthereumVisualSignConverter::new(), ); registry.register::( visualsign::registry::Chain::Solana, diff --git a/src/parser/app/src/routes/parse.rs b/src/parser/app/src/routes/parse.rs index 784931d4..11cb2614 100644 --- a/src/parser/app/src/routes/parse.rs +++ b/src/parser/app/src/routes/parse.rs @@ -27,11 +27,11 @@ pub fn parse( )); } - // todo: make these request args or metadata let options = VisualSignOptions { decode_transfers: true, transaction_name: None, metadata: parse_request.chain_metadata.clone(), + abi_registry: None, }; let registry = create_registry(); let proto_chain = ProtoChain::from_i32(parse_request.chain) diff --git a/src/parser/cli/Cargo.toml b/src/parser/cli/Cargo.toml index 2e8b915a..5804d1dc 100644 --- a/src/parser/cli/Cargo.toml +++ b/src/parser/cli/Cargo.toml @@ -14,7 +14,7 @@ parser_app = { path = "../app" } borsh = { version = "1.5.7", features = ["std"], default-features = false } generated = { path = "../../generated" } visualsign = { workspace = true } -#visualsign-ethereum = { path = "../../chain_parsers/visualsign-ethereum" } +visualsign-ethereum = { path = "../../chain_parsers/visualsign-ethereum" } visualsign-solana = { path = "../../chain_parsers/visualsign-solana" } visualsign-unspecified = { path = "../../chain_parsers/visualsign-unspecified" } diff --git a/src/parser/cli/src/cli.rs b/src/parser/cli/src/cli.rs index 9743f079..aa51885f 100644 --- a/src/parser/cli/src/cli.rs +++ b/src/parser/cli/src/cli.rs @@ -2,8 +2,11 @@ use crate::chains; use chains::parse_chain; use clap::Parser; use parser_app::registry::create_registry; +use std::sync::Arc; use visualsign::vsptrait::VisualSignOptions; use visualsign::{SignablePayload, SignablePayloadField}; +use visualsign_ethereum::abi_registry::AbiRegistry; +use visualsign_ethereum::embedded_abis::load_and_map_abi; #[derive(Parser, Debug)] #[command(name = "visualsign-parser")] @@ -29,6 +32,13 @@ struct Args { help = "Show only condensed view (what hardware wallets display)" )] condensed_only: bool, + + #[arg( + long = "abi-json-mappings", + value_name = "ABI_NAME:0xADDRESS", + help = "Map custom ABI JSON file to contract address. Format: AbiName:/path/to/abi.json:0xAddress. Can be used multiple times" + )] + abi_json_mappings: Vec, } #[derive(Debug, Clone, Copy)] @@ -205,15 +215,86 @@ fn common_label(field: &SignablePayloadField) -> String { } } +/// Parses full ABI mapping with file path: "AbiName:/path/to/file.json:0xAddress" +fn parse_abi_file_mapping(mapping_str: &str) -> Option<(String, String, String)> { + let parts: Vec<&str> = mapping_str.rsplitn(2, ':').collect(); + if parts.len() != 2 { + return None; + } + + let address_str = parts[0]; + let rest = parts[1]; + + let name_file_parts: Vec<&str> = rest.splitn(2, ':').collect(); + if name_file_parts.len() != 2 { + return None; + } + + let abi_name = name_file_parts[0].to_string(); + let file_path = name_file_parts[1].to_string(); + let address_str = address_str.to_string(); + + Some((abi_name, file_path, address_str)) +} + +/// Builds an ABI registry from CLI mappings with file paths +/// Returns (registry, valid_count) and logs any errors +fn build_abi_registry_from_mappings(abi_json_mappings: &[String]) -> (AbiRegistry, usize) { + let mut registry = AbiRegistry::new(); + let mut valid_count = 0; + + for mapping in abi_json_mappings { + match parse_abi_file_mapping(mapping) { + Some((abi_name, file_path, address_str)) => { + let chain_id = 1u64; // TODO: Make chain_id configurable + match load_and_map_abi(&mut registry, &abi_name, &file_path, chain_id, &address_str) + { + Ok(()) => { + valid_count += 1; + eprintln!( + " Loaded ABI '{}' from {} and mapped to {}", + abi_name, file_path, address_str + ); + } + Err(e) => { + eprintln!(" Warning: Failed to load/map ABI '{}': {}", abi_name, e); + } + } + } + None => { + eprintln!( + " Warning: Invalid ABI mapping '{}' (expected format: AbiName:/path/to/file.json:0xAddress)", + mapping + ); + } + } + } + + (registry, valid_count) +} + fn parse_and_display( chain: &str, raw_tx: &str, - options: VisualSignOptions, + mut options: VisualSignOptions, output_format: OutputFormat, condensed_only: bool, + abi_json_mappings: &[String], ) { let registry_chain = parse_chain(chain); + // Build and report ABI registry from mappings + if !abi_json_mappings.is_empty() { + eprintln!("Registering custom ABIs:"); + let (registry, valid_count) = build_abi_registry_from_mappings(abi_json_mappings); + eprintln!( + "Successfully registered {}/{} ABI mappings\n", + valid_count, + abi_json_mappings.len() + ); + options.abi_registry = Some(Arc::new(registry)); + } + let registry = create_registry(); let signable_payload_str = registry.convert_transaction(®istry_chain, raw_tx, options); match signable_payload_str { @@ -259,6 +340,7 @@ impl Cli { decode_transfers: true, transaction_name: None, metadata: None, + abi_registry: None, }; parse_and_display( @@ -267,6 +349,7 @@ impl Cli { options, args.output, args.condensed_only, + &args.abi_json_mappings, ); } } diff --git a/src/visualsign/README.md b/src/visualsign/README.md index 6c3e2325..0907f4ae 100644 --- a/src/visualsign/README.md +++ b/src/visualsign/README.md @@ -1,305 +1,119 @@ -# Visual Sign Protocol Documentation -This document provides specifications for the Visual Sign Protocol (VSP), a structured format for displaying transaction details to users for approval. The VSP is designed to present meaningful, human-readable information about operations requiring signatures. +# Visual Sign Protocol -## Important Concepts +Structured format for displaying transaction details to users for approval. -### Non-Canonical Format -**The SignablePayload JSON format is NOT canonical.** It should be treated by signers as an **opaque string field**. While we maintain deterministic ordering (currently alphabetical) for debugging consistency and cross-implementation compatibility, this is an implementation detail that may change. Signers should not parse or depend on the specific JSON structure or field ordering. +## Key Points -### Display Requirements for Implementers -Display elements (wallets, signing interfaces) are responsible for: -- **Parsing and interpreting** the SignablePayload to determine what to show users -- **Ensuring all fields are displayed** - Every field in the payload MUST be shown to the user -- **Minimum display guarantee** - At the very least, the `FallbackText` for each field must be displayed -- **Making display decisions** - The display element decides how to render fields (layout, styling, grouping) -- **Respecting user preferences** - Honor accessibility settings and display preferences +- **SignablePayload JSON is NOT canonical** - treat as an opaque string field +- **Display all fields** - at minimum show `FallbackText` for each field +- Field ordering is deterministic but not guaranteed to remain alphabetical +- v1 field types (`text`, `address`, `amount`) exist for backwards compatibility but are not used +- `AnnotatedFields` wrap `SignablePayloadField` with additional wallet context (not part of core spec) -**Notes** -* We don't use v1 text field types, but they're around for backwards compatibility for now -* AnnotatedFields are a layer on top of SignablePayload field for our wallet to provide more context, it's not in scope of the SignablePayload, it's still in structs but we'll consider removing in future -* Field ordering is deterministic but not guaranteed to be alphabetical in future versions - see [Deterministic Ordering Documentation](docs/DETERMINISTIC_ORDERING.md) - -## SignablePayload -A SignablePayload is the core structure that defines what is displayed to the user during the signing process. It contains metadata about the transaction and a collection of fields representing the transaction details. - -### Structure -
SignablePayload Structure +## SignablePayload Structure ```json { "Version": "0", "Title": "Withdraw", "Subtitle": "to 0x8a6e30eE13d06311a35f8fa16A950682A9998c71", - "Fields": [ - { - "FallbackText": "1 ETH", - "Label": "Amount", - "Type": "amount_v2", - "AmountV2": { - "Amount": "1", - "Abbreviation": "ETH" - } - }, - ... - ], - "EndorsedParamsDigest": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF", + "PayloadType": "Withdrawal", + "Fields": [...] } ``` -
- - -### Payload Components -| Field | Type | Description | -|-----------------------|----------------------------|-------------------------------------------| -| Version | String | Protocol version | -| Title | String | Primary title for the operation | -| Subtitle | String (optional) | Secondary descriptive text | -| PayloadType | String | Identifier for the SignablePayload (ex: Withdrawal, Swap, etc)| -| Fields | Array of SignablePayloadField | The fields containing transaction details | -| EndorsedParamsDigest | String (optional) | Digest of endorsed parameters | +| Field | Type | Description | +|-------------|-------------------------------|----------------------| +| Version | String | Protocol version | +| Title | String | Primary title | +| Subtitle | String (optional) | Secondary text | +| PayloadType | String | Operation identifier | +| Fields | Array\ | Transaction details | ## Field Types -The Visual Sign Protocol supports various field types to represent different kinds of data. - -#### Common Field Structure -All field types include these common properties: - -
Common Field Properties - -```json -{ - "Label": "Amount", - "FallbackText": "1 ETH", - "Type": "amount_v2" -} -``` -
- - -| Field | Type | Description | -|---------------|--------|--------------------------------------------| -| Label | String | Field label shown to the user | -| FallbackText | String | Plain text representation (for limited clients) | -| Type | String | Type identifier for the field | - -### Specific Field Types +All fields have common properties: -#### Text Fields -
Text Field Example +| Field | Type | Description | +|--------------|--------|--------------------| +| Label | String | Field label | +| FallbackText | String | Plain text fallback| +| Type | String | Type identifier | +### TextV2 ```json { "Label": "Asset", "FallbackText": "ETH | Ethereum", "Type": "text_v2", - "TextV2": { - "Text": "ETH | Ethereum" - } + "TextV2": { "Text": "ETH | Ethereum" } } ``` -
- - -#### Address Fields -
Address Field Example +### AddressV2 ```json { - "Label": "Amount", - "FallbackText": "0.00001234", - "Type": "amount_v2", - "AmountV2": { - "Amount": "0.00001234", - "Abbreviation": "BTC" + "Label": "Recipient", + "FallbackText": "0x1234...", + "Type": "address_v2", + "AddressV2": { + "Address": "0x1234...", + "Name": "My Wallet", + "Memo": "optional memo", + "AssetLabel": "ETH", + "BadgeText": "Verified" } } ``` -
- -### Amount Fields -Amount fields are user friendly ways to display the value being transferred -
Amount Field Example +### AmountV2 ```json { "Label": "Amount", "FallbackText": "0.00001234 BTC", "Type": "amount_v2", - "AmountV2": { - "Amount": "0.00001234", - "Abbreviation": "BTC" - } + "AmountV2": { "Amount": "0.00001234", "Abbreviation": "BTC" } } ``` -
- -### Number Fields - -
Number Field Example +### Number ```json { "Label": "gasLimit", "FallbackText": "21000", "Type": "number", - "Number": { - "Value": "21000" - } + "Number": { "Number": "21000" } } ``` -
- -### Divider Fields - -Divider fields are UI elements to split the UI on. This is used for clarity and to allow the UI to keep views in separate pages if needed. - -
Divider Field Example +### Divider ```json { "Label": "", "Type": "divider", - "Divider": { - "Style": "thin" - } + "Divider": { "Style": "thin" } } ``` -
+### PreviewLayout -### Layout Fields -We have additional layout fields for two different use cases - one for creating preview elements, where a condensed view can be optionally expanded by the user. - -
Preview Layout Field Example +Condensed/expanded view for complex data: ```json { "Type": "preview_layout", "PreviewLayout": { - "Title": "Delegate", - "Subtitle": "1 SOL Delegated to Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb" - }, - "Condensed": { - "Fields": [ /* array of SignablePayloadFields */] - }, - "Expanded": { - "Fields": [ /* array of SignablePayloadFields */] + "Title": { "Text": "Delegate" }, + "Subtitle": { "Text": "1 SOL" }, + "Condensed": { "Fields": [...] }, + "Expanded": { "Fields": [...] } } } ``` -
- - -## Endorsed Params - -The Endorsed Params feature allows passing additional parameters for the visualizer to interpret and potentially use for transforming the raw transaction to make meaningful display for user in a deterministic way. - -### Structure - -Endorsed parameters are cryptographically bound to the SignablePayload through the `EndorsedParamsDigest` field, which contains a hash of all endorsed parameters. These are presented as an example - and may be chain or wallet-specific. - -
Endorsed Params Structure - -```json -{ - "EndorsedParams": { - "ChainId": "1", - "ContractAddress": "0x6B175474E89094C44Da98b954EedeAC495271d0F", - "MethodSignature": "transfer(address,uint256)", - "Nonce": "42", - "CallData": "0xa9059cbb000000000000000000000000...", - "ABIs": {}, - "IDLs": {} - } -} -``` -
- -### Usage - -1. **Transaction Construction**: The visualizer service collects all necessary parameters for constructing a valid transaction. - -2. **Parameter Separation**: Parameters are separated into: - - User-facing fields (included in the `Fields` array) - - Hidden parameters (included in `EndorsedParams`) - -3. **Digest Creation**: The service computes a hash of the endorsed parameters: - ``` - EndorsedParamsDigest = sha256(serialize(EndorsedParams)) - ``` - -4. **Payload Assembly**: The digest is included in the SignablePayload, cryptographically binding the hidden parameters to the displayed information. - -### Security Considerations - -- The signer must verify that the `EndorsedParamsDigest` matches the endorsed parameters used for transaction construction -- Parameters that affect user funds or authorization should generally be displayed rather than hidden -- Implementations should document which parameters are endorsed vs. displayed to ensure transparency - -### Example Use Cases - -- Network fees and gas parameters -- Technical identifiers (contract addresses, chain IDs) -- Implementation-specific parameters (nonces, replay protection values) -- Method signatures and serialized call data - - -## Example Fixtures -Below are screenshots corresponding to specific fixture examples: - -Bitcoin Withdraw -![Bitcoin Withdraw using visualsign](docs/testFixtures.bitcoin_withdraw_fixture_generation.png) - -ERC20 Token Withdraw -![ERC20 Token Withdraw](docs/testFixtures.erc20_withdraw.png) - -Solana Withdraw with Expandable Preview Layouts -![Solana withdraw main page](docs/testFixtures.solana_withdraw_fixture_generation.png) -Expanding fields, these are expected to be shown when one of the expandable fields is clicked - -1. ![Solana details1](docs/testFixtures.solana_withdraw_fixture_generation_expandable_details_1.png) -2. ![alt text](docs/testFixtures.solana_withdraw_fixture_generation_expandable_details_2.png) - - -### Implementation Considerations -Field Ordering: Fields should be displayed in the order they appear in the Fields array -Version Compatibility: Clients should check the Version field to ensure they can properly render the payload -Fallback Rendering: If a client doesn't understand a field type, it should fall back to displaying the FallbackText -Security: Implementations should validate the ReplayProtection and EndorsedParamsDigest values - - -## Extending SignablePayloadField Types - -The VisualSign Protocol is designed to be extensible, allowing developers to safely add new field types while maintaining backward compatibility and ensuring data integrity. - -### Architecture Overview - -The field serialization system uses a **trait-based architecture with compile-time and runtime verification** that provides multiple layers of protection against incomplete implementations: - -```rust -trait FieldSerializer { - fn serialize_to_map(&self) -> Result, Error>; - fn get_expected_fields(&self) -> Vec<&'static str>; -} -``` - -### Key Features - -- **⚙️ Compile-Time Enforcement**: `DeterministicOrdering` trait ensures types implement deterministic serialization -- **🔒 Runtime Verification**: Automatically verifies all expected fields are present during serialization -- **📝 Deterministic Ordering**: Fields are automatically sorted deterministically (currently alphabetically) for consistent output -- **🚨 Error Detection**: Missing or unexpected fields cause immediate serialization failure with detailed error messages -- **🧪 Test-Driven**: Comprehensive test suite proves the verification system works correctly -- **🔄 Extensible**: Adding new field types is straightforward and safe - -### How to Add New Field Types -#### 1. Define the Field Structure - -First, create the data structure for your new field type: +## Adding New Field Types +1. Define the struct: ```rust #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct SignablePayloadFieldCurrency { @@ -307,179 +121,50 @@ pub struct SignablePayloadFieldCurrency { pub currency_code: String, #[serde(rename = "Symbol")] pub symbol: String, - #[serde(rename = "ExchangeRate", skip_serializing_if = "Option::is_none")] - pub exchange_rate: Option, -} -``` - -#### 2. Add the Enum Variant - -Add your new variant to the `SignablePayloadField` enum: - -```rust -pub enum SignablePayloadField { - // ... existing variants ... - - #[serde(rename = "currency")] - Currency { - #[serde(flatten)] - common: SignablePayloadFieldCommon, - #[serde(rename = "Currency")] - currency: SignablePayloadFieldCurrency, - }, -} -``` - -#### 3. Implement Serialization Logic - -Add your field to both required methods in the `FieldSerializer` implementation: - -```rust -impl FieldSerializer for SignablePayloadField { - fn serialize_to_map(&self) -> Result, Error> { - let mut fields = HashMap::new(); - match self { - // ... existing variants ... - - SignablePayloadField::Currency { common, currency } => { - serialize_field_variant!(fields, "currency", common, ("Currency", currency)); - }, - } - Ok(fields.into_iter().collect()) - } - - fn get_expected_fields(&self) -> Vec<&'static str> { - let mut base_fields = vec!["FallbackText", "Label", "Type"]; - match self { - // ... existing variants ... - - SignablePayloadField::Currency { .. } => base_fields.push("Currency"), - } - base_fields.sort(); - base_fields - } } ``` -#### 4. Update Helper Methods - -Add your variant to the existing helper methods: - +2. Add the enum variant to `SignablePayloadField`: ```rust -impl SignablePayloadField { - pub fn field_type(&self) -> &str { - match self { - // ... existing variants ... - SignablePayloadField::Currency { .. } => "currency", - } - } - - // Update other helper methods as needed... -} +#[serde(rename = "currency")] +Currency { + #[serde(flatten)] + common: SignablePayloadFieldCommon, + #[serde(rename = "Currency")] + currency: SignablePayloadFieldCurrency, +}, ``` -#### 5. Implement DeterministicOrdering Trait - -**Critical**: Your new field type must implement the `DeterministicOrdering` trait to be usable in contexts requiring deterministic serialization: - +3. Add to `serialize_to_map()`: ```rust -// This is already implemented for SignablePayloadField, but if creating a new top-level type: -impl DeterministicOrdering for YourNewType {} +SignablePayloadField::Currency { common, currency } => { + serialize_field_variant!(fields, "currency", common, ("Currency", currency)); +}, ``` -Without this implementation, the type cannot be used in functions requiring deterministic ordering, and compilation will fail with a clear error message. - -### Runtime Verification System - -The system automatically verifies field completeness during serialization: - +4. Add to `get_expected_fields()`: ```rust -// ✅ Successful serialization - all fields present -let currency_field = SignablePayloadField::Currency { - common: SignablePayloadFieldCommon { - fallback_text: "USD ($)".to_string(), - label: "Payment Currency".to_string(), - }, - currency: SignablePayloadFieldCurrency { - currency_code: "USD".to_string(), - symbol: "$".to_string(), - exchange_rate: None, - }, -}; - -let json = serde_json::to_string(¤cy_field)?; -// Result: {"Currency":{"CurrencyCode":"USD","Symbol":"$"},"FallbackText":"USD ($)","Label":"Payment Currency","Type":"currency"} +SignablePayloadField::Currency { .. } => base_fields.push("Currency"), ``` -If you forget to serialize a field or have mismatched expectations: - +5. Add to `field_type()`: ```rust -// ❌ This would fail with detailed error message: -// "Missing expected field 'Currency'. Expected: ["Currency", "FallbackText", "Label", "Type"], Actual: ["FallbackText", "Label", "Type"]" +SignablePayloadField::Currency { .. } => "currency", ``` -### Comprehensive Testing - -The system includes extensive tests that prove the verification works: +6. Implement `DeterministicOrdering` for the new struct. -```rust -#[test] -fn test_new_field_type() { - // Test that new field type serializes correctly with verification - let field = SignablePayloadField::Currency { /* ... */ }; - - // This will succeed only if ALL expected fields are present and correctly serialized - let result = serde_json::to_string(&field); - assert!(result.is_ok()); - - // Verify alphabetical ordering - let value: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - if let serde_json::Value::Object(map) = value { - let keys: Vec<_> = map.keys().cloned().collect(); - // Keys are automatically in alphabetical order - assert_eq!(keys, vec!["Currency", "FallbackText", "Label", "Type"]); - } -} -``` - -### Benefits of This Approach - -1. **🛡️ Defense in Depth**: - - **Compile-time**: Exhaustive pattern matching ensures all variants are handled, `DeterministicOrdering` trait enforces proper implementation - - **Runtime**: Field verification catches missing/incorrect fields - - **Test-time**: Comprehensive tests prove the system works - -2. **🔍 Clear Error Messages**: - - Missing `DeterministicOrdering` trait causes compile-time error with clear message - - Missing fields are immediately identified with specific field names at runtime - - Unexpected fields are caught and reported - - Detailed error context helps debugging - -3. **📊 Consistent Output**: - - All fields automatically ordered deterministically (currently alphabetically) - - Consistent JSON structure across all field types - - Backward compatibility maintained - -4. **🚀 Easy Extension**: - - Adding new field types requires minimal code changes - - Macro-based approach reduces boilerplate - - Compile-time checking makes it impossible to miss required implementation steps - -### Migration from Legacy Approach - -The new system maintains full backward compatibility while adding safety: +## Example Fixtures -- All existing field types work unchanged -- JSON output format is identical -- No breaking changes to API -- Existing tests continue to pass +Bitcoin Withdraw: +![Bitcoin Withdraw](docs/testFixtures.bitcoin_withdraw_fixture_generation.png) -### Best Practices +ERC20 Token Withdraw: +![ERC20 Token Withdraw](docs/testFixtures.erc20_withdraw.png) -1. **Always test new field types** with the provided verification tests -2. **Use descriptive field names** that clearly indicate their purpose -3. **Follow the naming convention** of existing field types -4. **Document new field types** in this README -5. **Consider backward compatibility** when designing new field structures +Solana Withdraw with expandable layouts: +![Solana withdraw](docs/testFixtures.solana_withdraw_fixture_generation.png) -This extensible architecture transforms field extension from a error-prone manual process into a safe, verified, and automatic system that catches mistakes before they can cause issues in production. \ No newline at end of file +Expanded details: +1. ![Details 1](docs/testFixtures.solana_withdraw_fixture_generation_expandable_details_1.png) +2. ![Details 2](docs/testFixtures.solana_withdraw_fixture_generation_expandable_details_2.png) diff --git a/src/visualsign/src/field_builders.rs b/src/visualsign/src/field_builders.rs index d42c31cd..0fd889fe 100644 --- a/src/visualsign/src/field_builders.rs +++ b/src/visualsign/src/field_builders.rs @@ -1,8 +1,8 @@ use crate::errors; use crate::{ AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldAddressV2, - SignablePayloadFieldAmountV2, SignablePayloadFieldCommon, SignablePayloadFieldNumber, - SignablePayloadFieldTextV2, + SignablePayloadFieldAmountV2, SignablePayloadFieldCommon, SignablePayloadFieldListLayout, + SignablePayloadFieldNumber, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, }; use regex::Regex; @@ -175,6 +175,42 @@ pub fn create_raw_data_field( }) } +/// Wrap a SignablePayloadField in an AnnotatedPayloadField with no annotations +pub fn annotate_field(field: SignablePayloadField) -> AnnotatedPayloadField { + AnnotatedPayloadField { + signable_payload_field: field, + static_annotation: None, + dynamic_annotation: None, + } +} + +/// Create a preview layout field with title, subtitle (fallback text), and expanded fields +/// This is useful for operation summaries that show a collapsible preview +pub fn create_preview_layout( + title: &str, + subtitle: String, + fields: Vec, +) -> AnnotatedPayloadField { + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: subtitle.clone(), + label: title.to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: title.to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: subtitle }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }, + static_annotation: None, + dynamic_annotation: None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/visualsign/src/registry.rs b/src/visualsign/src/registry.rs index 3df6490a..0efdc055 100644 --- a/src/visualsign/src/registry.rs +++ b/src/visualsign/src/registry.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::marker::PhantomData; use std::str::FromStr; +use std::sync::Arc; use crate::{ vsptrait::{ @@ -189,6 +190,131 @@ impl TransactionConverterRegistry { } } +/// Generic layered registry for combining global and request-scoped data. +/// +/// This struct enables efficient per-request registry overlays without cloning. +/// The global registry is shared via `Arc` (O(1) clone), while wallet-provided +/// data is owned and dropped after the request completes. +/// +/// # Example +/// +/// ``` +/// use std::sync::Arc; +/// use std::collections::HashMap; +/// use visualsign::registry::LayeredRegistry; +/// +/// // Global registry created once at startup +/// let mut global_data = HashMap::new(); +/// global_data.insert("USDC", 6u8); +/// global_data.insert("WETH", 18u8); +/// let global = Arc::new(global_data); +/// +/// // Request with wallet-provided data that overrides global +/// let mut wallet_data = HashMap::new(); +/// wallet_data.insert("USDC", 8u8); // Wallet says USDC has 8 decimals +/// let layered = LayeredRegistry::with_request(Arc::clone(&global), wallet_data); +/// +/// // Lookup checks request first, then falls back to global +/// assert_eq!(layered.lookup(|r| r.get("USDC").copied()), Some(8)); // From wallet +/// assert_eq!(layered.lookup(|r| r.get("WETH").copied()), Some(18)); // From global +/// assert_eq!(layered.lookup(|r| r.get("DAI").copied()), None); // Not found +/// ``` +/// +/// # Type Parameter +/// +/// `R` - The registry type (e.g., `ContractRegistry` for Ethereum) +#[derive(Clone)] +pub struct LayeredRegistry { + /// Request-scoped data (checked first during lookups) + request: Option, + /// Global registry shared across requests via Arc + global: Arc, +} + +impl LayeredRegistry { + /// Creates a layered registry with only the global layer. + /// + /// Use this when no request-specific data is available. + pub fn new(global: Arc) -> Self { + Self { + request: None, + global, + } + } + + /// Creates a layered registry with both global and request-scoped layers. + /// + /// Lookups will check the request layer first, then fall back to global. + pub fn with_request(global: Arc, request: R) -> Self { + Self { + request: Some(request), + global, + } + } + + /// Returns a reference to the global registry. + pub fn global(&self) -> &R { + &self.global + } + + /// Returns a reference to the request-scoped registry, if present. + pub fn request(&self) -> Option<&R> { + self.request.as_ref() + } + + /// Performs a layered lookup: checks request first, then global. + /// + /// The closure `f` is called on the request registry first (if present). + /// If it returns `None`, the closure is called on the global registry. + /// + /// # Example + /// + /// ``` + /// use std::sync::Arc; + /// use std::collections::HashMap; + /// use visualsign::registry::LayeredRegistry; + /// + /// let mut global = HashMap::new(); + /// global.insert("token", "USDC"); + /// let layered = LayeredRegistry::new(Arc::new(global)); + /// + /// let symbol = layered.lookup(|r| r.get("token").copied()); + /// assert_eq!(symbol, Some("USDC")); + /// ``` + pub fn lookup(&self, f: F) -> Option + where + F: Fn(&R) -> Option, + { + // Check request layer first + if let Some(ref request) = self.request { + if let Some(result) = f(request) { + return Some(result); + } + } + // Fall back to global + f(&self.global) + } + + /// Performs a layered lookup that returns a Result. + /// + /// Similar to `lookup`, but for fallible operations. Checks request first, + /// then global. Returns the first `Ok` result, or the last `Err` if both fail. + pub fn lookup_result(&self, f: F) -> Result + where + F: Fn(&R) -> Result, + { + // Check request layer first + if let Some(ref request) = self.request { + let result = f(request); + if result.is_ok() { + return result; + } + } + // Fall back to global + f(&self.global) + } +} + #[cfg(test)] mod tests { use super::*; @@ -460,4 +586,124 @@ mod tests { assert_eq!(Chain::Tron.as_str(), "Tron"); assert_eq!(Chain::Custom("MyChain".to_string()).as_str(), "MyChain"); } + + // Mock registry for LayeredRegistry tests + #[derive(Default)] + struct MockRegistry { + values: HashMap, + } + + impl MockRegistry { + fn with_value(key: &str, value: &str) -> Self { + let mut values = HashMap::new(); + values.insert(key.to_string(), value.to_string()); + Self { values } + } + + fn get(&self, key: &str) -> Option { + self.values.get(key).cloned() + } + } + + #[test] + fn test_layered_registry_global_only() { + let global = Arc::new(MockRegistry::with_value("token", "USDC")); + let layered = LayeredRegistry::new(global); + + // Should find value in global + let result = layered.lookup(|r| r.get("token")); + assert_eq!(result, Some("USDC".to_string())); + + // Should return None for missing key + let result = layered.lookup(|r| r.get("missing")); + assert_eq!(result, None); + } + + #[test] + fn test_layered_registry_request_overrides_global() { + let global = Arc::new(MockRegistry::with_value("token", "USDC")); + let request = MockRegistry::with_value("token", "WETH"); + let layered = LayeredRegistry::with_request(global, request); + + // Request layer should override global + let result = layered.lookup(|r| r.get("token")); + assert_eq!(result, Some("WETH".to_string())); + } + + #[test] + fn test_layered_registry_fallback_to_global() { + let global = Arc::new(MockRegistry::with_value("global_key", "global_value")); + let request = MockRegistry::with_value("request_key", "request_value"); + let layered = LayeredRegistry::with_request(global, request); + + // Key only in request layer + let result = layered.lookup(|r| r.get("request_key")); + assert_eq!(result, Some("request_value".to_string())); + + // Key only in global layer - should fall back + let result = layered.lookup(|r| r.get("global_key")); + assert_eq!(result, Some("global_value".to_string())); + + // Key in neither layer + let result = layered.lookup(|r| r.get("missing")); + assert_eq!(result, None); + } + + #[test] + fn test_layered_registry_accessors() { + let global = Arc::new(MockRegistry::with_value("key", "global")); + let request = MockRegistry::with_value("key", "request"); + let layered = LayeredRegistry::with_request(Arc::clone(&global), request); + + // Direct access to global + assert_eq!(layered.global().get("key"), Some("global".to_string())); + + // Direct access to request + assert!(layered.request().is_some()); + assert_eq!( + layered.request().unwrap().get("key"), + Some("request".to_string()) + ); + } + + #[test] + fn test_layered_registry_no_request() { + let global = Arc::new(MockRegistry::with_value("key", "value")); + let layered: LayeredRegistry = LayeredRegistry::new(global); + + assert!(layered.request().is_none()); + } + + #[test] + fn test_layered_registry_lookup_result_success() { + let global = Arc::new(MockRegistry::with_value("key", "value")); + let layered = LayeredRegistry::new(global); + + let result: Result = + layered.lookup_result(|r| r.get("key").ok_or("not found")); + assert_eq!(result, Ok("value".to_string())); + } + + #[test] + fn test_layered_registry_lookup_result_fallback() { + let global = Arc::new(MockRegistry::with_value("global_key", "global_value")); + let request = MockRegistry::default(); // Empty request registry + let layered = LayeredRegistry::with_request(global, request); + + // Request fails, should fall back to global + let result: Result = + layered.lookup_result(|r| r.get("global_key").ok_or("not found")); + assert_eq!(result, Ok("global_value".to_string())); + } + + #[test] + fn test_layered_registry_lookup_result_both_fail() { + let global = Arc::new(MockRegistry::default()); + let request = MockRegistry::default(); + let layered = LayeredRegistry::with_request(global, request); + + let result: Result = + layered.lookup_result(|r| r.get("missing").ok_or("not found")); + assert_eq!(result, Err("not found")); + } } diff --git a/src/visualsign/src/vsptrait.rs b/src/visualsign/src/vsptrait.rs index 8261677c..8d90f8bb 100644 --- a/src/visualsign/src/vsptrait.rs +++ b/src/visualsign/src/vsptrait.rs @@ -1,18 +1,43 @@ +use std::any::Any; use std::fmt::Debug; +use std::sync::Arc; use crate::SignablePayload; pub use crate::errors::{TransactionParseError, VisualSignError}; pub use generated::parser::ChainMetadata; -#[derive(Default, Debug, Clone)] +#[derive(Clone)] pub struct VisualSignOptions { pub decode_transfers: bool, pub transaction_name: Option, pub metadata: Option, + pub abi_registry: Option>, // Add more options as needed - we can extend this struct later } +impl Default for VisualSignOptions { + fn default() -> Self { + Self { + decode_transfers: false, + transaction_name: None, + metadata: None, + abi_registry: None, + } + } +} + +impl Debug for VisualSignOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VisualSignOptions") + .field("decode_transfers", &self.decode_transfers) + .field("transaction_name", &self.transaction_name) + .field("metadata", &self.metadata) + .field("abi_registry", &"") + .finish() + } +} + pub trait VisualSignConverter { fn to_visual_sign_payload( &self, @@ -259,6 +284,7 @@ mod tests { decode_transfers: true, transaction_name: Some("Custom Transaction".to_string()), metadata: None, + abi_registry: None, }; let result = converter.to_visual_sign_payload(transaction, options);