diff --git a/Cargo.lock b/Cargo.lock index 039f5ae62..f20503f2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2446,6 +2446,37 @@ dependencies = [ "shlex", ] +[[package]] +name = "cdp-sdk" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89b5d6b763f3adf6c059cff4c0d6434279ddb4a9a89332c2566dea0b600f75b" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bon", + "chrono", + "hex", + "http 1.3.1", + "jsonwebtoken", + "prettyplease", + "progenitor-middleware", + "progenitor-middleware-client", + "rand 0.9.2", + "regress", + "reqwest", + "reqwest-middleware", + "serde", + "serde_json", + "serde_yaml", + "sha2 0.10.9", + "syn 2.0.106", + "thiserror 1.0.69", + "url", + "urlencoding", + "uuid 1.18.0", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -4776,6 +4807,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem 3.0.5", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "k256" version = "0.13.4" @@ -5449,6 +5495,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openapiv3" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" +dependencies = [ + "indexmap 2.11.0", + "serde", + "serde_json", +] + [[package]] name = "opener" version = "0.8.2" @@ -5524,6 +5581,7 @@ dependencies = [ "bs58", "bytes", "cargo-llvm-cov", + "cdp-sdk", "chrono", "clap", "color-eyre", @@ -5557,6 +5615,7 @@ dependencies = [ "redis", "regex", "reqwest", + "reqwest-middleware", "secrets", "serde", "serde_json", @@ -5980,6 +6039,74 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "progenitor-middleware" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3fec73cff673d01b80f45ef71f3272efdea2602820e4e98f86d25b2ba75671" +dependencies = [ + "progenitor-middleware-client", + "progenitor-middleware-impl", + "progenitor-middleware-macro", +] + +[[package]] +name = "progenitor-middleware-client" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85987049cc053fe8550a50c7000d7bd2d9ca41f64d47a64d022bb21e7de4634b" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "percent-encoding", + "reqwest", + "reqwest-middleware", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "progenitor-middleware-impl" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6152bcf183b3e027a4db2e1dcd43de99e909c05c089c06f758e6d083a3bbb6bd" +dependencies = [ + "heck", + "http 1.3.1", + "indexmap 2.11.0", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "syn 2.0.106", + "thiserror 2.0.16", + "typify", + "unicode-ident", +] + +[[package]] +name = "progenitor-middleware-macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e66ad6baef4d1e5093e7e098a2ad4e6a366c56e5365899b3c1c622a6537e719" +dependencies = [ + "openapiv3", + "proc-macro2", + "progenitor-middleware-impl", + "quote", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_tokenstream", + "serde_yaml", + "syn 2.0.106", +] + [[package]] name = "prometheus" version = "0.14.0" @@ -6388,6 +6515,16 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "regress" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145bb27393fe455dd64d6cbc8d059adfa392590a45eadf079c01b11857e7b010" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + [[package]] name = "reqwest" version = "0.12.23" @@ -6432,6 +6569,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.2", ] @@ -6824,6 +6962,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "chrono", + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", + "uuid 1.18.0", +] + [[package]] name = "schemars" version = "0.9.0" @@ -6848,6 +7000,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -6979,6 +7143,9 @@ name = "semver" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] [[package]] name = "semver-parser" @@ -7055,6 +7222,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "serde_json" version = "1.0.143" @@ -7076,6 +7254,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.106", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -7120,6 +7310,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.11.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serdect" version = "0.2.0" @@ -10764,6 +10967,53 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "typify" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" +dependencies = [ + "heck", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver 1.0.26", + "serde", + "serde_json", + "syn 2.0.106", + "thiserror 2.0.16", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver 1.0.26", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.106", + "typify-impl", +] + [[package]] name = "ucd-trie" version = "0.1.7" @@ -10826,6 +11076,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -10920,6 +11176,7 @@ checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", + "serde", "wasm-bindgen", ] @@ -11117,6 +11374,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmtimer" version = "0.4.2" diff --git a/Cargo.toml b/Cargo.toml index f87ae531d..51c9b2cf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,8 @@ pem = { version = "3" } simple_asn1 = { version = "0.6" } k256 = { version = "0.13", features = ["ecdsa-core"]} solana-system-interface = { version = "1.0.0", features = ["bincode"] } +cdp-sdk = "0.1.0" +reqwest-middleware = { version = "0.4.2", default-features = false, features = ["json"] } [dev-dependencies] cargo-llvm-cov = "0.6" diff --git a/README.md b/README.md index 542e38c7a..911fe361b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ The repository includes several ready-to-use examples to help you get started wi | [`evm-turnkey-signer`](./examples/evm-turnkey-signer/) | Using Turnkey Signer for EVM secure signing | | [`solana-turnkey-signer`](./examples/solana-turnkey-signer/) | Using Turnkey Signer for Solana secure signing | | [`solana-google-cloud-kms-signer`](./examples/solana-google-cloud-kms-signer/) | Using Google Cloud KMS Signer for Solana secure signing | +| [`evm-cdp-signer`](./examples/evm-cdp-signer/) | Using CDP Signer for EVM secure signing | | [`network-configuration-config-file`](./examples/network-configuration-config-file/) | Using Custom network configuration via config file | | [`network-configuration-json-file`](./examples/network-configuration-json-file/) | Using Custom network configuration via json file | diff --git a/docs/modules/ROOT/pages/configuration.adoc b/docs/modules/ROOT/pages/configuration.adoc index 54d239ac7..dd0f075ed 100644 --- a/docs/modules/ROOT/pages/configuration.adoc +++ b/docs/modules/ROOT/pages/configuration.adoc @@ -231,6 +231,7 @@ For comprehensive details on configuring all supported signer types including: - HashiCorp Vault (secret and transit) - Cloud KMS providers (Google Cloud, AWS) - Turnkey signers +- CDP signers - Security best practices and troubleshooting See the dedicated xref:signers.adoc[Signers Configuration] guide. diff --git a/docs/modules/ROOT/pages/evm.adoc b/docs/modules/ROOT/pages/evm.adoc index eb6866d7f..11d159bbc 100644 --- a/docs/modules/ROOT/pages/evm.adoc +++ b/docs/modules/ROOT/pages/evm.adoc @@ -37,12 +37,13 @@ For detailed network configuration options, see the xref:network_configuration.a - `turnkey` (hosted Turnkey signer) - `google_cloud_kms` (Google Cloud KMS) - `aws_kms` (Amazon AWS KMS) +- `cdp` (hosted Coinbase Developer Platform signer) For detailed signer configuration options, see the xref:signers.adoc[Signers] guide. [NOTE] ==== -In production systems, hosted signers (AWS KMS, Google Cloud KMS, Turnkey) are recommended for the best security model. +In production systems, hosted signers (AWS KMS, Google Cloud KMS, Turnkey, CDP) are recommended for the best security model. ==== == Quickstart @@ -180,16 +181,14 @@ Common endpoints: [source,bash] ---- -curl --location --request POST 'http://localhost:8080/api/v1/relayers/solana-example/transactions' \ +curl --location --request POST 'http://localhost:8080/api/v1/relayers/sepolia-example/transactions' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ - { "value": 1, "data": "0x", "to": "0xd9b55a2ba539031e3c18c9528b0dc3a7f603a93b", "speed": "average" - } }' ---- @@ -197,17 +196,15 @@ curl --location --request POST 'http://localhost:8080/api/v1/relayers/solana-exa [source,bash] ---- -curl --location --request POST 'http://localhost:8080/api/v1/relayers/solana-example/transactions' \ +curl --location --request POST 'http://localhost:8080/api/v1/relayers/sepolia-example/transactions' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ - { "value": 1, "data": "0x", "to": "0xd9b55a2ba539031e3c18c9528b0dc3a7f603a93b", "speed": "average", "gas_limit": 21000 - } }' ---- @@ -215,17 +212,15 @@ curl --location --request POST 'http://localhost:8080/api/v1/relayers/solana-exa [source,bash] ---- -curl --location --request POST 'http://localhost:8080/api/v1/relayers/solana-example/transactions' \ +curl --location --request POST 'http://localhost:8080/api/v1/relayers/sepolia-example/transactions' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ - { "value": 1, "data": "0x", "to": "0xd9b55a2ba539031e3c18c9528b0dc3a7f603a93b", "max_fee_per_gas": 30000000000, "max_priority_fee_per_gas": 20000000000, - } }' ---- @@ -233,16 +228,14 @@ curl --location --request POST 'http://localhost:8080/api/v1/relayers/solana-exa [source,bash] ---- -curl --location --request POST 'http://localhost:8080/api/v1/relayers/solana-example/transactions' \ +curl --location --request POST 'http://localhost:8080/api/v1/relayers/sepolia-example/transactions' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ - { "value": 1, "data": "0x", "to": "0xd9b55a2ba539031e3c18c9528b0dc3a7f603a93b", "gas_price": "12312313123" - } }' ---- @@ -250,7 +243,7 @@ curl --location --request POST 'http://localhost:8080/api/v1/relayers/solana-exa [source,bash] ---- -curl --location --request GET 'http://localhost:8080/api/v1/relayers/solana-example/transactions/' \ +curl --location --request GET 'http://localhost:8080/api/v1/relayers/sepolia-example/transactions/' \ --header 'Authorization: Bearer ' ---- diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index e89a82455..320e07172 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -231,6 +231,8 @@ For quick setup with various configurations, check the https://github.com/OpenZe * `evm-gcp-kms-signer`: Using Google Cloud KMS for EVM secure signing * `evm-turnkey-signer`: Using Turnkey for EVM secure signing * `solana-turnkey-signer`: Using Turnkey for Solana secure signing +* `evm-cdp-signer`: Using CDP for EVM secure signing +* `solana-cdp-signer`: Using CDP for Solana secure signing * `redis-storage`: Using Redis for Storage * `network-configuration-config-file`: Using Custom network configuration via config file * `network-configuration-json-file`: Using Custom network configuration via JSON file diff --git a/docs/modules/ROOT/pages/signers.adoc b/docs/modules/ROOT/pages/signers.adoc index 3d42e24f6..a57c741df 100644 --- a/docs/modules/ROOT/pages/signers.adoc +++ b/docs/modules/ROOT/pages/signers.adoc @@ -37,6 +37,7 @@ OpenZeppelin Relayer supports the following signer types: - `turnkey`: Turnkey signer - `google_cloud_kms`: Google Cloud KMS signer - `aws_kms`: Amazon AWS KMS signer +- `cdp`: Coinbase Developer Platform signer == Network Compatibility Matrix @@ -75,6 +76,11 @@ The following table shows which signer types are compatible with each network ty |✅ Supported |❌ Not supported |❌ Not supported + +|`cdp` +|✅ Supported +|✅ Supported +|❌ Not supported |=== [NOTE] @@ -459,6 +465,60 @@ Configuration fields: | ID of the key in AWS KMS (can be key ID, key ARN, alias name, or alias ARN) |=== +== CDP Signer Configuration + +Uses CDP's secure key management infrastructure. + +[source,json] +---- +{ + "id": "cdp-signer", + "type": "cdp", + "config": { + "api_key_id": "your-cdp-api-key-id", + "api_key_secret": { + "type": "env", + "value": "CDP_API_KEY_SECRET" + }, + "wallet_secret": { + "type": "env", + "value": "CDP_WALLET_SECRET" + }, + "account_address": "your-cdp-evm-or-solana-account-address" + } +} +---- + +Configuration fields: +[cols="1,1,2"] +|=== +|Field |Type |Description + +| api_key_id +| String +| The Key ID of a Secret API Key. Used for authentication to the CDP signing service + +| api_key_secret.type +| String +| Type of value source (`env` or `plain`) + +| api_key_secret.value +| String +| The API key secret or environment variable name containing it. Used with the Key ID to authenticate API requests + +| wallet_secret.type +| String +| Type of value source (`env` or `plain`) + +| wallet_secret.value +| String +| The Wallet Secret or environment variable name containing it. Used to authorize API requests for signing operations. + +| account_address +| String +| The address of the CDP EVM EOA or CDP Solana Account used for signing operations. +|=== + == Security Best Practices === File Permissions diff --git a/docs/modules/ROOT/pages/solana.adoc b/docs/modules/ROOT/pages/solana.adoc index 294f754a7..21f53710e 100644 --- a/docs/modules/ROOT/pages/solana.adoc +++ b/docs/modules/ROOT/pages/solana.adoc @@ -64,6 +64,7 @@ For detailed network configuration options, see the xref:network_configuration.a - `google_cloud_kms` (hosted) - `local` (local) - `vault` (local) +- `cdp` (hosted) [NOTE] ==== diff --git a/docs/openapi.json b/docs/openapi.json index 3f2aac918..470a58a0e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -6286,6 +6286,9 @@ }, { "$ref": "#/components/schemas/GoogleCloudKmsSignerRequestConfig" + }, + { + "$ref": "#/components/schemas/CDPSignerRequestConfig" } ], "description": "Signer configuration enum for API requests (without type discriminator)" @@ -6405,6 +6408,23 @@ } } }, + { + "type": "object", + "required": [ + "api_key_id", + "account_address" + ], + "properties": { + "api_key_id": { + "type": "string" + }, + "account_address": { + "type": "string" + } + }, + "description": "Non-secret CDP signer details.", + "additionalProperties": false + }, { "type": "object" } @@ -6465,7 +6485,8 @@ "google_cloud_kms", "vault", "vault_transit", - "turnkey" + "turnkey", + "cdp" ] }, "SignerTypeRequest": { @@ -6477,7 +6498,8 @@ "vault", "vault_transit", "turnkey", - "google_cloud_kms" + "google_cloud_kms", + "cdp" ] }, "SignerUpdateRequest": { @@ -7245,6 +7267,39 @@ }, "additionalProperties": false }, + "CDPSignerRequestConfig": { + "type": "object", + "description": "CDP signer configuration for API requests", + "required": [ + "api_key_id", + "api_key_secret", + "wallet_secret", + "account_address" + ], + "properties": { + "api_key_id": { + "type": "string", + "minLength": 1 + }, + "api_key_secret": { + "type": "string", + "format": "password", + "writeOnly": true, + "minLength": 1 + }, + "wallet_secret": { + "type": "string", + "format": "password", + "writeOnly": true, + "minLength": 1 + }, + "account_address": { + "type": "string", + "pattern": "^((0x[0-9a-fA-F]{40})|([1-9A-HJ-NP-Za-km-z]{32,44}))$" + } + }, + "additionalProperties": false + }, "UpdateRelayerRequest": { "type": "object", "properties": { diff --git a/examples/evm-cdp-signer/.env.example b/examples/evm-cdp-signer/.env.example new file mode 100644 index 000000000..73a34580a --- /dev/null +++ b/examples/evm-cdp-signer/.env.example @@ -0,0 +1,5 @@ +API_KEY= +CDP_API_KEY_SECRET= +CDP_WALLET_SECRET= +REDIS_URL=redis://redis:6379 +WEBHOOK_SIGNING_KEY= diff --git a/examples/evm-cdp-signer/README.md b/examples/evm-cdp-signer/README.md new file mode 100644 index 000000000..4ed2e1762 --- /dev/null +++ b/examples/evm-cdp-signer/README.md @@ -0,0 +1,156 @@ +# Using CDP for Secure Transaction Signing in OpenZeppelin Relayer + +This example demonstrates how to use CDP (Coinbase Developer Platform) Wallet to securely sign transactions in OpenZeppelin Relayer. + +## Prerequisites + +1. A CDP account - [Sign up here](https://portal.cdp.coinbase.com/) +1. Rust and Cargo installed +1. Git +1. [Docker](https://docs.docker.com/get-docker/) +1. [Docker Compose](https://docs.docker.com/compose/install/) + +## Getting Started + +### Step 1: Clone the Repository + +Clone this repository to your local machine: + +```bash +git clone https://github.com/OpenZeppelin/openzeppelin-relayer +cd openzeppelin-relayer +``` + +### Step 2: Set Up Your CDP Account + +1. Log in to [CDP Portal](https://portal.cdp.coinbase.com/) +1. Create a new project if you haven't already +1. Note down your project details - you'll need these later + +### Step 3: Create API Credentials + +1. Go to the API Keys section in your project dashboard. +1. Create a new Secret API Key. +1. Save both the API Key ID and Secret - you'll need these for configuration. +1. Note: The API Key Secret is only shown once, make sure to save it securely. + +### Step 4: Create a Wallet + +1. In CDP Portal, go to the Wallets tab, then Server Wallets. +1. Generate a new Wallet Secret if needed. +1. Create a new EVM EOA Wallet using either our [REST API](https://docs.cdp.coinbase.com/api-reference/v2/rest-api/evm-accounts/create-an-evm-account) or an official [CDP SDK](https://github.com/coinbase/cdp-sdk) +1. Note down the following details: + - EVM Account Address + +### Step 5: Configure the Relayer Service + +Create an environment file by copying the example: + +```bash +cp examples/evm-cdp-signer/.env.example examples/evm-cdp-signer/.env +``` + +#### Populate CDP API Credentials + +Edit the `.env` file and update the following variables: + +```env +CDP_API_KEY_SECRET=your_api_key_secret +CDP_WALLET_SECRET=your_wallet_secret +``` + +#### Populate CDP config + +Edit the `config.json` file and update the following variables: + +```json +{ + "signers": [ + { + "id": "cdp-signer-evm", + "type": "cdp", + "config": { + "api_key_id": "YOUR_API_KEY_ID", + "api_key_secret": { + "type": "env", + "value": "CDP_API_KEY_SECRET" + }, + "wallet_secret": { + "type": "env", + "value": "CDP_WALLET_SECRET" + }, + "account_address": "0xYOUR_EVM_ACCOUNT_ADDRESS" + } + } + ] +} +``` + +#### Generate Security Keys + +Generate random keys for API authentication and webhook signing: + +```bash +# Generate API key +cargo run --example generate_uuid + +# Generate webhook signing key +cargo run --example generate_uuid +``` + +Add these to your `.env` file: + +```env +WEBHOOK_SIGNING_KEY=generated_webhook_key +API_KEY=generated_api_key +``` + +#### Configure Webhook URL + +Update the `examples/evm-cdp-signer/config/config.json` file with your webhook configuration: + +1. For testing, get a webhook URL from [Webhook.site](https://webhook.site) +2. Update the config file: + +```json +{ + "notifications": [ + { + "url": "your_webhook_url" + } + ] +} +``` + +### Step 6: Run the Service + +Start the service with Docker Compose: + +```bash +docker compose -f examples/evm-cdp-signer/docker-compose.yaml up +``` + +### Step 7: Test the Service + +1. The service exposes a REST API +2. You can test it using curl or any HTTP client: + +```bash +curl -X GET http://localhost:8080/api/v1/relayers \ + -H "Content-Type: application/json" \ + -H "AUTHORIZATION: Bearer $API_KEY" +``` + +### Troubleshooting + +If you encounter issues: + +1. Verify your CDP credentials are correct +2. Check the service logs for detailed error messages +3. Verify the transaction format matches the expected schema +4. Ensure your CDP wallet has sufficient permissions for signing + +### Additional Resources + +- [CDP Documentation](https://docs.cdp.coinbase.com/) +- [OpenZeppelin Relayer Documentation](https://docs.openzeppelin.com/relayer) diff --git a/examples/evm-cdp-signer/config/config.json b/examples/evm-cdp-signer/config/config.json new file mode 100644 index 000000000..f24afbfa4 --- /dev/null +++ b/examples/evm-cdp-signer/config/config.json @@ -0,0 +1,47 @@ +{ + "relayers": [ + { + "id": "sepolia-example", + "name": "Sepolia Example", + "network": "sepolia", + "paused": false, + "notification_id": "notification-example", + "signer_id": "cdp-signer-evm", + "network_type": "evm", + "policies": { + "min_balance": 0 + } + } + ], + "notifications": [ + { + "id": "notification-example", + "type": "webhook", + "url": "", + "signing_key": { + "type": "env", + "value": "WEBHOOK_SIGNING_KEY" + } + } + ], + "signers": [ + { + "id": "cdp-signer-evm", + "type": "cdp", + "config": { + "api_key_id": "", + "api_key_secret": { + "type": "env", + "value": "CDP_API_KEY_SECRET" + }, + "wallet_secret": { + "type": "env", + "value": "CDP_WALLET_SECRET" + }, + "account_address": "" + } + } + ], + "networks": "./config/networks", + "plugins": [] +} diff --git a/examples/evm-cdp-signer/docker-compose.yaml b/examples/evm-cdp-signer/docker-compose.yaml new file mode 100644 index 000000000..a48312caf --- /dev/null +++ b/examples/evm-cdp-signer/docker-compose.yaml @@ -0,0 +1,54 @@ +--- +services: + relayer: + build: + context: ../../ + dockerfile: Dockerfile.development + ports: + - 8080:8080/tcp + environment: + REDIS_URL: ${REDIS_URL} + RATE_LIMIT_REQUESTS_PER_SECOND: 10 + RATE_LIMIT_BURST: 50 + WEBHOOK_SIGNING_KEY: ${WEBHOOK_SIGNING_KEY} + API_KEY: ${API_KEY} + CDP_API_KEY_SECRET: ${CDP_API_KEY_SECRET} + CDP_WALLET_SECRET: ${CDP_WALLET_SECRET} + security_opt: + - no-new-privileges + networks: + - relayer-network + - metrics-network + volumes: + - ./config:/app/config/ + - ../../config/networks:/app/config/networks + depends_on: + - redis + restart: on-failure:5 + redis: + image: redis:bookworm + ports: + - 6379:6379/tcp + security_opt: + - no-new-privileges + volumes: + - redis_data:/data + command: + - redis-server + - --appendonly + - 'yes' + - --save + - '60' + - '1' + networks: + - relayer-network + - metrics-network + restart: on-failure:5 +networks: + metrics-network: + internal: true + relayer-network: + driver: bridge +volumes: + redis_data: + driver: local diff --git a/examples/solana-cdp-signer/.env.example b/examples/solana-cdp-signer/.env.example new file mode 100644 index 000000000..73a34580a --- /dev/null +++ b/examples/solana-cdp-signer/.env.example @@ -0,0 +1,5 @@ +API_KEY= +CDP_API_KEY_SECRET= +CDP_WALLET_SECRET= +REDIS_URL=redis://redis:6379 +WEBHOOK_SIGNING_KEY= diff --git a/examples/solana-cdp-signer/README.md b/examples/solana-cdp-signer/README.md new file mode 100644 index 000000000..a2e4250ff --- /dev/null +++ b/examples/solana-cdp-signer/README.md @@ -0,0 +1,156 @@ +# Using CDP for Secure Transaction Signing in OpenZeppelin Relayer + +This example demonstrates how to use CDP (Coinbase Developer Platform) Wallet to securely sign transactions in OpenZeppelin Relayer. + +## Prerequisites + +1. A CDP account - [Sign up here](https://portal.cdp.coinbase.com/) +1. Rust and Cargo installed +1. Git +1. [Docker](https://docs.docker.com/get-docker/) +1. [Docker Compose](https://docs.docker.com/compose/install/) + +## Getting Started + +### Step 1: Clone the Repository + +Clone this repository to your local machine: + +```bash +git clone https://github.com/OpenZeppelin/openzeppelin-relayer +cd openzeppelin-relayer +``` + +### Step 2: Set Up Your CDP Account + +1. Log in to [CDP Portal](https://portal.cdp.coinbase.com/) +1. Create a new project if you haven't already +1. Note down your project details - you'll need these later + +### Step 3: Create API Credentials + +1. Go to the API Keys section in your project dashboard. +1. Create a new Secret API Key. +1. Save both the API Key ID and Secret - you'll need these for configuration. +1. Note: The API Key Secret is only shown once, make sure to save it securely. + +### Step 4: Create a Wallet + +1. In CDP Portal, go to the Wallets tab, then Server Wallets. +1. Generate a new Wallet Secret if needed. +1. Create a new Solana Account using either our [REST API](https://docs.cdp.coinbase.com/api-reference/v2/rest-api/solana-accounts/create-a-solana-account) or an official [CDP SDK](https://github.com/coinbase/cdp-sdk) +1. Note down the following details: + - Solana Account Address + +### Step 5: Configure the Relayer Service + +Create an environment file by copying the example: + +```bash +cp examples/solana-cdp-signer/.env.example examples/solana-cdp-signer/.env +``` + +#### Populate CDP API Credentials + +Edit the `.env` file and update the following variables: + +```env +CDP_API_KEY_SECRET=your_api_key_secret +CDP_WALLET_SECRET=your_wallet_secret +``` + +#### Populate CDP config + +Edit the `config.json` file and update the following variables: + +```json +{ + "signers": [ + { + "id": "cdp-signer-solana", + "type": "cdp", + "config": { + "api_key_id": "YOUR_API_KEY_ID", + "api_key_secret": { + "type": "env", + "value": "CDP_API_KEY_SECRET" + }, + "wallet_secret": { + "type": "env", + "value": "CDP_WALLET_SECRET" + }, + "account_address": "YOUR_SOLANA_ACCOUNT_ADDRESS" + } + } + ] +} +``` + +#### Generate Security Keys + +Generate random keys for API authentication and webhook signing: + +```bash +# Generate API key +cargo run --example generate_uuid + +# Generate webhook signing key +cargo run --example generate_uuid +``` + +Add these to your `.env` file: + +```env +WEBHOOK_SIGNING_KEY=generated_webhook_key +API_KEY=generated_api_key +``` + +#### Configure Webhook URL + +Update the `examples/solana-cdp-signer/config/config.json` file with your webhook configuration: + +1. For testing, get a webhook URL from [Webhook.site](https://webhook.site) +2. Update the config file: + +```json +{ + "notifications": [ + { + "url": "your_webhook_url" + } + ] +} +``` + +### Step 6: Run the Service + +Start the service with Docker Compose: + +```bash +docker compose -f examples/solana-cdp-signer/docker-compose.yaml up +``` + +### Step 7: Test the Service + +1. The service exposes a REST API +2. You can test it using curl or any HTTP client: + +```bash +curl -X GET http://localhost:8080/api/v1/relayers \ + -H "Content-Type: application/json" \ + -H "AUTHORIZATION: Bearer $API_KEY" +``` + +### Troubleshooting + +If you encounter issues: + +1. Verify your CDP credentials are correct +2. Check the service logs for detailed error messages +3. Verify the transaction format matches the expected schema +4. Ensure your CDP wallet has sufficient permissions for signing + +### Additional Resources + +- [CDP Documentation](https://docs.cdp.coinbase.com/) +- [OpenZeppelin Relayer Documentation](https://docs.openzeppelin.com/relayer) diff --git a/examples/solana-cdp-signer/config/config.json b/examples/solana-cdp-signer/config/config.json new file mode 100644 index 000000000..1d7c5ff66 --- /dev/null +++ b/examples/solana-cdp-signer/config/config.json @@ -0,0 +1,43 @@ +{ + "relayers": [ + { + "id": "solana-example", + "name": "Solana Example", + "network": "devnet", + "paused": false, + "signer_id": "cdp-signer-solana", + "network_type": "solana" + } + ], + "notifications": [ + { + "id": "notification-example", + "type": "webhook", + "url": "", + "signing_key": { + "type": "env", + "value": "WEBHOOK_SIGNING_KEY" + } + } + ], + "signers": [ + { + "id": "cdp-signer-solana", + "type": "cdp", + "config": { + "api_key_id": "", + "api_key_secret": { + "type": "env", + "value": "CDP_API_KEY_SECRET" + }, + "wallet_secret": { + "type": "env", + "value": "CDP_WALLET_SECRET" + }, + "account_address": "" + } + } + ], + "networks": "./config/networks", + "plugins": [] +} diff --git a/examples/solana-cdp-signer/docker-compose.yaml b/examples/solana-cdp-signer/docker-compose.yaml new file mode 100644 index 000000000..a48312caf --- /dev/null +++ b/examples/solana-cdp-signer/docker-compose.yaml @@ -0,0 +1,54 @@ +--- +services: + relayer: + build: + context: ../../ + dockerfile: Dockerfile.development + ports: + - 8080:8080/tcp + environment: + REDIS_URL: ${REDIS_URL} + RATE_LIMIT_REQUESTS_PER_SECOND: 10 + RATE_LIMIT_BURST: 50 + WEBHOOK_SIGNING_KEY: ${WEBHOOK_SIGNING_KEY} + API_KEY: ${API_KEY} + CDP_API_KEY_SECRET: ${CDP_API_KEY_SECRET} + CDP_WALLET_SECRET: ${CDP_WALLET_SECRET} + security_opt: + - no-new-privileges + networks: + - relayer-network + - metrics-network + volumes: + - ./config:/app/config/ + - ../../config/networks:/app/config/networks + depends_on: + - redis + restart: on-failure:5 + redis: + image: redis:bookworm + ports: + - 6379:6379/tcp + security_opt: + - no-new-privileges + volumes: + - redis_data:/data + command: + - redis-server + - --appendonly + - 'yes' + - --save + - '60' + - '1' + networks: + - relayer-network + - metrics-network + restart: on-failure:5 +networks: + metrics-network: + internal: true + relayer-network: + driver: bridge +volumes: + redis_data: + driver: local diff --git a/src/models/error/signer.rs b/src/models/error/signer.rs index ee00976d6..7da0e0d7f 100644 --- a/src/models/error/signer.rs +++ b/src/models/error/signer.rs @@ -1,7 +1,7 @@ use serde::Serialize; use thiserror::Error; -use crate::services::{AwsKmsError, GoogleCloudKmsError, TurnkeyError, VaultError}; +use crate::services::{AwsKmsError, CdpError, GoogleCloudKmsError, TurnkeyError, VaultError}; use super::TransactionError; @@ -29,6 +29,9 @@ pub enum SignerError { #[error("Turnkey error: {0}")] TurnkeyError(#[from] TurnkeyError), + #[error("CDP error: {0}")] + CdpError(#[from] CdpError), + #[error("AWS KMS error: {0}")] AwsKmsError(#[from] AwsKmsError), diff --git a/src/models/signer/config.rs b/src/models/signer/config.rs index 9c489da81..da8ec13af 100644 --- a/src/models/signer/config.rs +++ b/src/models/signer/config.rs @@ -12,9 +12,9 @@ use crate::{ config::ConfigFileError, models::signer::{ - AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, - GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, Signer, SignerConfig, - TurnkeySignerConfig, VaultSignerConfig, VaultTransitSignerConfig, + AwsKmsSignerConfig, CdpSignerConfig, GoogleCloudKmsSignerConfig, + GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, + Signer, SignerConfig, TurnkeySignerConfig, VaultSignerConfig, VaultTransitSignerConfig, }, models::PlainOrEnvValue, }; @@ -46,6 +46,15 @@ pub struct TurnkeySignerFileConfig { pub public_key: String, } +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct CdpSignerFileConfig { + pub api_key_id: String, + pub api_key_secret: PlainOrEnvValue, + pub wallet_secret: PlainOrEnvValue, + pub account_address: String, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct VaultSignerFileConfig { @@ -143,6 +152,7 @@ pub enum SignerFileConfigEnum { #[serde(rename = "aws_kms")] AwsKms(AwsKmsSignerFileConfig), Turnkey(TurnkeySignerFileConfig), + Cdp(CdpSignerFileConfig), Vault(VaultSignerFileConfig), #[serde(rename = "vault_transit")] VaultTransit(VaultTransitSignerFileConfig), @@ -274,6 +284,27 @@ impl TryFrom for TurnkeySignerConfig { } } +impl TryFrom for CdpSignerConfig { + type Error = ConfigFileError; + + fn try_from(config: CdpSignerFileConfig) -> Result { + let api_key_secret = config.api_key_secret.get_value().map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get API key secret: {}", e)) + })?; + + let wallet_secret = config.wallet_secret.get_value().map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get wallet secret: {}", e)) + })?; + + Ok(CdpSignerConfig { + api_key_id: config.api_key_id, + api_key_secret, + wallet_secret, + account_address: config.account_address, + }) + } +} + impl TryFrom for VaultSignerConfig { type Error = ConfigFileError; @@ -392,6 +423,9 @@ impl TryFrom for SignerConfig { SignerFileConfigEnum::Turnkey(turnkey) => Ok(SignerConfig::Turnkey( TurnkeySignerConfig::try_from(turnkey)?, )), + SignerFileConfigEnum::Cdp(cdp) => { + Ok(SignerConfig::Cdp(CdpSignerConfig::try_from(cdp)?)) + } SignerFileConfigEnum::Vault(vault) => { Ok(SignerConfig::Vault(VaultSignerConfig::try_from(vault)?)) } @@ -635,4 +669,69 @@ mod tests { assert_eq!(gcp_config.key.key_id, "test-key"); assert_eq!(gcp_config.service_account.project_id, "test-project"); } + + #[test] + fn test_cdp_file_config_conversion() { + use crate::models::SecretString; + let cfg = CdpSignerFileConfig { + api_key_id: "id".into(), + api_key_secret: PlainOrEnvValue::Plain { + value: SecretString::new("asecret"), + }, + wallet_secret: PlainOrEnvValue::Plain { + value: SecretString::new("wsecret"), + }, + account_address: "0x0000000000000000000000000000000000000000".into(), + }; + let res = CdpSignerConfig::try_from(cfg); + assert!(res.is_ok()); + let c = res.unwrap(); + assert_eq!(c.api_key_id, "id"); + assert_eq!( + c.account_address, + "0x0000000000000000000000000000000000000000" + ); + } + + #[test] + fn test_cdp_file_config_conversion_api_key_secret_error() { + let cfg = CdpSignerFileConfig { + api_key_id: "id".into(), + api_key_secret: PlainOrEnvValue::Env { + value: "NONEXISTENT_ENV_VAR".into(), + }, + wallet_secret: PlainOrEnvValue::Plain { + value: SecretString::new("wsecret"), + }, + account_address: "0x0000000000000000000000000000000000000000".into(), + }; + let res = CdpSignerConfig::try_from(cfg); + assert!(res.is_err()); + let err = res.unwrap_err(); + assert!(matches!(err, ConfigFileError::InvalidFormat(_))); + if let ConfigFileError::InvalidFormat(msg) = err { + assert!(msg.contains("Failed to get API key secret")); + } + } + + #[test] + fn test_cdp_file_config_conversion_wallet_secret_error() { + let cfg = CdpSignerFileConfig { + api_key_id: "id".into(), + api_key_secret: PlainOrEnvValue::Plain { + value: SecretString::new("asecret"), + }, + wallet_secret: PlainOrEnvValue::Env { + value: "NONEXISTENT_ENV_VAR".into(), + }, + account_address: "0x0000000000000000000000000000000000000000".into(), + }; + let res = CdpSignerConfig::try_from(cfg); + assert!(res.is_err()); + let err = res.unwrap_err(); + assert!(matches!(err, ConfigFileError::InvalidFormat(_))); + if let ConfigFileError::InvalidFormat(msg) = err { + assert!(msg.contains("Failed to get wallet secret")); + } + } } diff --git a/src/models/signer/mod.rs b/src/models/signer/mod.rs index f0609650a..b3ef7c8f2 100644 --- a/src/models/signer/mod.rs +++ b/src/models/signer/mod.rs @@ -28,9 +28,11 @@ pub use request::*; mod response; pub use response::*; -use crate::{constants::ID_REGEX, models::SecretString}; +use crate::{constants::ID_REGEX, models::SecretString, utils::base64_decode}; use secrets::SecretVec; use serde::{Deserialize, Serialize, Serializer}; +use solana_sdk::pubkey::Pubkey; +use std::str::FromStr; use utoipa::ToSchema; use validator::Validate; @@ -179,6 +181,26 @@ pub struct TurnkeySignerConfig { pub public_key: String, } +/// CDP signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +#[validate(schema(function = "validate_cdp_config"))] +pub struct CdpSignerConfig { + #[validate(length(min = 1, message = "API Key ID cannot be empty"))] + pub api_key_id: String, + #[validate(custom( + function = "validate_secret_string", + message = "API Key Secret cannot be empty" + ))] + pub api_key_secret: SecretString, + #[validate(custom( + function = "validate_secret_string", + message = "API Wallet Secret cannot be empty" + ))] + pub wallet_secret: SecretString, + #[validate(length(min = 1, message = "Account address cannot be empty"))] + pub account_address: String, +} + /// Google Cloud KMS service account configuration #[derive(Debug, Clone, Serialize, Deserialize, Validate)] pub struct GoogleCloudKmsSignerServiceAccountConfig { @@ -240,6 +262,60 @@ fn validate_secret_string(secret: &SecretString) -> Result<(), validator::Valida Ok(()) } +/// Custom validator for CDP signer configuration +fn validate_cdp_config(config: &CdpSignerConfig) -> Result<(), validator::ValidationError> { + // Validate api_key_secret is valid base64 + let api_key_valid = config + .api_key_secret + .as_str(|secret_str| base64_decode(secret_str).is_ok()); + if !api_key_valid { + let mut error = validator::ValidationError::new("invalid_base64_api_key_secret"); + error.message = Some("API Key Secret is not valid base64".into()); + return Err(error); + } + + // Validate wallet_secret is valid base64 + let wallet_secret_valid = config + .wallet_secret + .as_str(|secret_str| base64_decode(secret_str).is_ok()); + if !wallet_secret_valid { + let mut error = validator::ValidationError::new("invalid_base64_wallet_secret"); + error.message = Some("Wallet Secret is not valid base64".into()); + return Err(error); + } + + let addr = &config.account_address; + + // Check if it's an EVM address (0x-prefixed hex) + if addr.starts_with("0x") { + if addr.len() != 42 { + let mut error = validator::ValidationError::new("invalid_evm_address_format"); + error.message = Some( + "EVM account address must be a valid 0x-prefixed 40-character hex string".into(), + ); + return Err(error); + } + + // Check if the hex part is valid + if let Some(end) = addr.strip_prefix("0x") { + if !end.chars().all(|c| c.is_ascii_hexdigit()) { + let mut error = validator::ValidationError::new("invalid_evm_address_hex"); + error.message = Some("EVM account address contains invalid hex characters".into()); + return Err(error); + } + } + } else { + // Assume it's a Solana address - validate using Pubkey::from_str + if Pubkey::from_str(addr).is_err() { + let mut error = validator::ValidationError::new("invalid_solana_address"); + error.message = Some("Invalid Solana account address format".into()); + return Err(error); + } + } + + Ok(()) +} + /// Domain signer configuration enum containing all supported signer types #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SignerConfig { @@ -248,6 +324,7 @@ pub enum SignerConfig { VaultTransit(VaultTransitSignerConfig), AwsKms(AwsKmsSignerConfig), Turnkey(TurnkeySignerConfig), + Cdp(CdpSignerConfig), GoogleCloudKms(GoogleCloudKmsSignerConfig), } @@ -280,6 +357,12 @@ impl SignerConfig { format_validation_errors(&e) )) }), + Self::Cdp(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "CDP validation failed: {}", + format_validation_errors(&e) + )) + }), Self::GoogleCloudKms(config) => Validate::validate(config).map_err(|e| { SignerValidationError::InvalidConfig(format!( "Google Cloud KMS validation failed: {}", @@ -329,6 +412,14 @@ impl SignerConfig { } } + /// Get CDP signer config if this is a CDP signer + pub fn get_cdp(&self) -> Option<&CdpSignerConfig> { + match self { + Self::Cdp(config) => Some(config), + _ => None, + } + } + /// Get Google Cloud KMS signer config if this is a Google Cloud KMS signer pub fn get_google_cloud_kms(&self) -> Option<&GoogleCloudKmsSignerConfig> { match self { @@ -345,6 +436,7 @@ impl SignerConfig { Self::Vault(_) => SignerType::Vault, Self::VaultTransit(_) => SignerType::VaultTransit, Self::Turnkey(_) => SignerType::Turnkey, + Self::Cdp(_) => SignerType::Cdp, Self::GoogleCloudKms(_) => SignerType::GoogleCloudKms, } } @@ -399,6 +491,7 @@ pub enum SignerType { #[serde(rename = "vault_transit")] VaultTransit, Turnkey, + Cdp, } impl Signer { @@ -863,4 +956,170 @@ mod tests { assert!(config.get_google_cloud_kms().is_some()); assert!(config.get_local().is_none()); } + + #[test] + fn test_valid_cdp_signer_with_evm_address() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("c2VjcmV0"), // Valid base64: "secret" + wallet_secret: SecretString::new("d2FsbGV0LXNlY3JldA=="), // Valid base64: "wallet-secret" + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::Cdp); + } + + #[test] + fn test_valid_cdp_signer_with_solana_address() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("c2VjcmV0"), // Valid base64: "secret" + wallet_secret: SecretString::new("d2FsbGV0LXNlY3JldA=="), // Valid base64: "wallet-secret" + account_address: "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2".to_string(), + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::Cdp); + } + + #[test] + fn test_invalid_cdp_signer_empty_address() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("c2VjcmV0"), // Valid base64: "secret" + wallet_secret: SecretString::new("d2FsbGV0LXNlY3JldA=="), // Valid base64: "wallet-secret" + account_address: "".to_string(), + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Account address cannot be empty")); + } else { + panic!("Expected InvalidConfig error for empty address"); + } + } + + #[test] + fn test_invalid_cdp_signer_bad_evm_address() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("c2VjcmV0"), // Valid base64: "secret" + wallet_secret: SecretString::new("d2FsbGV0LXNlY3JldA=="), // Valid base64: "wallet-secret" + account_address: "0xinvalid-address".to_string(), + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("EVM account address must be a valid 0x-prefixed")); + } else { + panic!("Expected InvalidConfig error for bad EVM address"); + } + } + + #[test] + fn test_invalid_cdp_signer_bad_solana_address() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("c2VjcmV0"), // Valid base64: "secret" + wallet_secret: SecretString::new("d2FsbGV0LXNlY3JldA=="), // Valid base64: "wallet-secret" + account_address: "invalid".to_string(), + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Invalid Solana account address format")); + } else { + panic!("Expected InvalidConfig error for bad Solana address"); + } + } + + #[test] + fn test_invalid_cdp_signer_evm_address_wrong_format() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("c2VjcmV0"), // Valid base64: "secret" + wallet_secret: SecretString::new("d2FsbGV0LXNlY3JldA=="), // Valid base64: "wallet-secret" + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44".to_string(), // Too short + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("EVM account address must be a valid 0x-prefixed")); + } else { + panic!("Expected InvalidConfig error for wrong EVM address format"); + } + } + + #[test] + fn test_invalid_cdp_signer_solana_address_wrong_charset() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("c2VjcmV0"), // Valid base64: "secret" + wallet_secret: SecretString::new("d2FsbGV0LXNlY3JldA=="), // Valid base64: "wallet-secret" + account_address: "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm0".to_string(), // Contains '0' which is invalid in Base58 + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Invalid Solana account address format")); + } else { + panic!("Expected InvalidConfig error for wrong Solana address charset"); + } + } + + #[test] + fn test_invalid_cdp_signer_invalid_base64_api_key_secret() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("invalid-base64!@#"), // Invalid base64 + wallet_secret: SecretString::new("dGVzdC13YWxsZXQtc2VjcmV0"), // Valid base64: "test-wallet-secret" + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("API Key Secret is not valid base64")); + } else { + panic!("Expected InvalidConfig error for invalid base64 API key secret"); + } + } + + #[test] + fn test_invalid_cdp_signer_invalid_base64_wallet_secret() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("dGVzdC1hcGkta2V5LXNlY3JldA=="), // Valid base64: "test-api-key-secret" + wallet_secret: SecretString::new("invalid-base64!@#"), // Invalid base64 + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Wallet Secret is not valid base64")); + } else { + panic!("Expected InvalidConfig error for invalid base64 wallet secret"); + } + } + + #[test] + fn test_valid_cdp_signer_with_valid_base64_secrets() { + let config = CdpSignerConfig { + api_key_id: "test-api-key".to_string(), + api_key_secret: SecretString::new("dGVzdC1hcGkta2V5LXNlY3JldA=="), // Valid base64: "test-api-key-secret" + wallet_secret: SecretString::new("dGVzdC13YWxsZXQtc2VjcmV0"), // Valid base64: "test-wallet-secret" + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + }; + let signer = Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(config)); + let result = signer.validate(); + assert!(result.is_ok()); + assert_eq!(signer.signer_type(), SignerType::Cdp); + } } diff --git a/src/models/signer/repository.rs b/src/models/signer/repository.rs index 35b1f2dfb..062b8f2bd 100644 --- a/src/models/signer/repository.rs +++ b/src/models/signer/repository.rs @@ -13,10 +13,10 @@ use crate::{ models::{ signer::{ - AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, - GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, Signer, SignerConfig, - SignerValidationError, TurnkeySignerConfig, VaultSignerConfig, - VaultTransitSignerConfig, + AwsKmsSignerConfig, CdpSignerConfig, GoogleCloudKmsSignerConfig, + GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, + LocalSignerConfig, Signer, SignerConfig, SignerValidationError, TurnkeySignerConfig, + VaultSignerConfig, VaultTransitSignerConfig, }, SecretString, }, @@ -41,6 +41,7 @@ pub enum SignerConfigStorage { VaultTransit(VaultTransitSignerConfigStorage), AwsKms(AwsKmsSignerConfigStorage), Turnkey(TurnkeySignerConfigStorage), + Cdp(CdpSignerConfigStorage), GoogleCloudKms(GoogleCloudKmsSignerConfigStorage), } @@ -127,6 +128,22 @@ pub struct TurnkeySignerConfigStorage { pub public_key: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CdpSignerConfigStorage { + pub api_key_id: String, + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub api_key_secret: SecretString, + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub wallet_secret: SecretString, + pub account_address: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GoogleCloudKmsSignerServiceAccountConfigStorage { #[serde( @@ -283,6 +300,28 @@ impl From for TurnkeySignerConfig { } } +impl From for CdpSignerConfigStorage { + fn from(config: CdpSignerConfig) -> Self { + Self { + api_key_id: config.api_key_id, + api_key_secret: config.api_key_secret, + wallet_secret: config.wallet_secret, + account_address: config.account_address, + } + } +} + +impl From for CdpSignerConfig { + fn from(storage: CdpSignerConfigStorage) -> Self { + Self { + api_key_id: storage.api_key_id, + api_key_secret: storage.api_key_secret, + wallet_secret: storage.wallet_secret, + account_address: storage.account_address, + } + } +} + impl From for GoogleCloudKmsSignerConfigStorage { fn from(config: GoogleCloudKmsSignerConfig) -> Self { Self { @@ -379,6 +418,7 @@ impl From for SignerConfigStorage { } SignerConfig::AwsKms(aws_kms) => SignerConfigStorage::AwsKms(aws_kms.into()), SignerConfig::Turnkey(turnkey) => SignerConfigStorage::Turnkey(turnkey.into()), + SignerConfig::Cdp(cdp) => SignerConfigStorage::Cdp(cdp.into()), SignerConfig::GoogleCloudKms(gcp) => SignerConfigStorage::GoogleCloudKms(gcp.into()), } } @@ -394,6 +434,7 @@ impl From for SignerConfig { } SignerConfigStorage::AwsKms(aws_kms) => SignerConfig::AwsKms(aws_kms.into()), SignerConfigStorage::Turnkey(turnkey) => SignerConfig::Turnkey(turnkey.into()), + SignerConfigStorage::Cdp(cdp) => SignerConfig::Cdp(cdp.into()), SignerConfigStorage::GoogleCloudKms(gcp) => SignerConfig::GoogleCloudKms(gcp.into()), } } @@ -432,6 +473,14 @@ impl SignerConfigStorage { } } + /// Get CDP signer config, returns error if not a CDP signer + pub fn get_cdp(&self) -> Option<&CdpSignerConfigStorage> { + match self { + Self::Cdp(config) => Some(config), + _ => None, + } + } + /// Get google cloud kms signer config, returns error if not a google cloud kms signer pub fn get_google_cloud_kms(&self) -> Option<&GoogleCloudKmsSignerConfigStorage> { match self { @@ -523,4 +572,62 @@ mod tests { let converted_data = converted_back.raw_key.borrow(); assert_eq!(*original_data, *converted_data); } + + #[test] + fn test_cdp_config_storage_conversion() { + use crate::models::SecretString; + + let domain_config = CdpSignerConfig { + api_key_id: "test-api-key-id".to_string(), + api_key_secret: SecretString::new("test-api-secret"), + wallet_secret: SecretString::new("test-wallet-secret"), + account_address: "0x1234567890123456789012345678901234567890".to_string(), + }; + + let storage_config = CdpSignerConfigStorage::from(domain_config.clone()); + let converted_back = CdpSignerConfig::from(storage_config); + + assert_eq!(domain_config.api_key_id, converted_back.api_key_id); + assert_eq!( + domain_config.account_address, + converted_back.account_address + ); + assert_eq!( + domain_config.api_key_secret.to_str(), + converted_back.api_key_secret.to_str() + ); + assert_eq!( + domain_config.wallet_secret.to_str(), + converted_back.wallet_secret.to_str() + ); + } + + #[test] + fn test_signer_config_storage_get_cdp() { + use crate::models::SecretString; + + let cdp_storage = CdpSignerConfigStorage { + api_key_id: "test-id".to_string(), + api_key_secret: SecretString::new("secret"), + wallet_secret: SecretString::new("wallet-secret"), + account_address: "0x1234567890123456789012345678901234567890".to_string(), + }; + + let config_storage = SignerConfigStorage::Cdp(cdp_storage); + let retrieved_cdp = config_storage.get_cdp(); + assert!(retrieved_cdp.is_some()); + assert_eq!(retrieved_cdp.unwrap().api_key_id, "test-id"); + } + + #[test] + fn test_signer_config_storage_get_cdp_from_non_cdp() { + let aws_storage = AwsKmsSignerConfigStorage { + region: Some("us-east-1".to_string()), + key_id: "test-key".to_string(), + }; + + let config_storage = SignerConfigStorage::AwsKms(aws_storage); + let retrieved_cdp = config_storage.get_cdp(); + assert!(retrieved_cdp.is_none()); + } } diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs index 6ee7942d5..e6b62aecf 100644 --- a/src/models/signer/request.rs +++ b/src/models/signer/request.rs @@ -10,9 +10,10 @@ //! all input is properly validated before reaching the core business logic. use crate::models::{ - ApiError, AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, - GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, SecretString, Signer, - SignerConfig, TurnkeySignerConfig, VaultSignerConfig, VaultTransitSignerConfig, + ApiError, AwsKmsSignerConfig, CdpSignerConfig, GoogleCloudKmsSignerConfig, + GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, + SecretString, Signer, SignerConfig, TurnkeySignerConfig, VaultSignerConfig, + VaultTransitSignerConfig, }; use secrets::SecretVec; use serde::{Deserialize, Serialize}; @@ -108,6 +109,16 @@ pub struct GoogleCloudKmsSignerRequestConfig { pub key: GoogleCloudKmsSignerKeyRequestConfig, } +/// CDP signer configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] +pub struct CdpSignerRequestConfig { + pub api_key_id: String, + pub api_key_secret: String, + pub wallet_secret: String, + pub account_address: String, +} + /// Signer configuration enum for API requests (without type discriminator) #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] #[serde(untagged)] @@ -117,6 +128,7 @@ pub enum SignerConfigRequest { Vault(VaultSignerRequestConfig), VaultTransit(VaultTransitSignerRequestConfig), Turnkey(TurnkeySignerRequestConfig), + Cdp(CdpSignerRequestConfig), GoogleCloudKms(GoogleCloudKmsSignerRequestConfig), } @@ -132,6 +144,7 @@ pub enum SignerTypeRequest { #[serde(rename = "vault_transit")] VaultTransit, Turnkey, + Cdp, #[serde(rename = "google_cloud_kms")] GoogleCloudKms, } @@ -242,6 +255,12 @@ impl TryFrom for SignerConfig { public_key: turnkey_config.public_key, }) } + SignerConfigRequest::Cdp(cdp_config) => SignerConfig::Cdp(CdpSignerConfig { + api_key_id: cdp_config.api_key_id, + api_key_secret: SecretString::new(&cdp_config.api_key_secret), + wallet_secret: SecretString::new(&cdp_config.wallet_secret), + account_address: cdp_config.account_address, + }), SignerConfigRequest::GoogleCloudKms(gcp_kms_config) => { SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { service_account: gcp_kms_config.service_account.into(), @@ -277,6 +296,7 @@ impl TryFrom for Signer { SignerConfigRequest::VaultTransit(_) ) | (SignerTypeRequest::Turnkey, SignerConfigRequest::Turnkey(_)) + | (SignerTypeRequest::Cdp, SignerConfigRequest::Cdp(_)) | ( SignerTypeRequest::GoogleCloudKms, SignerConfigRequest::GoogleCloudKms(_) @@ -860,4 +880,93 @@ mod tests { assert!(msg.contains("API public key cannot be empty")); } } + + #[test] + fn test_json_deserialization_cdp_signer() { + let json = r#"{ + "id": "test-cdp-signer", + "type": "cdp", + "config": { + "api_key_id": "test-api-key-id", + "api_key_secret": "dGVzdC1hcGkta2V5LXNlY3JldA==", + "wallet_secret": "dGVzdC13YWxsZXQtc2VjcmV0", + "account_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44f" + } + }"#; + + let result: Result = serde_json::from_str(json); + + assert!( + result.is_ok(), + "Failed to deserialize CDP signer: {:?}", + result.err() + ); + + let request = result.unwrap(); + assert_eq!(request.id, Some("test-cdp-signer".to_string())); + + match request.config { + SignerConfigRequest::Cdp(cdp_config) => { + assert_eq!(cdp_config.api_key_id, "test-api-key-id"); + assert_eq!(cdp_config.api_key_secret, "dGVzdC1hcGkta2V5LXNlY3JldA=="); + assert_eq!(cdp_config.wallet_secret, "dGVzdC13YWxsZXQtc2VjcmV0"); + assert_eq!( + cdp_config.account_address, + "0x742d35Cc6634C0532925a3b844Bc454e4438f44f" + ); + } + _ => panic!("Expected CDP config variant"), + } + } + + #[test] + fn test_valid_cdp_create_request() { + let request = SignerCreateRequest { + id: Some("test-cdp-signer".to_string()), + signer_type: SignerTypeRequest::Cdp, + config: SignerConfigRequest::Cdp(CdpSignerRequestConfig { + api_key_id: "test-api-key-id".to_string(), + api_key_secret: "dGVzdC1hcGkta2V5LXNlY3JldA==".to_string(), // Valid base64: "test-api-key-secret" + wallet_secret: "dGVzdC13YWxsZXQtc2VjcmV0".to_string(), // Valid base64: "test-wallet-secret" + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + }), + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert_eq!(signer.id, "test-cdp-signer"); + assert_eq!(signer.signer_type(), SignerType::Cdp); + + if let Some(cdp_config) = signer.config.get_cdp() { + assert_eq!(cdp_config.api_key_id, "test-api-key-id"); + assert_eq!( + cdp_config.account_address, + "0x742d35Cc6634C0532925a3b844Bc454e4438f44f" + ); + } else { + panic!("Expected CDP config"); + } + } + + #[test] + fn test_invalid_cdp_empty_api_key_id() { + let request = SignerCreateRequest { + id: Some("test-signer".to_string()), + signer_type: SignerTypeRequest::Cdp, + config: SignerConfigRequest::Cdp(CdpSignerRequestConfig { + api_key_id: "".to_string(), // Empty + api_key_secret: "dGVzdC1hcGkta2V5LXNlY3JldA==".to_string(), // Valid base64: "test-api-key-secret" + wallet_secret: "dGVzdC13YWxsZXQtc2VjcmV0".to_string(), // Valid base64: "test-wallet-secret" + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + }), + }; + + let result = Signer::try_from(request); + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("API Key ID cannot be empty")); + } + } } diff --git a/src/models/signer/response.rs b/src/models/signer/response.rs index 5e195e9d8..b14a82d01 100644 --- a/src/models/signer/response.rs +++ b/src/models/signer/response.rs @@ -50,6 +50,12 @@ pub enum SignerConfigResponse { public_key: String, // api_private_key: Option, hidden from response due to security concerns }, + Cdp { + api_key_id: String, + account_address: String, + // api_key_secret: SecretString, hidden from response due to security concerns + // wallet_secret: SecretString, hidden from response due to security concerns + }, #[serde(rename = "google_cloud_kms")] GoogleCloudKms { service_account: GoogleCloudKmsSignerServiceAccountResponseConfig, @@ -107,6 +113,10 @@ impl From for SignerConfigResponse { private_key_id: c.private_key_id, public_key: c.public_key, }, + SignerConfig::Cdp(c) => SignerConfigResponse::Cdp { + api_key_id: c.api_key_id, + account_address: c.account_address, + }, SignerConfig::GoogleCloudKms(c) => SignerConfigResponse::GoogleCloudKms { service_account: GoogleCloudKmsSignerServiceAccountResponseConfig { project_id: c.service_account.project_id, @@ -292,4 +302,77 @@ mod tests { let response: SignerResponse = serde_json::from_str(json).unwrap(); assert_eq!(response.r#type, SignerType::GoogleCloudKms); } + + #[test] + fn test_cdp_signer_response_conversion() { + use crate::models::signer::{CdpSignerConfig, SignerConfig}; + use crate::models::SecretString; + + let cdp_config = CdpSignerConfig { + api_key_id: "test-api-key-id".to_string(), + api_key_secret: SecretString::new("secret"), + wallet_secret: SecretString::new("wallet-secret"), + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + }; + + let signer = + crate::models::Signer::new("cdp-signer".to_string(), SignerConfig::Cdp(cdp_config)); + + let response = SignerResponse::from(signer); + + assert_eq!(response.id, "cdp-signer"); + assert_eq!(response.r#type, SignerType::Cdp); + assert_eq!( + response.config, + SignerConfigResponse::Cdp { + api_key_id: "test-api-key-id".to_string(), + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + } + ); + } + + #[test] + fn test_cdp_response_serialization() { + let response = SignerResponse { + id: "test-cdp-signer".to_string(), + r#type: SignerType::Cdp, + config: SignerConfigResponse::Cdp { + api_key_id: "test-api-key-id".to_string(), + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"id\":\"test-cdp-signer\"")); + assert!(json.contains("\"type\":\"cdp\"")); + assert!(json.contains("\"api_key_id\":\"test-api-key-id\"")); + assert!(json.contains("\"account_address\":\"0x742d35Cc6634C0532925a3b844Bc454e4438f44f\"")); + + // Verify that secrets are not included + assert!(!json.contains("api_key_secret")); + assert!(!json.contains("wallet_secret")); + } + + #[test] + fn test_cdp_response_deserialization() { + let json = r#"{ + "id": "test-cdp-signer", + "type": "cdp", + "config": { + "api_key_id": "test-api-key-id", + "account_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44f" + } + }"#; + + let response: SignerResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.id, "test-cdp-signer"); + assert_eq!(response.r#type, SignerType::Cdp); + assert_eq!( + response.config, + SignerConfigResponse::Cdp { + api_key_id: "test-api-key-id".to_string(), + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + } + ); + } } diff --git a/src/services/cdp/mod.rs b/src/services/cdp/mod.rs new file mode 100644 index 000000000..e24b30bb3 --- /dev/null +++ b/src/services/cdp/mod.rs @@ -0,0 +1,1051 @@ +//! # CDP Service Module +//! +//! This module provides integration with CDP API for secure wallet management +//! and cryptographic operations. +//! +//! ## Features +//! +//! - API key-based authentication via WalletAuth +//! - Digital signature generation for EVM +//! - Message signing via CDP API +//! - Secure transaction signing for blockchain operations +//! +//! ## Architecture +//! +//! ```text +//! CdpService (implements CdpServiceTrait) +//! ├── Authentication (WalletAuth) +//! ├── Transaction Signing +//! └── Raw Payload Signing +//! ``` +use async_trait::async_trait; +use base64::{engine::general_purpose, Engine as _}; +use reqwest_middleware::ClientBuilder; +use std::{str, time::Duration}; +use thiserror::Error; + +use crate::models::{Address, CdpSignerConfig}; + +use cdp_sdk::{auth::WalletAuth, types, Client, CDP_BASE_URL}; + +#[derive(Error, Debug, serde::Serialize)] +pub enum CdpError { + #[error("HTTP error: {0}")] + HttpError(String), + + #[error("Authentication failed: {0}")] + AuthenticationFailed(String), + + #[error("Configuration error: {0}")] + ConfigError(String), + + #[error("Signing error: {0}")] + SigningError(String), + + #[error("Serialization error: {0}")] + SerializationError(String), + + #[error("Invalid signature: {0}")] + SignatureError(String), + + #[error("Other error: {0}")] + OtherError(String), +} + +/// Result type for CDP operations +pub type CdpResult = Result; + +#[cfg(test)] +use mockall::automock; + +#[async_trait] +#[cfg_attr(test, automock)] +pub trait CdpServiceTrait: Send + Sync { + /// Returns the EVM or Solana address for the configured account + async fn account_address(&self) -> Result; + + /// Signs a message using the EVM signing scheme + async fn sign_evm_message(&self, message: String) -> Result, CdpError>; + + /// Signs an EVM transaction using the CDP API + async fn sign_evm_transaction(&self, message: &[u8]) -> Result, CdpError>; + + /// Signs a message using Solana signing scheme + async fn sign_solana_message(&self, message: &[u8]) -> Result, CdpError>; + + /// Signs a transaction using Solana signing scheme + async fn sign_solana_transaction(&self, message: String) -> Result, CdpError>; +} + +#[derive(Clone)] +pub struct CdpService { + pub config: CdpSignerConfig, + pub client: Client, +} + +impl CdpService { + pub fn new(config: CdpSignerConfig) -> Result { + // Initialize the CDP client with WalletAuth middleware, which is required for signing operations + let wallet_auth = WalletAuth::builder() + .api_key_id(config.api_key_id.clone()) + .api_key_secret(config.api_key_secret.to_str().to_string()) + .wallet_secret(config.wallet_secret.to_str().to_string()) + .source("openzeppelin-relayer".to_string()) + .source_version(env!("CARGO_PKG_VERSION").to_string()) + .build() + .map_err(|e| CdpError::ConfigError(format!("Invalid CDP configuration: {}", e)))?; + + let inner = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| CdpError::ConfigError(format!("Failed to build HTTP client: {}", e)))?; + let wallet_client = ClientBuilder::new(inner).with(wallet_auth).build(); + let client = Client::new_with_client(CDP_BASE_URL, wallet_client); + Ok(Self { config, client }) + } + + /// Get the configured account address + fn get_account_address(&self) -> &str { + &self.config.account_address + } + + /// Check if the configured address is an EVM address (0x-prefixed hex) + fn is_evm_address(&self) -> bool { + self.config.account_address.starts_with("0x") + } + + /// Check if the configured address is a Solana address (Base58) + fn is_solana_address(&self) -> bool { + !self.config.account_address.starts_with("0x") + } + + /// Converts a CDP address to our Address type, auto-detecting format + fn address_from_string(&self, address_str: &str) -> Result { + if address_str.starts_with("0x") { + // EVM address (hex) + let hex_str = address_str.strip_prefix("0x").unwrap(); + + // Decode hex string to bytes + let bytes = hex::decode(hex_str) + .map_err(|e| CdpError::ConfigError(format!("Invalid hex address: {}", e)))?; + + if bytes.len() != 20 { + return Err(CdpError::ConfigError(format!( + "EVM address should be 20 bytes, got {} bytes", + bytes.len() + ))); + } + + let mut array = [0u8; 20]; + array.copy_from_slice(&bytes); + + Ok(Address::Evm(array)) + } else { + // Solana address (Base58) + Ok(Address::Solana(address_str.to_string())) + } + } +} + +#[async_trait] +impl CdpServiceTrait for CdpService { + async fn account_address(&self) -> Result { + let address_str = self.get_account_address(); + self.address_from_string(address_str) + } + + async fn sign_evm_message(&self, message: String) -> Result, CdpError> { + if !self.is_evm_address() { + return Err(CdpError::ConfigError( + "Account address is not an EVM address (must start with 0x)".to_string(), + )); + } + let address = self.get_account_address(); + + let message_body = types::SignEvmMessageBody::builder().message(message); + + let response = self + .client + .sign_evm_message() + .address(address) + .x_wallet_auth("") // Added by WalletAuth middleware. + .body(message_body) + .send() + .await + .map_err(|e| CdpError::SigningError(format!("Failed to sign message: {}", e)))?; + + let result = response.into_inner(); + + // Parse the signature hex string to bytes + let signature_bytes = hex::decode( + result + .signature + .strip_prefix("0x") + .unwrap_or(&result.signature), + ) + .map_err(|e| CdpError::SigningError(format!("Invalid signature hex: {}", e)))?; + + Ok(signature_bytes) + } + + async fn sign_evm_transaction(&self, message: &[u8]) -> Result, CdpError> { + if !self.is_evm_address() { + return Err(CdpError::ConfigError( + "Account address is not an EVM address (must start with 0x)".to_string(), + )); + } + let address = self.get_account_address(); + + // Convert transaction bytes to hex string for CDP API + let hex_encoded = hex::encode(message); + + let tx_body = + types::SignEvmTransactionBody::builder().transaction(format!("0x{}", hex_encoded)); + + let response = self + .client + .sign_evm_transaction() + .address(address) + .x_wallet_auth("") + .body(tx_body) + .send() + .await + .map_err(|e| CdpError::SigningError(format!("Failed to sign transaction: {}", e)))?; + + let result = response.into_inner(); + + // Parse the signed transaction hex string to bytes + let signed_tx_bytes = hex::decode( + result + .signed_transaction + .strip_prefix("0x") + .unwrap_or(&result.signed_transaction), + ) + .map_err(|e| CdpError::SigningError(format!("Invalid signed transaction hex: {}", e)))?; + + Ok(signed_tx_bytes) + } + + async fn sign_solana_message(&self, message: &[u8]) -> Result, CdpError> { + if !self.is_solana_address() { + return Err(CdpError::ConfigError( + "Account address is not a Solana address (must not start with 0x)".to_string(), + )); + } + let address = self.get_account_address(); + let encoded_message = str::from_utf8(message) + .map_err(|e| CdpError::SerializationError(format!("Invalid UTF-8 message: {}", e)))? + .to_string(); + + let message_body = types::SignSolanaMessageBody::builder().message(encoded_message); + + let response = self + .client + .sign_solana_message() + .address(address) + .x_wallet_auth("") // Added by WalletAuth middleware. + .body(message_body) + .send() + .await + .map_err(|e| CdpError::SigningError(format!("Failed to sign Solana message: {}", e)))?; + + let result = response.into_inner(); + + // Parse the signature base58 string to bytes + let signature_bytes = bs58::decode(result.signature).into_vec().map_err(|e| { + CdpError::SigningError(format!("Invalid Solana signature base58: {}", e)) + })?; + + Ok(signature_bytes) + } + + async fn sign_solana_transaction(&self, transaction: String) -> Result, CdpError> { + if !self.is_solana_address() { + return Err(CdpError::ConfigError( + "Account address is not a Solana address (must not start with 0x)".to_string(), + )); + } + let address = self.get_account_address(); + + let message_body = types::SignSolanaTransactionBody::builder().transaction(transaction); + + let response = self + .client + .sign_solana_transaction() + .address(address) + .x_wallet_auth("") // Added by WalletAuth middleware. + .body(message_body) + .send() + .await + .map_err(|e| CdpError::SigningError(format!("Failed to sign Solana transaction: {}", e)))?; + + let result = response.into_inner(); + + // Parse the signed transaction base64 string to bytes + let signature_bytes = general_purpose::STANDARD + .decode(result.signed_transaction) + .map_err(|e| { + CdpError::SigningError(format!("Invalid Solana signed transaction base64: {}", e)) + })?; + + Ok(signature_bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SecretString; + use mockito; + use serde_json::json; + + fn create_test_config_evm() -> CdpSignerConfig { + CdpSignerConfig { + api_key_id: "test-api-key-id".to_string(), + api_key_secret: SecretString::new("test-api-key-secret"), + wallet_secret: SecretString::new("test-wallet-secret"), + account_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string(), + } + } + + fn create_test_config_solana() -> CdpSignerConfig { + CdpSignerConfig { + api_key_id: "test-api-key-id".to_string(), + api_key_secret: SecretString::new("test-api-key-secret"), + wallet_secret: SecretString::new("test-wallet-secret"), + account_address: "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2".to_string(), + } + } + + // Helper function to create a test client with middleware + fn create_test_client() -> reqwest_middleware::ClientWithMiddleware { + let inner = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + reqwest_middleware::ClientBuilder::new(inner).build() + } + + // Setup mock for EVM message signing + async fn setup_mock_sign_evm_message(mock_server: &mut mockito::ServerGuard) -> mockito::Mock { + mock_server + .mock("POST", mockito::Matcher::Regex(r".*/v2/evm/accounts/.*/sign/message".to_string())) + .match_header("Content-Type", "application/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&json!({ + "signature": "0x3045022100abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789002201234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + })).unwrap()) + .expect(1) + .create_async() + .await + } + + // Setup mock for EVM transaction signing + async fn setup_mock_sign_evm_transaction( + mock_server: &mut mockito::ServerGuard, + ) -> mockito::Mock { + mock_server + .mock("POST", mockito::Matcher::Regex(r".*/v2/evm/accounts/.*/sign/transaction".to_string())) + .match_header("Content-Type", "application/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&json!({ + "signedTransaction": "0x02f87001020304050607080910111213141516171819202122232425262728293031" + })).unwrap()) + .expect(1) + .create_async() + .await + } + + // Setup mock for Solana message signing + async fn setup_mock_sign_solana_message( + mock_server: &mut mockito::ServerGuard, + ) -> mockito::Mock { + mock_server + .mock("POST", mockito::Matcher::Regex(r".*/v2/solana/accounts/.*/sign/message".to_string())) + .match_header("Content-Type", "application/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&json!({ + "signature": "5VERuXP42jC4Uxo1Rc3eLQgFaQGYdM9ZJvqK3JmZ6vxGz4s8FJ7KHkQpE3cN8RuQ2mW6tX9Y5K2P1VcZqL8TfABC3X" + })).unwrap()) + .expect(1) + .create_async() + .await + } + + // Setup mock for Solana transaction signing + async fn setup_mock_sign_solana_transaction( + mock_server: &mut mockito::ServerGuard, + ) -> mockito::Mock { + mock_server + .mock( + "POST", + mockito::Matcher::Regex(r".*/v2/solana/accounts/.*/sign/transaction".to_string()), + ) + .match_header("Content-Type", "application/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::to_string(&json!({ + "signedTransaction": "SGVsbG8gV29ybGQh" // Base64 encoded test data + })) + .unwrap(), + ) + .expect(1) + .create_async() + .await + } + + // Setup mock for error responses - 400 Bad Request + async fn setup_mock_error_400_malformed_transaction( + mock_server: &mut mockito::ServerGuard, + path_pattern: &str, + ) -> mockito::Mock { + mock_server + .mock("POST", mockito::Matcher::Regex(path_pattern.to_string())) + .match_header("Content-Type", "application/json") + .with_status(400) + .with_header("content-type", "application/json") + .with_body( + serde_json::to_string(&json!({ + "errorType": "malformed_transaction", + "errorMessage": "Malformed unsigned transaction." + })) + .unwrap(), + ) + .expect(1) + .create_async() + .await + } + + // Setup mock for error responses - 401 Unauthorized + async fn setup_mock_error_401_unauthorized( + mock_server: &mut mockito::ServerGuard, + path_pattern: &str, + ) -> mockito::Mock { + mock_server + .mock("POST", mockito::Matcher::Regex(path_pattern.to_string())) + .match_header("Content-Type", "application/json") + .with_status(401) + .with_header("content-type", "application/json") + .with_body( + serde_json::to_string(&json!({ + "errorType": "unauthorized", + "errorMessage": "Invalid API credentials." + })) + .unwrap(), + ) + .expect(1) + .create_async() + .await + } + + // Setup mock for error responses - 500 Internal Server Error + async fn setup_mock_error_500_internal_error( + mock_server: &mut mockito::ServerGuard, + path_pattern: &str, + ) -> mockito::Mock { + mock_server + .mock("POST", mockito::Matcher::Regex(path_pattern.to_string())) + .match_header("Content-Type", "application/json") + .with_status(500) + .with_header("content-type", "application/json") + .with_body( + serde_json::to_string(&json!({ + "errorType": "internal_error", + "errorMessage": "Internal server error occurred." + })) + .unwrap(), + ) + .expect(1) + .create_async() + .await + } + + // Setup mock for error responses - 422 Unprocessable Entity + async fn setup_mock_error_422_invalid_signature( + mock_server: &mut mockito::ServerGuard, + path_pattern: &str, + ) -> mockito::Mock { + mock_server + .mock("POST", mockito::Matcher::Regex(path_pattern.to_string())) + .match_header("Content-Type", "application/json") + .with_status(422) + .with_header("content-type", "application/json") + .with_body( + serde_json::to_string(&json!({ + "errorType": "invalid_signature_request", + "errorMessage": "Unable to process signature request." + })) + .unwrap(), + ) + .expect(1) + .create_async() + .await + } + + #[test] + fn test_new_cdp_service_valid_config() { + let config = create_test_config_evm(); + let result = CdpService::new(config); + + // Service creation should succeed with valid config + assert!(result.is_ok()); + } + + #[test] + fn test_get_account_address() { + let config = create_test_config_evm(); + let service = CdpService::new(config).unwrap(); + + let address = service.get_account_address(); + assert_eq!(address, "0x742d35Cc6634C0532925a3b844Bc454e4438f44f"); + } + + #[test] + fn test_is_evm_address() { + let config = create_test_config_evm(); + let service = CdpService::new(config).unwrap(); + assert!(service.is_evm_address()); + assert!(!service.is_solana_address()); + } + + #[test] + fn test_is_solana_address() { + let config = create_test_config_solana(); + let service = CdpService::new(config).unwrap(); + assert!(service.is_solana_address()); + assert!(!service.is_evm_address()); + } + + #[tokio::test] + async fn test_address_evm_success() { + let config = create_test_config_evm(); + let service = CdpService::new(config).unwrap(); + let result = service.account_address().await; + + assert!(result.is_ok()); + match result.unwrap() { + Address::Evm(addr) => { + // Verify the address bytes match expected values + let expected = [ + 0x74, 0x2d, 0x35, 0xcc, 0x66, 0x34, 0xC0, 0x53, 0x29, 0x25, 0xa3, 0xb8, 0x44, + 0xbc, 0x45, 0x4e, 0x44, 0x38, 0xf4, 0x4f, + ]; + assert_eq!(addr, expected); + } + _ => panic!("Expected EVM address"), + } + } + + #[tokio::test] + async fn test_address_solana_success() { + let config = create_test_config_solana(); + let service = CdpService::new(config).unwrap(); + let result = service.account_address().await; + + assert!(result.is_ok()); + match result.unwrap() { + Address::Solana(addr) => { + assert_eq!(addr, "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2"); + } + _ => panic!("Expected Solana address"), + } + } + + #[test] + fn test_address_from_string_valid_evm_address() { + let config = create_test_config_evm(); + let service = CdpService::new(config).unwrap(); + + let test_address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44f"; + let result = service.address_from_string(test_address); + + assert!(result.is_ok()); + match result.unwrap() { + Address::Evm(addr) => { + let expected = [ + 0x74, 0x2d, 0x35, 0xcc, 0x66, 0x34, 0xC0, 0x53, 0x29, 0x25, 0xa3, 0xb8, 0x44, + 0xbc, 0x45, 0x4e, 0x44, 0x38, 0xf4, 0x4f, + ]; + assert_eq!(addr, expected); + } + _ => panic!("Expected EVM address"), + } + } + + #[test] + fn test_address_from_string_valid_solana_address() { + let config = create_test_config_solana(); + let service = CdpService::new(config).unwrap(); + + let test_address = "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2"; + let result = service.address_from_string(test_address); + + assert!(result.is_ok()); + match result.unwrap() { + Address::Solana(addr) => { + assert_eq!(addr, "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2"); + } + _ => panic!("Expected Solana address"), + } + } + + #[test] + fn test_address_from_string_without_0x_prefix() { + let config = create_test_config_evm(); + let service = CdpService::new(config).unwrap(); + + let test_address = "742d35Cc6634C0532925a3b844Bc454e4438f44f"; + let result = service.address_from_string(test_address); + + // Without 0x prefix, it should be treated as Solana address + assert!(result.is_ok()); + match result.unwrap() { + Address::Solana(addr) => { + assert_eq!(addr, "742d35Cc6634C0532925a3b844Bc454e4438f44f"); + } + _ => panic!("Expected Solana address"), + } + } + + #[test] + fn test_address_from_string_invalid_hex() { + let config = create_test_config_evm(); + let service = CdpService::new(config).unwrap(); + + let test_address = "0xnot_valid_hex"; + let result = service.address_from_string(test_address); + + assert!(result.is_err()); + match result { + Err(CdpError::ConfigError(msg)) => { + assert!(msg.contains("Invalid hex address")); + } + _ => panic!("Expected ConfigError for invalid hex"), + } + } + + #[test] + fn test_address_from_string_wrong_length() { + let config = create_test_config_evm(); + let service = CdpService::new(config).unwrap(); + + let test_address = "0x742d35Cc"; // Too short + let result = service.address_from_string(test_address); + + assert!(result.is_err()); + match result { + Err(CdpError::ConfigError(msg)) => { + assert!(msg.contains("EVM address should be 20 bytes")); + } + _ => panic!("Expected ConfigError for wrong length"), + } + } + + #[test] + fn test_cdp_error_display() { + let errors = [ + CdpError::HttpError("HTTP error".to_string()), + CdpError::AuthenticationFailed("Auth failed".to_string()), + CdpError::ConfigError("Config error".to_string()), + CdpError::SigningError("Signing error".to_string()), + CdpError::SerializationError("Serialization error".to_string()), + CdpError::SignatureError("Signature error".to_string()), + CdpError::OtherError("Other error".to_string()), + ]; + + for error in errors { + let error_str = error.to_string(); + assert!(!error_str.is_empty()); + } + } + + #[tokio::test] + async fn test_sign_evm_message_success() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_sign_evm_message(&mut mock_server).await; + + let config = create_test_config_evm(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + + let service = CdpService { config, client }; + + let message = "Hello World!".to_string(); + let result = service.sign_evm_message(message).await; + + match result { + Ok(signature) => { + assert!(!signature.is_empty()); + } + Err(e) => { + panic!("Expected success but got error: {:?}", e); + } + } + } + + #[tokio::test] + async fn test_sign_evm_message_wrong_address_type() { + let config = create_test_config_solana(); // Solana address for EVM signing + let client = Client::new_with_client("http://test", create_test_client()); + let service = CdpService { config, client }; + + let message = "Hello World!".to_string(); + let result = service.sign_evm_message(message).await; + + assert!(result.is_err()); + match result { + Err(CdpError::ConfigError(msg)) => { + assert!(msg.contains("Account address is not an EVM address")); + } + _ => panic!("Expected ConfigError for wrong address type"), + } + } + + #[tokio::test] + async fn test_sign_evm_transaction_success() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_sign_evm_transaction(&mut mock_server).await; + + let config = create_test_config_evm(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + + let service = CdpService { config, client }; + + let transaction_bytes = b"test transaction data"; + let result = service.sign_evm_transaction(transaction_bytes).await; + + match result { + Ok(signed_tx) => { + assert!(!signed_tx.is_empty()); + } + Err(e) => { + panic!("Expected success but got error: {:?}", e); + } + } + } + + #[tokio::test] + async fn test_sign_evm_transaction_wrong_address_type() { + let config = create_test_config_solana(); // Solana address for EVM signing + let client = Client::new_with_client("http://test", create_test_client()); + let service = CdpService { config, client }; + + let transaction_bytes = b"test transaction data"; + let result = service.sign_evm_transaction(transaction_bytes).await; + + assert!(result.is_err()); + match result { + Err(CdpError::ConfigError(msg)) => { + assert!(msg.contains("Account address is not an EVM address")); + } + _ => panic!("Expected ConfigError for wrong address type"), + } + } + + #[tokio::test] + async fn test_sign_solana_message_success() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_sign_solana_message(&mut mock_server).await; + + let config = create_test_config_solana(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + + let service = CdpService { config, client }; + + let message_bytes = b"Hello Solana!"; + let result = service.sign_solana_message(message_bytes).await; + + assert!(result.is_ok()); + let signature = result.unwrap(); + assert!(!signature.is_empty()); + } + + #[tokio::test] + async fn test_sign_solana_message_wrong_address_type() { + let config = create_test_config_evm(); // EVM address for Solana signing + let client = Client::new_with_client("http://test", create_test_client()); + let service = CdpService { config, client }; + + let message_bytes = b"Hello Solana!"; + let result = service.sign_solana_message(message_bytes).await; + + assert!(result.is_err()); + match result { + Err(CdpError::ConfigError(msg)) => { + assert!(msg.contains("Account address is not a Solana address")); + } + _ => panic!("Expected ConfigError for wrong address type"), + } + } + + #[tokio::test] + async fn test_sign_solana_transaction_success() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_sign_solana_transaction(&mut mock_server).await; + + let config = create_test_config_solana(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + + let service = CdpService { config, client }; + + let transaction = "test-transaction-string".to_string(); + let result = service.sign_solana_transaction(transaction).await; + + match result { + Ok(signed_tx) => { + assert!(!signed_tx.is_empty()); + } + Err(e) => { + panic!("Expected success but got error: {:?}", e); + } + } + } + + #[tokio::test] + async fn test_sign_solana_transaction_wrong_address_type() { + let config = create_test_config_evm(); // EVM address for Solana signing + let client = Client::new_with_client("http://test", create_test_client()); + let service = CdpService { config, client }; + + let transaction = "test-transaction-string".to_string(); + let result = service.sign_solana_transaction(transaction).await; + + assert!(result.is_err()); + match result { + Err(CdpError::ConfigError(msg)) => { + assert!(msg.contains("Account address is not a Solana address")); + } + _ => panic!("Expected ConfigError for wrong address type"), + } + } + + // Error handling tests + #[tokio::test] + async fn test_sign_evm_message_error_400_malformed_transaction() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_error_400_malformed_transaction( + &mut mock_server, + r".*/v2/evm/accounts/.*/sign/message", + ) + .await; + + let config = create_test_config_evm(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + let service = CdpService { config, client }; + + let message = "Hello World!".to_string(); + let result = service.sign_evm_message(message).await; + + assert!(result.is_err()); + match result { + Err(CdpError::SigningError(msg)) => { + assert!(msg.contains("Failed to sign message")); + } + _ => panic!("Expected SigningError for malformed transaction"), + } + } + + #[tokio::test] + async fn test_sign_evm_message_error_401_unauthorized() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_error_401_unauthorized( + &mut mock_server, + r".*/v2/evm/accounts/.*/sign/message", + ) + .await; + + let config = create_test_config_evm(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + let service = CdpService { config, client }; + + let message = "Hello World!".to_string(); + let result = service.sign_evm_message(message).await; + + assert!(result.is_err()); + match result { + Err(CdpError::SigningError(msg)) => { + assert!(msg.contains("Failed to sign message")); + } + _ => panic!("Expected SigningError for unauthorized"), + } + } + + #[tokio::test] + async fn test_sign_evm_message_error_500_internal_error() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_error_500_internal_error( + &mut mock_server, + r".*/v2/evm/accounts/.*/sign/message", + ) + .await; + + let config = create_test_config_evm(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + let service = CdpService { config, client }; + + let message = "Hello World!".to_string(); + let result = service.sign_evm_message(message).await; + + assert!(result.is_err()); + match result { + Err(CdpError::SigningError(msg)) => { + assert!(msg.contains("Failed to sign message")); + } + _ => panic!("Expected SigningError for internal error"), + } + } + + #[tokio::test] + async fn test_sign_evm_transaction_error_400_malformed_transaction() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_error_400_malformed_transaction( + &mut mock_server, + r".*/v2/evm/accounts/.*/sign/transaction", + ) + .await; + + let config = create_test_config_evm(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + let service = CdpService { config, client }; + + let transaction_bytes = b"invalid transaction data"; + let result = service.sign_evm_transaction(transaction_bytes).await; + + assert!(result.is_err()); + match result { + Err(CdpError::SigningError(msg)) => { + assert!(msg.contains("Failed to sign transaction")); + } + _ => panic!("Expected SigningError for malformed transaction"), + } + } + + #[tokio::test] + async fn test_sign_evm_transaction_error_422_invalid_signature() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_error_422_invalid_signature( + &mut mock_server, + r".*/v2/evm/accounts/.*/sign/transaction", + ) + .await; + + let config = create_test_config_evm(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + let service = CdpService { config, client }; + + let transaction_bytes = b"test transaction data"; + let result = service.sign_evm_transaction(transaction_bytes).await; + + assert!(result.is_err()); + match result { + Err(CdpError::SigningError(msg)) => { + assert!(msg.contains("Failed to sign transaction")); + } + _ => panic!("Expected SigningError for invalid signature request"), + } + } + + #[tokio::test] + async fn test_sign_solana_message_error_400_malformed_transaction() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_error_400_malformed_transaction( + &mut mock_server, + r".*/v2/solana/accounts/.*/sign/message", + ) + .await; + + let config = create_test_config_solana(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + let service = CdpService { config, client }; + + let message_bytes = b"Hello Solana!"; + let result = service.sign_solana_message(message_bytes).await; + + assert!(result.is_err()); + match result { + Err(CdpError::SigningError(msg)) => { + assert!(msg.contains("Failed to sign Solana message")); + } + _ => panic!("Expected SigningError for malformed transaction"), + } + } + + #[tokio::test] + async fn test_sign_solana_message_error_401_unauthorized() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_error_401_unauthorized( + &mut mock_server, + r".*/v2/solana/accounts/.*/sign/message", + ) + .await; + + let config = create_test_config_solana(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + let service = CdpService { config, client }; + + let message_bytes = b"Hello Solana!"; + let result = service.sign_solana_message(message_bytes).await; + + assert!(result.is_err()); + match result { + Err(CdpError::SigningError(msg)) => { + assert!(msg.contains("Failed to sign Solana message")); + } + _ => panic!("Expected SigningError for unauthorized"), + } + } + + #[tokio::test] + async fn test_sign_solana_transaction_error_400_malformed_transaction() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_error_400_malformed_transaction( + &mut mock_server, + r".*/v2/solana/accounts/.*/sign/transaction", + ) + .await; + + let config = create_test_config_solana(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + let service = CdpService { config, client }; + + let transaction = "invalid-transaction-string".to_string(); + let result = service.sign_solana_transaction(transaction).await; + + assert!(result.is_err()); + match result { + Err(CdpError::SigningError(msg)) => { + assert!(msg.contains("Failed to sign Solana transaction")); + } + _ => panic!("Expected SigningError for malformed transaction"), + } + } + + #[tokio::test] + async fn test_sign_solana_transaction_error_500_internal_error() { + let mut mock_server = mockito::Server::new_async().await; + let _mock = setup_mock_error_500_internal_error( + &mut mock_server, + r".*/v2/solana/accounts/.*/sign/transaction", + ) + .await; + + let config = create_test_config_solana(); + let client = Client::new_with_client(&mock_server.url(), create_test_client()); + let service = CdpService { config, client }; + + let transaction = "test-transaction-string".to_string(); + let result = service.sign_solana_transaction(transaction).await; + + assert!(result.is_err()); + match result { + Err(CdpError::SigningError(msg)) => { + assert!(msg.contains("Failed to sign Solana transaction")); + } + _ => panic!("Expected SigningError for internal error"), + } + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 45802046a..b65f1b581 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -26,6 +26,9 @@ pub use vault::*; mod turnkey; pub use turnkey::*; +mod cdp; +pub use cdp::*; + mod google_cloud_kms; pub use google_cloud_kms::*; diff --git a/src/services/signer/evm/cdp_signer.rs b/src/services/signer/evm/cdp_signer.rs new file mode 100644 index 000000000..2e177900e --- /dev/null +++ b/src/services/signer/evm/cdp_signer.rs @@ -0,0 +1,508 @@ +//! # EVM CDP Signer Implementation +//! +//! This module provides an EVM signer implementation that uses the CDP API +//! for secure key management and transaction signing operations. +//! +//! ## Features +//! +//! - Secure signing of EVM transactions (both legacy and EIP-1559) +//! - Message signing with EIP-191 prefixing +//! - Remote key management through CDP's secure infrastructure +//! +//! ## Security Notes +//! +//! Private keys never leave the CDP service, providing enhanced security +//! compared to local key storage solutions. +use std::str::FromStr; + +use alloy::{ + consensus::{SignableTransaction, TxEip1559, TxLegacy}, + primitives::{eip191_hash_message, keccak256}, +}; +use async_trait::async_trait; +use log::{debug, info}; + +use crate::{ + domain::{ + SignDataRequest, SignDataResponse, SignDataResponseEvm, SignTransactionResponse, + SignTransactionResponseEvm, SignTypedDataRequest, + }, + models::{ + Address, CdpSignerConfig, EvmTransactionDataSignature, EvmTransactionDataTrait, + NetworkTransactionData, SignerError, SignerRepoModel, + }, + services::{signer::Signer, CdpService, CdpServiceTrait}, +}; + +use super::DataSignerTrait; + +pub type DefaultCdpService = CdpService; + +pub struct CdpSigner +where + T: CdpServiceTrait, +{ + cdp_service: T, +} + +impl CdpSigner { + pub fn new(config: CdpSignerConfig) -> Result { + let cdp_service = DefaultCdpService::new(config).map_err(|e| { + SignerError::Configuration(format!("Failed to create CDP service: {}", e)) + })?; + Ok(Self { cdp_service }) + } +} + +#[cfg(test)] +impl CdpSigner { + pub fn new_with_service(cdp_service: T) -> Self { + Self { cdp_service } + } + + pub fn new_for_testing(cdp_service: T) -> Self { + Self::new_with_service(cdp_service) + } +} + +#[async_trait] +impl Signer for CdpSigner { + async fn address(&self) -> Result { + let address = self + .cdp_service + .account_address() + .await + .map_err(SignerError::CdpError)?; + + Ok(address) + } + + async fn sign_transaction( + &self, + transaction: NetworkTransactionData, + ) -> Result { + let evm_data = transaction.get_evm_transaction_data()?; + + // Prepare data for signing based on transaction type + let (unsigned_tx_bytes, is_eip1559) = if evm_data.is_eip1559() { + let tx = TxEip1559::try_from(transaction)?; + (tx.encoded_for_signing(), true) + } else { + let tx = TxLegacy::try_from(transaction)?; + (tx.encoded_for_signing(), false) + }; + + // Sign the data with CDP service + let signed_bytes = self + .cdp_service + .sign_evm_transaction(&unsigned_tx_bytes) + .await + .map_err(SignerError::CdpError)?; + + // Process the signed transaction + let mut signed_bytes_slice = signed_bytes.as_slice(); + + // Parse the signed transaction and extract components + let (hash, signature_bytes) = if is_eip1559 { + let signed_tx = + alloy::consensus::Signed::::eip2718_decode(&mut signed_bytes_slice) + .map_err(|e| { + SignerError::SigningError(format!( + "Failed to decode signed transaction: {}", + e + )) + })?; + + let sig = signed_tx.signature(); + let mut sig_bytes = sig.as_bytes(); + + // Adjust v value for EIP-1559 (27/28 -> 0/1) + if sig_bytes[64] == 27 { + sig_bytes[64] = 0; + } else if sig_bytes[64] == 28 { + sig_bytes[64] = 1; + } + + (signed_tx.hash().to_string(), sig_bytes) + } else { + let signed_tx = + alloy::consensus::Signed::::eip2718_decode(&mut signed_bytes_slice) + .map_err(|e| { + SignerError::SigningError(format!( + "Failed to decode signed transaction: {}", + e + )) + })?; + + let sig = signed_tx.signature(); + (signed_tx.hash().to_string(), sig.as_bytes()) + }; + + Ok(SignTransactionResponse::Evm(SignTransactionResponseEvm { + hash, + signature: EvmTransactionDataSignature::from(&signature_bytes), + raw: signed_bytes, + })) + } +} + +#[async_trait] +impl DataSignerTrait for CdpSigner { + async fn sign_data(&self, request: SignDataRequest) -> Result { + let message_string = request.message.to_string(); + + // Sign the prefixed message + let signature_bytes = self + .cdp_service + .sign_evm_message(message_string) + .await + .map_err(SignerError::CdpError)?; + + // Ensure we have the right signature length + if signature_bytes.len() != 65 { + return Err(SignerError::SigningError(format!( + "Invalid signature length from CDP: expected 65 bytes, got {}", + signature_bytes.len() + ))); + } + + let r = hex::encode(&signature_bytes[0..32]); + let s = hex::encode(&signature_bytes[32..64]); + let v = signature_bytes[64]; + + Ok(SignDataResponse::Evm(SignDataResponseEvm { + r, + s, + v, + sig: hex::encode(&signature_bytes), + })) + } + + async fn sign_typed_data( + &self, + _typed_data: SignTypedDataRequest, + ) -> Result { + // EIP-712 typed data signing requires specific handling + Err(SignerError::NotImplemented( + "EIP-712 typed data signing not yet implemented for CDP".into(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + models::{CdpSignerConfig, SecretString}, + services::{CdpError, MockCdpServiceTrait}, + }; + use mockall::predicate::*; + + #[tokio::test] + async fn test_address() { + let mut mock_service = MockCdpServiceTrait::new(); + + mock_service + .expect_account_address() + .times(1) + .returning(|| { + Box::pin(async { + Ok(Address::Evm([ + 200, 52, 220, 220, 154, 7, 77, 187, 173, 204, 113, 88, 71, 137, 174, 75, + 70, 61, 177, 22, + ])) + }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + let result = signer.address().await.unwrap(); + + match result { + Address::Evm(addr) => { + assert_eq!( + hex::encode(addr), + "c834dcdc9a074dbbadcc71584789ae4b463db116" + ); + } + _ => panic!("Expected EVM address"), + } + } + + #[tokio::test] + async fn test_sign_data() { + let mut mock_service = MockCdpServiceTrait::new(); + let test_message = "Test message"; + + let r = [1u8; 32]; + let s = [2u8; 32]; + let v = 27u8; + let mut mock_sig = Vec::with_capacity(65); + mock_sig.extend_from_slice(&r); + mock_sig.extend_from_slice(&s); + mock_sig.push(v); + + mock_service + .expect_sign_evm_message() + .times(1) + .returning(move |_| { + let sig = mock_sig.clone(); + Box::pin(async { Ok(sig) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + let request = SignDataRequest { + message: test_message.to_string(), + }; + + let result = signer.sign_data(request).await.unwrap(); + + match result { + SignDataResponse::Evm(sig) => { + assert_eq!( + sig.r, + "0101010101010101010101010101010101010101010101010101010101010101" + ); + assert_eq!( + sig.s, + "0202020202020202020202020202020202020202020202020202020202020202" + ); + assert_eq!(sig.v, 27); + assert_eq!(sig.sig, "01010101010101010101010101010101010101010101010101010101010101010202020202020202020202020202020202020202020202020202020202020202".to_string() + "1b"); + } + _ => panic!("Expected EVM signature"), + } + } + + #[tokio::test] + async fn test_sign_transaction() { + let mut mock_service = MockCdpServiceTrait::new(); + + let tx_data = crate::models::EvmTransactionData { + from: "0x7f5f4552091a69125d5dfcb7b8c2658029395bdf".to_string(), + to: Some("0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string()), + gas_price: None, + gas_limit: Some(21000), + nonce: Some(0), + value: crate::models::U256::from(1000000000000000000u64), + data: Some("0x".to_string()), + chain_id: 1, + hash: None, + signature: None, + raw: None, + max_fee_per_gas: Some(1), + max_priority_fee_per_gas: Some(1), + speed: None, + }; + + mock_service + .expect_sign_evm_transaction() + .returning(move |_| { + let test = hex::decode("02f86d83aa36a70184442b657e84e946e47982520894b726167dc2ef2ac582f0a3de4c08ac4abb90626a0180c001a0f6b2cfef2b4d31f4af9a6d851c022f3ae89571e1eee6ec5d05889eaf50c4244da0369a720cf91e1327b9fff17d9291e042a22172e92c1db5e76f4b0ebf7fae9ed2").unwrap(); + Box::pin(async { Ok(test) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + + let result = signer + .sign_transaction(NetworkTransactionData::Evm(tx_data)) + .await + .unwrap(); + + match result { + SignTransactionResponse::Evm(signed_tx) => { + assert_eq!( + signed_tx.signature.r, + "f6b2cfef2b4d31f4af9a6d851c022f3ae89571e1eee6ec5d05889eaf50c4244d" + ); + assert_eq!( + signed_tx.signature.s, + "369a720cf91e1327b9fff17d9291e042a22172e92c1db5e76f4b0ebf7fae9ed2" + ); + assert_eq!(signed_tx.signature.v, 1); + assert_eq!( + signed_tx.hash, + "0xc2e3533e19d6cf2318a1415bcbe2df3977707c5000dc2b9cd04b99e5aeee2b58" + ); + } + _ => panic!("Expected EVM signed transaction"), + } + } + + #[tokio::test] + async fn test_sign_data_error_handling() { + let mut mock_service = MockCdpServiceTrait::new(); + let test_message = "Test message"; + + // Set up mock to return an error + mock_service + .expect_sign_evm_message() + .times(1) + .returning(move |_| { + Box::pin(async { Err(CdpError::SigningError("Mock signing error".into())) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + let request = SignDataRequest { + message: test_message.to_string(), + }; + + let result = signer.sign_data(request).await; + + assert!(result.is_err()); + match result { + Err(SignerError::CdpError(err)) => { + assert_eq!(err.to_string(), "Signing error: Mock signing error"); + } + _ => panic!("Expected SigningError error variant"), + } + } + + #[tokio::test] + async fn test_sign_data_invalid_signature_length() { + let mut mock_service = MockCdpServiceTrait::new(); + let test_message = "Test message"; + + mock_service + .expect_sign_evm_message() + .times(1) + .returning(move |_| { + let invalid_sig = vec![1u8; 64]; // Only 64 bytes instead of 65 + Box::pin(async { Ok(invalid_sig) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + let request = SignDataRequest { + message: test_message.to_string(), + }; + + // Verify that we get the expected error about signature length + let result = signer.sign_data(request).await; + assert!(result.is_err()); + match result { + Err(SignerError::SigningError(msg)) => { + assert!(msg.contains("Invalid signature length")); + assert!(msg.contains("expected 65 bytes")); + } + _ => panic!("Expected SigningError error variant"), + } + } + + #[tokio::test] + async fn test_sign_typed_data_not_implemented() { + let mock_service = MockCdpServiceTrait::new(); + let signer = CdpSigner::new_for_testing(mock_service); + + let request = SignTypedDataRequest { + domain_separator: "test-domain".to_string(), + hash_struct_message: "test-struct".to_string(), + }; + + let result = signer.sign_typed_data(request).await; + assert!(result.is_err()); + match result { + Err(SignerError::NotImplemented(_)) => {} + _ => panic!("Expected NotImplemented error variant"), + } + } + + #[tokio::test] + async fn test_sign_legacy_transaction() { + let mut mock_service = MockCdpServiceTrait::new(); + + // Create a legacy transaction (with gas_price instead of max_fee_per_gas) + let tx_data = crate::models::EvmTransactionData { + from: "0x7f5f4552091a69125d5dfcb7b8c2658029395bdf".to_string(), + to: Some("0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string()), + gas_price: Some(20_000_000_000), + gas_limit: Some(21000), + nonce: Some(0), + value: crate::models::U256::from(1000000000000000000u64), + data: Some("0x".to_string()), + chain_id: 1, + hash: None, + signature: None, + raw: None, + max_fee_per_gas: None, // Not used in legacy transactions + max_priority_fee_per_gas: None, // Not used in legacy transactions + speed: None, + }; + + mock_service + .expect_sign_evm_transaction() + .returning(move |_| { + let test = hex::decode("f86c808504a817c80082520894742d35cc6634c0532925a3b844bc454e4438f44f880de0b6b3a76400008025a0a37376a614e7c1c1605614b126467b0cf6ecb72e9a8d918e69d6048c4db42e89a01a87f2753e120205ae26681ad7d5158b6d8371424ca825ab4b773fb71e0c45fa").unwrap(); + Box::pin(async { Ok(test) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + + let result = signer + .sign_transaction(NetworkTransactionData::Evm(tx_data)) + .await + .unwrap(); + + match result { + SignTransactionResponse::Evm(signed_tx) => { + assert_eq!( + signed_tx.signature.r, + "a37376a614e7c1c1605614b126467b0cf6ecb72e9a8d918e69d6048c4db42e89" + ); + assert_eq!( + signed_tx.signature.s, + "1a87f2753e120205ae26681ad7d5158b6d8371424ca825ab4b773fb71e0c45fa" + ); + assert_eq!(signed_tx.signature.v, 27); + } + _ => panic!("Expected EVM signed transaction"), + } + } + + #[tokio::test] + async fn test_sign_transaction_error_handling() { + let mut mock_service = MockCdpServiceTrait::new(); + + let tx_data = crate::models::EvmTransactionData { + from: "0x7f5f4552091a69125d5dfcb7b8c2658029395bdf".to_string(), + to: Some("0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string()), + gas_price: None, + gas_limit: Some(21000), + nonce: Some(0), + value: crate::models::U256::from(1000000000000000000u64), + data: Some("0x".to_string()), + chain_id: 1, + hash: None, + signature: None, + raw: None, + max_fee_per_gas: Some(1), + max_priority_fee_per_gas: Some(1), + speed: None, + }; + + mock_service + .expect_sign_evm_transaction() + .returning(move |_| { + Box::pin(async { + Err(CdpError::SigningError( + "Mock transaction signing error".into(), + )) + }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + + let result = signer + .sign_transaction(NetworkTransactionData::Evm(tx_data)) + .await; + + assert!(result.is_err()); + match result { + Err(SignerError::CdpError(err)) => { + assert_eq!( + err.to_string(), + "Signing error: Mock transaction signing error" + ); + } + _ => panic!("Expected SigningError error variant"), + } + } +} diff --git a/src/services/signer/evm/mod.rs b/src/services/signer/evm/mod.rs index cbdadf60b..7a0f79cd4 100644 --- a/src/services/signer/evm/mod.rs +++ b/src/services/signer/evm/mod.rs @@ -13,11 +13,13 @@ //! └── Turnkey (Turnkey backend) //! ``` mod aws_kms_signer; +mod cdp_signer; mod google_cloud_kms_signer; mod local_signer; mod turnkey_signer; mod vault_signer; use aws_kms_signer::*; +use cdp_signer::*; use google_cloud_kms_signer::*; use local_signer::*; use oz_keystore::HashicorpCloudClient; @@ -43,7 +45,7 @@ use crate::{ signer::SignerFactoryError, turnkey::TurnkeyService, vault::{VaultConfig, VaultService, VaultServiceTrait}, - AwsKmsService, GoogleCloudKmsService, TurnkeyServiceTrait, + AwsKmsService, CdpService, GoogleCloudKmsService, TurnkeyServiceTrait, }, }; use eyre::Result; @@ -64,6 +66,7 @@ pub enum EvmSigner { Local(LocalSigner), Vault(VaultSigner), Turnkey(TurnkeySigner), + Cdp(CdpSigner), AwsKms(AwsKmsSigner), GoogleCloudKms(GoogleCloudKmsSigner), } @@ -75,6 +78,7 @@ impl Signer for EvmSigner { Self::Local(signer) => signer.address().await, Self::Vault(signer) => signer.address().await, Self::Turnkey(signer) => signer.address().await, + Self::Cdp(signer) => signer.address().await, Self::AwsKms(signer) => signer.address().await, Self::GoogleCloudKms(signer) => signer.address().await, } @@ -88,6 +92,7 @@ impl Signer for EvmSigner { Self::Local(signer) => signer.sign_transaction(transaction).await, Self::Vault(signer) => signer.sign_transaction(transaction).await, Self::Turnkey(signer) => signer.sign_transaction(transaction).await, + Self::Cdp(signer) => signer.sign_transaction(transaction).await, Self::AwsKms(signer) => signer.sign_transaction(transaction).await, Self::GoogleCloudKms(signer) => signer.sign_transaction(transaction).await, } @@ -101,6 +106,7 @@ impl DataSignerTrait for EvmSigner { Self::Local(signer) => signer.sign_data(request).await, Self::Vault(signer) => signer.sign_data(request).await, Self::Turnkey(signer) => signer.sign_data(request).await, + Self::Cdp(signer) => signer.sign_data(request).await, Self::AwsKms(signer) => signer.sign_data(request).await, Self::GoogleCloudKms(signer) => signer.sign_data(request).await, } @@ -114,6 +120,7 @@ impl DataSignerTrait for EvmSigner { Self::Local(signer) => signer.sign_typed_data(request).await, Self::Vault(signer) => signer.sign_typed_data(request).await, Self::Turnkey(signer) => signer.sign_typed_data(request).await, + Self::Cdp(signer) => signer.sign_typed_data(request).await, Self::AwsKms(signer) => signer.sign_typed_data(request).await, Self::GoogleCloudKms(signer) => signer.sign_typed_data(request).await, } @@ -163,6 +170,12 @@ impl EvmSignerFactory { })?; EvmSigner::Turnkey(TurnkeySigner::new(turnkey_service)) } + SignerConfig::Cdp(config) => { + let cdp_signer = CdpSigner::new(config.clone()).map_err(|e| { + SignerFactoryError::CreationFailed(format!("CDP service error: {}", e)) + })?; + EvmSigner::Cdp(cdp_signer) + } SignerConfig::GoogleCloudKms(config) => { let gcp_service = GoogleCloudKmsService::new(config).map_err(|e| { SignerFactoryError::CreationFailed(format!( @@ -182,7 +195,7 @@ impl EvmSignerFactory { mod tests { use super::*; use crate::models::{ - AwsKmsSignerConfig, EvmTransactionData, GoogleCloudKmsSignerConfig, + AwsKmsSignerConfig, CdpSignerConfig, EvmTransactionData, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, SecretString, SignerConfig, SignerRepoModel, TurnkeySignerConfig, VaultTransitSignerConfig, U256, @@ -323,6 +336,25 @@ mod tests { ); } + #[tokio::test] + async fn test_create_evm_signer_cdp() { + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Cdp(CdpSignerConfig { + api_key_id: "test-api-key-id".to_string(), + api_key_secret: SecretString::new("test-api-key-secret"), + wallet_secret: SecretString::new("test-wallet-secret"), + account_address: "0xb726167dc2ef2ac582f0a3de4c08ac4abb90626a".to_string(), + }), + }; + + let signer = EvmSignerFactory::create_evm_signer(signer_model) + .await + .unwrap(); + + assert!(matches!(signer, EvmSigner::Cdp(_))); + } + #[tokio::test] async fn test_address_evm_signer_local() { let signer_model = SignerDomainModel { @@ -381,6 +413,29 @@ mod tests { ); } + #[tokio::test] + async fn test_address_evm_signer_cdp() { + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Cdp(CdpSignerConfig { + api_key_id: "test-api-key-id".to_string(), + api_key_secret: SecretString::new("test-api-key-secret"), + wallet_secret: SecretString::new("test-wallet-secret"), + account_address: "0xb726167dc2ef2ac582f0a3de4c08ac4abb90626a".to_string(), + }), + }; + + let signer = EvmSignerFactory::create_evm_signer(signer_model) + .await + .unwrap(); + let signer_address = signer.address().await.unwrap(); + + assert_eq!( + "0xb726167dc2ef2ac582f0a3de4c08ac4abb90626a", + signer_address.to_string() + ); + } + #[tokio::test] async fn test_sign_data_evm_signer_local() { let signer_model = SignerDomainModel { diff --git a/src/services/signer/solana/cdp_signer.rs b/src/services/signer/solana/cdp_signer.rs new file mode 100644 index 000000000..ce8f9ccdb --- /dev/null +++ b/src/services/signer/solana/cdp_signer.rs @@ -0,0 +1,478 @@ +//! # Solana CDP Signer Implementation +//! +//! This module provides a Solana signer implementation that uses the CDP API +//! for secure wallet management and cryptographic operations. +//! +//! ## Features +//! +//! - Secure signing of Solana messages +//! - Remote key management through CDP's secure infrastructure +//! - Message signing with proper Solana signature format +//! +//! ## Security Notes +//! +//! Private keys never leave the CDP service, providing enhanced security +//! compared to local key storage solutions. +use crate::{ + domain::SignTransactionResponse, + models::{Address, CdpSignerConfig, NetworkTransactionData, SignerError}, + services::{signer::Signer, CdpService, CdpServiceTrait}, +}; +use async_trait::async_trait; +use base64::{engine::general_purpose, Engine as _}; +use solana_sdk::signature::Signature; +use solana_sdk::{pubkey::Pubkey, transaction::Transaction}; +use std::str::FromStr; + +use super::SolanaSignTrait; + +pub type DefaultCdpService = CdpService; + +pub struct CdpSigner +where + T: CdpServiceTrait, +{ + cdp_service: T, +} + +impl CdpSigner { + pub fn new(config: CdpSignerConfig) -> Result { + let cdp_service = DefaultCdpService::new(config).map_err(|e| { + SignerError::Configuration(format!("Failed to create CDP service: {}", e)) + })?; + + Ok(Self { cdp_service }) + } +} + +#[cfg(test)] +impl CdpSigner { + pub fn new_with_service(cdp_service: T) -> Self { + Self { cdp_service } + } + + pub fn new_for_testing(cdp_service: T) -> Self { + Self { cdp_service } + } +} + +#[async_trait] +impl SolanaSignTrait for CdpSigner { + async fn pubkey(&self) -> Result { + let address = self + .cdp_service + .account_address() + .await + .map_err(SignerError::CdpError)?; + + Ok(address) + } + + async fn sign(&self, message: &[u8]) -> Result { + // The message bytes are bincode-serialized transaction message data + // We need to reconstruct a full transaction for the CDP API + + // Deserialize the message from bincode + let solana_message: solana_sdk::message::Message = + bincode::deserialize(message).map_err(|e| { + SignerError::SigningError(format!("Failed to deserialize message: {}", e)) + })?; + + // Create an unsigned transaction from the message + let transaction = solana_sdk::transaction::Transaction::new_unsigned(solana_message); + + // Convert to EncodedSerializedTransaction (base64) + let encoded_tx = crate::models::EncodedSerializedTransaction::try_from(&transaction) + .map_err(|e| { + SignerError::SigningError(format!("Failed to encode transaction: {}", e)) + })?; + + // Use the CDP transaction signing API instead of message signing + let signed_tx_bytes = self + .cdp_service + .sign_solana_transaction(encoded_tx.into_inner()) + .await + .map_err(SignerError::CdpError)?; + + // The CDP service returns raw serialized signed-transaction bytes. + // Encode to base64 to reuse EncodedSerializedTransaction for parsing. + let signed_tx_encoded = general_purpose::STANDARD.encode(signed_tx_bytes); + + let signed_tx_data = crate::models::EncodedSerializedTransaction::new(signed_tx_encoded); + let signed_transaction: Transaction = signed_tx_data.try_into().map_err(|e| { + SignerError::SigningError(format!("Failed to decode signed transaction: {}", e)) + })?; + + // Get the CDP signer's address to find the correct signature index + let cdp_address = self + .cdp_service + .account_address() + .await + .map_err(SignerError::CdpError)?; + + let cdp_pubkey = match cdp_address { + crate::models::Address::Solana(addr) => Pubkey::from_str(&addr) + .map_err(|e| SignerError::SigningError(format!("Invalid CDP pubkey: {}", e)))?, + _ => { + return Err(SignerError::SigningError( + "CDP address is not a Solana address".to_string(), + )) + } + }; + + // Find the signature index for the CDP signer's pubkey + let signer_index = signed_transaction + .message + .account_keys + .iter() + .position(|key| *key == cdp_pubkey) + .ok_or_else(|| { + SignerError::SigningError("CDP pubkey not found in transaction signers".to_string()) + })?; + + // Extract the signature at the correct index + if signer_index >= signed_transaction.signatures.len() { + return Err(SignerError::SigningError( + "Signature index out of bounds".to_string(), + )); + } + + Ok(signed_transaction.signatures[signer_index]) + } +} + +#[async_trait] +impl Signer for CdpSigner { + async fn address(&self) -> Result { + let address = self + .cdp_service + .account_address() + .await + .map_err(SignerError::CdpError)?; + + Ok(address) + } + + async fn sign_transaction( + &self, + transaction: NetworkTransactionData, + ) -> Result { + let solana_data = transaction.get_solana_transaction_data()?; + + let signed_transaction = self + .cdp_service + .sign_solana_transaction(solana_data.transaction) + .await + .map_err(SignerError::CdpError)?; + + Ok(SignTransactionResponse::Solana(signed_transaction)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + models::{CdpSignerConfig, SecretString, SolanaTransactionData}, + services::{signer::Signer as RelayerSigner, CdpError, MockCdpServiceTrait}, + }; + use mockall::predicate::*; + use solana_sdk::signer::Signer; + + #[tokio::test] + async fn test_address() { + let mut mock_service = MockCdpServiceTrait::new(); + + mock_service + .expect_account_address() + .times(1) + .returning(|| { + Box::pin(async { + Ok(Address::Solana( + "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2".to_string(), + )) + }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + let result = signer.address().await.unwrap(); + + match result { + Address::Solana(addr) => { + assert_eq!(addr, "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2"); + } + _ => panic!("Expected Solana address"), + } + } + + #[tokio::test] + async fn test_pubkey() { + let mut mock_service = MockCdpServiceTrait::new(); + + mock_service + .expect_account_address() + .times(1) + .returning(|| { + Box::pin(async { + Ok(Address::Solana( + "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2".to_string(), + )) + }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + let result = signer.pubkey().await.unwrap(); + + match result { + Address::Solana(addr) => { + assert_eq!(addr, "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2"); + } + _ => panic!("Expected Solana address"), + } + } + + #[tokio::test] + async fn test_sign() { + let mut mock_service = MockCdpServiceTrait::new(); + + // Create a proper Solana message and serialize it with bincode + use solana_sdk::{ + hash::Hash, + message::Message, + pubkey::Pubkey, + signature::{Keypair, Signer}, + }; + use solana_system_interface::instruction; + + let payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + let instruction = instruction::transfer(&payer.pubkey(), &recipient, 1000); + let message = Message::new(&[instruction], Some(&payer.pubkey())); + let test_message = bincode::serialize(&message).unwrap(); + + // Mock a signed transaction response (base64-encoded) + let transaction = solana_sdk::transaction::Transaction::new_unsigned(message.clone()); + let mut signed_transaction = transaction; + signed_transaction.signatures = vec![Signature::from([1u8; 64])]; // Mock signature + let signed_tx_bytes = bincode::serialize(&signed_transaction).unwrap(); + + mock_service.expect_account_address().returning(move || { + let addr = payer.pubkey().to_string(); + Box::pin(async move { Ok(Address::Solana(addr)) }) + }); + + mock_service + .expect_sign_solana_transaction() + .times(1) + .returning(move |_| { + let signed_bytes = signed_tx_bytes.clone(); + Box::pin(async { Ok(signed_bytes) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + let result = signer.sign(&test_message).await.unwrap(); + + let expected_sig = Signature::from([1u8; 64]); + assert_eq!(result, expected_sig); + } + + #[tokio::test] + async fn test_sign_error_handling() { + let mut mock_service = MockCdpServiceTrait::new(); + + // Create a proper Solana message and serialize it with bincode + use solana_sdk::{ + hash::Hash, + message::Message, + pubkey::Pubkey, + signature::{Keypair, Signer}, + }; + use solana_system_interface::instruction; + + let payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + let instruction = instruction::transfer(&payer.pubkey(), &recipient, 1000); + let message = Message::new(&[instruction], Some(&payer.pubkey())); + let test_message = bincode::serialize(&message).unwrap(); + + mock_service + .expect_sign_solana_transaction() + .times(1) + .returning(move |_| { + Box::pin(async { Err(CdpError::SigningError("Mock signing error".into())) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + + let result = signer.sign(&test_message).await; + + assert!(result.is_err()); + match result { + Err(SignerError::CdpError(err)) => { + assert_eq!(err.to_string(), "Signing error: Mock signing error"); + } + _ => panic!("Expected CdpError error variant"), + } + } + + #[tokio::test] + async fn test_sign_invalid_transaction_data() { + let mut mock_service = MockCdpServiceTrait::new(); + + // Create a proper Solana message and serialize it with bincode + use solana_sdk::{ + hash::Hash, + message::Message, + pubkey::Pubkey, + signature::{Keypair, Signer}, + }; + use solana_system_interface::instruction; + + let payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + let instruction = instruction::transfer(&payer.pubkey(), &recipient, 1000); + let message = Message::new(&[instruction], Some(&payer.pubkey())); + let test_message = bincode::serialize(&message).unwrap(); + + // Return invalid transaction data (not a valid serialized transaction) + mock_service + .expect_sign_solana_transaction() + .times(1) + .returning(move |_| { + let invalid_tx_data = vec![1u8; 32]; // Invalid transaction data + Box::pin(async { Ok(invalid_tx_data) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + + let result = signer.sign(&test_message).await; + assert!(result.is_err()); + match result { + Err(SignerError::SigningError(msg)) => { + assert!(msg.contains("Failed to decode signed transaction")); + } + _ => panic!("Expected SigningError error variant"), + } + } + + #[tokio::test] + async fn test_sign_transaction_success() { + let mut mock_service = MockCdpServiceTrait::new(); + + let test_transaction = "transaction_123".to_string(); + let mock_signed_transaction = vec![1u8; 64]; // Mock signed transaction bytes + + mock_service + .expect_sign_solana_transaction() + .times(1) + .with(eq(test_transaction.clone())) + .returning(move |_| { + let signed_tx = mock_signed_transaction.clone(); + Box::pin(async { Ok(signed_tx) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + + let tx_data = SolanaTransactionData { + transaction: test_transaction, + signature: None, + }; + + let result = signer + .sign_transaction(NetworkTransactionData::Solana(tx_data)) + .await; + + assert!(result.is_ok()); + match result.unwrap() { + SignTransactionResponse::Solana(signed_tx) => { + assert_eq!(signed_tx, vec![1u8; 64]); + } + _ => panic!("Expected Solana SignTransactionResponse"), + } + } + + #[tokio::test] + async fn test_sign_transaction_error() { + let mut mock_service = MockCdpServiceTrait::new(); + + let test_transaction = "transaction_123".to_string(); + + mock_service + .expect_sign_solana_transaction() + .times(1) + .with(eq(test_transaction.clone())) + .returning(move |_| { + Box::pin(async { Err(CdpError::SigningError("Mock signing error".into())) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + + let tx_data = SolanaTransactionData { + transaction: test_transaction, + signature: None, + }; + + let result = signer + .sign_transaction(NetworkTransactionData::Solana(tx_data)) + .await; + + assert!(result.is_err()); + match result { + Err(SignerError::CdpError(err)) => { + assert_eq!(err.to_string(), "Signing error: Mock signing error"); + } + _ => panic!("Expected CdpError error variant"), + } + } + + #[tokio::test] + async fn test_address_error_handling() { + let mut mock_service = MockCdpServiceTrait::new(); + + mock_service + .expect_account_address() + .times(1) + .returning(|| { + Box::pin(async { Err(CdpError::ConfigError("Invalid public key".to_string())) }) + }); + + let signer = CdpSigner::new_for_testing(mock_service); + let result = signer.address().await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_sign_missing_cdp_pubkey() { + let mut mock = MockCdpServiceTrait::new(); + + // Build a tx whose required signer is NOT the CDP pubkey + use solana_sdk::{message::Message, pubkey::Pubkey, signature::Keypair}; + use solana_system_interface::instruction; + + let payer = Keypair::new(); + let other = Pubkey::new_unique(); + let ix = instruction::transfer(&payer.pubkey(), &other, 1); + let msg = Message::new(&[ix], Some(&payer.pubkey())); + let msg_bytes = bincode::serialize(&msg).unwrap(); + + // Return a signed tx with a signature but a different signer key ordering + let mut tx = Transaction::new_unsigned(msg.clone()); + tx.signatures = vec![Signature::from([2u8; 64])]; + let tx_bytes = bincode::serialize(&tx).unwrap(); + + let other_str = other.to_string(); + mock.expect_account_address().returning(move || { + let other_clone = other_str.clone(); + Box::pin(async move { Ok(Address::Solana(other_clone)) }) + }); + mock.expect_sign_solana_transaction().returning(move |_| { + let tx_bytes_clone = tx_bytes.clone(); + Box::pin(async move { Ok(tx_bytes_clone) }) + }); + + let signer = CdpSigner::new_for_testing(mock); + let res = signer.sign(&msg_bytes).await; + assert!(matches!(res, Err(SignerError::SigningError(_)))); + } +} diff --git a/src/services/signer/solana/mod.rs b/src/services/signer/solana/mod.rs index 8515a0366..0bdb4316b 100644 --- a/src/services/signer/solana/mod.rs +++ b/src/services/signer/solana/mod.rs @@ -27,6 +27,9 @@ use vault_transit_signer::*; mod turnkey_signer; use turnkey_signer::*; +mod cdp_signer; +use cdp_signer::*; + mod google_cloud_kms_signer; use google_cloud_kms_signer::*; @@ -41,7 +44,7 @@ use crate::{ Address, NetworkTransactionData, Signer as SignerDomainModel, SignerConfig, SignerRepoModel, SignerType, TransactionRepoModel, VaultSignerConfig, }, - services::{GoogleCloudKmsService, TurnkeyService, VaultConfig, VaultService}, + services::{CdpService, GoogleCloudKmsService, TurnkeyService, VaultConfig, VaultService}, }; use eyre::Result; @@ -54,6 +57,7 @@ pub enum SolanaSigner { Vault(VaultSigner), VaultTransit(VaultTransitSigner), Turnkey(TurnkeySigner), + Cdp(CdpSigner), GoogleCloudKms(GoogleCloudKmsSigner), } @@ -65,6 +69,7 @@ impl Signer for SolanaSigner { Self::Vault(signer) => signer.address().await, Self::VaultTransit(signer) => signer.address().await, Self::Turnkey(signer) => signer.address().await, + Self::Cdp(signer) => signer.address().await, Self::GoogleCloudKms(signer) => signer.address().await, } } @@ -78,6 +83,7 @@ impl Signer for SolanaSigner { Self::Vault(signer) => signer.sign_transaction(transaction).await, Self::VaultTransit(signer) => signer.sign_transaction(transaction).await, Self::Turnkey(signer) => signer.sign_transaction(transaction).await, + Self::Cdp(signer) => signer.sign_transaction(transaction).await, Self::GoogleCloudKms(signer) => signer.sign_transaction(transaction).await, } } @@ -113,6 +119,7 @@ impl SolanaSignTrait for SolanaSigner { Self::Vault(signer) => signer.pubkey().await, Self::VaultTransit(signer) => signer.pubkey().await, Self::Turnkey(signer) => signer.pubkey().await, + Self::Cdp(signer) => signer.pubkey().await, Self::GoogleCloudKms(signer) => signer.pubkey().await, } } @@ -123,6 +130,7 @@ impl SolanaSignTrait for SolanaSigner { Self::Vault(signer) => Ok(signer.sign(message).await?), Self::VaultTransit(signer) => Ok(signer.sign(message).await?), Self::Turnkey(signer) => Ok(signer.sign(message).await?), + Self::Cdp(signer) => Ok(signer.sign(message).await?), Self::GoogleCloudKms(signer) => Ok(signer.sign(message).await?), } } @@ -174,6 +182,12 @@ impl SolanaSignerFactory { SignerConfig::AwsKms(_) => { return Err(SignerFactoryError::UnsupportedType("AWS KMS".into())); } + SignerConfig::Cdp(config) => { + let cdp_signer = CdpSigner::new(config.clone()).map_err(|e| { + SignerFactoryError::CreationFailed(format!("CDP service error: {}", e)) + })?; + return Ok(SolanaSigner::Cdp(cdp_signer)); + } SignerConfig::Turnkey(turnkey_signer_config) => { let turnkey_service = TurnkeyService::new(turnkey_signer_config.clone()).map_err(|e| { @@ -207,10 +221,10 @@ impl SolanaSignerFactory { mod solana_signer_factory_tests { use super::*; use crate::models::{ - AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, - GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, SecretString, SignerConfig, - SignerRepoModel, SolanaTransactionData, TurnkeySignerConfig, VaultSignerConfig, - VaultTransitSignerConfig, + AwsKmsSignerConfig, CdpSignerConfig, GoogleCloudKmsSignerConfig, + GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, + SecretString, SignerConfig, SignerRepoModel, SolanaTransactionData, TurnkeySignerConfig, + VaultSignerConfig, VaultTransitSignerConfig, }; use mockall::predicate::*; use secrets::SecretVec; @@ -328,6 +342,26 @@ mod solana_signer_factory_tests { } } + #[test] + fn test_create_solana_signer_cdp() { + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Cdp(CdpSignerConfig { + api_key_id: "test-api-key-id".to_string(), + api_key_secret: SecretString::new("test-api-key-secret"), + wallet_secret: SecretString::new("test-wallet-secret"), + account_address: "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2".to_string(), + }), + }; + + let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); + + match signer { + SolanaSigner::Cdp(_) => {} + _ => panic!("Expected CDP signer"), + } + } + #[tokio::test] async fn test_create_solana_signer_google_cloud_kms() { let signer_model = SignerDomainModel { @@ -428,6 +462,28 @@ mod solana_signer_factory_tests { assert_eq!(expected_pubkey, signer_pubkey); } + #[tokio::test] + async fn test_address_solana_signer_cdp() { + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Cdp(CdpSignerConfig { + api_key_id: "test-api-key-id".to_string(), + api_key_secret: SecretString::new("test-api-key-secret"), + wallet_secret: SecretString::new("test-wallet-secret"), + account_address: "6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2".to_string(), + }), + }; + let expected_pubkey = + Address::Solana("6s7RsvzcdXFJi1tXeDoGfSKZFzN3juVt9fTar6WEhEm2".to_string()); + + let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); + let signer_address = signer.address().await.unwrap(); + let signer_pubkey = signer.pubkey().await.unwrap(); + + assert_eq!(expected_pubkey, signer_address); + assert_eq!(expected_pubkey, signer_pubkey); + } + #[tokio::test] async fn test_address_solana_signer_google_cloud_kms() { let signer_model = SignerDomainModel { diff --git a/src/services/signer/stellar/mod.rs b/src/services/signer/stellar/mod.rs index fbc7a53b6..c1757a02e 100644 --- a/src/services/signer/stellar/mod.rs +++ b/src/services/signer/stellar/mod.rs @@ -134,6 +134,7 @@ impl StellarSignerFactory { SignerConfig::Turnkey(_) => { return Err(SignerFactoryError::UnsupportedType("Turnkey".into())) } + SignerConfig::Cdp(_) => return Err(SignerFactoryError::UnsupportedType("CDP".into())), SignerConfig::GoogleCloudKms(_) => { return Err(SignerFactoryError::UnsupportedType( "Google Cloud KMS".into(),