From b723acb5cb073b9dcee411bf2a5dfbb32897660e Mon Sep 17 00:00:00 2001 From: 8e8b2c <138928994+8e8b2c@users.noreply.github.com> Date: Mon, 8 Jul 2024 13:40:46 +0100 Subject: [PATCH] feat: EvmSigningOffer (#29) * feat: EvmSigningOffer * fix: build evm-bytes-signer in e2e --- .github/workflows/e2e.yml | 5 + Cargo.lock | 1 + Cargo.toml | 1 + crates/holoom_types/src/evm_signing_offer.rs | 54 ++++++ crates/holoom_types/src/lib.rs | 33 +++- crates/holoom_types/src/recipe.rs | 4 + .../username_registry_coordinator/Cargo.toml | 1 + .../src/evm_signing_offer.rs | 160 ++++++++++++++++++ .../username_registry_coordinator/src/lib.rs | 15 ++ .../src/recipe_execution.rs | 3 + .../src/entry_types.rs | 6 + .../src/link_types.rs | 26 +++ .../src/evm_signing_offer.rs | 9 + .../username_registry_validation/src/lib.rs | 6 + .../src/name_to_evm_signing_offer.rs | 21 +++ .../src/name_to_recipe.rs | 21 +++ .../src/recipe_execution.rs | 3 + docker/evm-bytes-signer/Dockerfile | 15 ++ package-lock.json | 155 ++++++----------- package.json | 3 +- .../evm-bytes-signature-requestor-client.ts | 104 ++++++++++++ packages/client/src/index.ts | 1 + packages/client/src/types.ts | 34 +++- packages/e2e/src/main.ts | 12 +- packages/e2e/tests/signing-offer.test.js | 130 ++++++++++++++ packages/e2e/tests/utils/testcontainers.js | 29 ++++ packages/evm-bytes-signer/package.json | 24 +++ packages/evm-bytes-signer/src/bytes-signer.ts | 24 +++ .../src/evm-bytes-signer-client.ts | 84 +++++++++ packages/evm-bytes-signer/src/index.ts | 78 +++++++++ packages/evm-bytes-signer/src/types.ts | 79 +++++++++ packages/evm-bytes-signer/src/utils.ts | 10 ++ packages/evm-bytes-signer/tsconfig.json | 109 ++++++++++++ scripts/build_docker_images.sh | 5 + 34 files changed, 1159 insertions(+), 106 deletions(-) create mode 100644 crates/holoom_types/src/evm_signing_offer.rs create mode 100644 crates/username_registry_coordinator/src/evm_signing_offer.rs create mode 100644 crates/username_registry_validation/src/evm_signing_offer.rs create mode 100644 crates/username_registry_validation/src/name_to_evm_signing_offer.rs create mode 100644 crates/username_registry_validation/src/name_to_recipe.rs create mode 100644 docker/evm-bytes-signer/Dockerfile create mode 100644 packages/client/src/evm-bytes-signature-requestor-client.ts create mode 100644 packages/e2e/tests/signing-offer.test.js create mode 100644 packages/evm-bytes-signer/package.json create mode 100644 packages/evm-bytes-signer/src/bytes-signer.ts create mode 100644 packages/evm-bytes-signer/src/evm-bytes-signer-client.ts create mode 100644 packages/evm-bytes-signer/src/index.ts create mode 100644 packages/evm-bytes-signer/src/types.ts create mode 100644 packages/evm-bytes-signer/src/utils.ts create mode 100644 packages/evm-bytes-signer/tsconfig.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 899f1c6..35e22e7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -139,6 +139,7 @@ jobs: - run: npx puppeteer browsers install chrome - run: npm run build:client - run: npm run build:external-id-attestor + - run: npm run build:evm-bytes-signer - run: npm run build:mock-oracle - name: Save client build for possible publish uses: actions/upload-artifact@v4 @@ -161,6 +162,10 @@ jobs: -t holoom/mock-oracle \ -f docker/mock-oracle/Dockerfile \ packages/mock-oracle + docker build \ + -t holoom/evm-bytes-signer \ + -f docker/evm-bytes-signer/Dockerfile \ + packages/evm-bytes-signer docker build -t holoom/rocket docker/rocket - name: Start frontend in background run: npm run dev -w @holoom/e2e & diff --git a/Cargo.lock b/Cargo.lock index 7c1c05e..e2d8c3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9203,6 +9203,7 @@ version = "0.0.1" dependencies = [ "bincode", "hdk", + "hex", "holoom_types", "indexmap 2.2.5", "jaq_wrapper", diff --git a/Cargo.toml b/Cargo.toml index 5f988ce..e314fd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ holo_hash = { version = "=0.2.6", features = ["encoding"] } serde = "=1.0.166" serde_json = "1.0.109" bincode = "1.3.3" +hex = "0.4.3" alloy-primitives = { version = "0.6.3", features = ["serde", "k256"] } ed25519-dalek = { version = "2.1.1", features = ["serde"] } bs58 = "0.5.0" diff --git a/crates/holoom_types/src/evm_signing_offer.rs b/crates/holoom_types/src/evm_signing_offer.rs new file mode 100644 index 0000000..141174a --- /dev/null +++ b/crates/holoom_types/src/evm_signing_offer.rs @@ -0,0 +1,54 @@ +use hdi::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{EvmAddress, EvmSignature}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(tag = "type")] +pub enum EvmU256Item { + Uint, + Hex, +} + +#[hdk_entry_helper] +#[derive(Clone, PartialEq)] +pub struct EvmSigningOffer { + pub recipe_ah: ActionHash, + pub u256_items: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateEvmSigningOfferPayload { + pub identifier: String, + pub evm_signing_offer: EvmSigningOffer, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct EvmSignatureOverRecipeExecutionRequest { + pub request_id: String, + pub recipe_execution_ah: ActionHash, + pub signing_offer_ah: ActionHash, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ResolveEvmSignatureOverRecipeExecutionRequestPayload { + pub request_id: String, + pub requestor: AgentPubKey, + pub signed_u256_array: SignedEvmU256Array, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RejectEvmSignatureOverRecipeExecutionRequestPayload { + pub request_id: String, + pub requestor: AgentPubKey, + pub reason: String, +} + +pub type EvmU256 = alloy_primitives::U256; + +#[derive(Serialize, Deserialize, Debug)] +pub struct SignedEvmU256Array { + pub raw: Vec, + pub signature: EvmSignature, + pub signer: EvmAddress, +} diff --git a/crates/holoom_types/src/lib.rs b/crates/holoom_types/src/lib.rs index f422cc1..a342dc9 100644 --- a/crates/holoom_types/src/lib.rs +++ b/crates/holoom_types/src/lib.rs @@ -1,8 +1,10 @@ +use evm_signing_offer::{EvmU256, SignedEvmU256Array}; use hdi::prelude::*; use serde::{Deserialize, Serialize}; pub mod external_id; pub use external_id::*; +pub mod evm_signing_offer; pub mod metadata; pub mod recipe; pub use metadata::*; @@ -30,12 +32,39 @@ pub enum LocalHoloomSignal { request_id: String, reason: String, }, + EvmSignatureRequested { + request_id: String, + requestor_pubkey: AgentPubKey, + u256_array: Vec, + }, + EvmSignatureProvided { + request_id: String, + signed_u256_array: SignedEvmU256Array, + }, + EvmSignatureRequestRejected { + request_id: String, + reason: String, + }, } #[derive(Serialize, Deserialize, Debug)] pub enum RemoteHoloomSignal { - ExternalIdAttested { request_id: String, record: Record }, - ExternalIdRejected { request_id: String, reason: String }, + ExternalIdAttested { + request_id: String, + record: Record, + }, + ExternalIdRejected { + request_id: String, + reason: String, + }, + EvmSignatureProvided { + request_id: String, + signed_u256_array: SignedEvmU256Array, + }, + EvmSignatureRequestRejected { + request_id: String, + reason: String, + }, } #[derive(Serialize, Deserialize, Debug, Clone, SerializedBytes)] diff --git a/crates/holoom_types/src/recipe.rs b/crates/holoom_types/src/recipe.rs index bae5c8b..c849ac2 100644 --- a/crates/holoom_types/src/recipe.rs +++ b/crates/holoom_types/src/recipe.rs @@ -1,10 +1,13 @@ use hdi::prelude::*; use serde::{Deserialize, Serialize}; +use crate::EvmAddress; + #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] #[serde(tag = "type")] pub enum RecipeArgumentType { String, + EvmAddress, } #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] @@ -46,6 +49,7 @@ pub struct Recipe { #[serde(tag = "type")] pub enum RecipeArgument { String { value: String }, + EvmAddress { value: EvmAddress }, } #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] diff --git a/crates/username_registry_coordinator/Cargo.toml b/crates/username_registry_coordinator/Cargo.toml index f3e39b9..ba33e43 100644 --- a/crates/username_registry_coordinator/Cargo.toml +++ b/crates/username_registry_coordinator/Cargo.toml @@ -18,3 +18,4 @@ username_registry_utils = { workspace = true } jaq_wrapper = { workspace = true } indexmap = "2.2.5" serde_json = { workspace = true } +hex = { workspace = true } diff --git a/crates/username_registry_coordinator/src/evm_signing_offer.rs b/crates/username_registry_coordinator/src/evm_signing_offer.rs new file mode 100644 index 0000000..8bc5483 --- /dev/null +++ b/crates/username_registry_coordinator/src/evm_signing_offer.rs @@ -0,0 +1,160 @@ +use hdk::prelude::*; +use holoom_types::{ + evm_signing_offer::{ + CreateEvmSigningOfferPayload, EvmSignatureOverRecipeExecutionRequest, EvmSigningOffer, + EvmU256, EvmU256Item, RejectEvmSignatureOverRecipeExecutionRequestPayload, + ResolveEvmSignatureOverRecipeExecutionRequestPayload, + }, + recipe::RecipeExecution, + LocalHoloomSignal, RemoteHoloomSignal, +}; +use jaq_wrapper::{parse_single_json, Val}; +use username_registry_integrity::{EntryTypes, LinkTypes}; +use username_registry_utils::{deserialize_record_entry, hash_identifier}; + +#[hdk_extern] +fn create_evm_signing_offer(payload: CreateEvmSigningOfferPayload) -> ExternResult { + let action_hash = create_entry(EntryTypes::EvmSigningOffer(payload.evm_signing_offer))?; + create_link( + hash_identifier(payload.identifier)?, + action_hash.clone(), + LinkTypes::NameToSigningOffer, + (), + )?; + get(action_hash, GetOptions::default())?.ok_or(wasm_error!(WasmErrorInner::Guest( + "Couldn't get newly created EvmSigningOffer Record".into() + ))) +} + +#[hdk_extern] +pub fn get_latest_evm_signing_offer_ah_for_name(name: String) -> ExternResult> { + let base_address = hash_identifier(name)?; + let mut links = get_links(base_address, LinkTypes::NameToSigningOffer, None)?; + links.sort_by_key(|link| link.timestamp); + let Some(link) = links.pop() else { + return Ok(None); + }; + let action_hash = ActionHash::try_from(link.target).map_err(|_| { + wasm_error!(WasmErrorInner::Guest( + "Link target isn't an ActionHash".into() + )) + })?; + Ok(Some(action_hash)) +} + +#[hdk_extern] +fn send_request_for_evm_signature_over_recipe_execution( + request: EvmSignatureOverRecipeExecutionRequest, +) -> ExternResult<()> { + let signing_offer_record = get(request.signing_offer_ah.clone(), GetOptions::default())? + .ok_or(wasm_error!(WasmErrorInner::Guest( + "EvmSigningOffer not found".into() + )))?; + let signing_agent = signing_offer_record.action().author().clone(); + let zome_name: ZomeName = zome_info()?.name; + let fn_name = FunctionName::from("ingest_evm_signature_over_recipe_execution_request"); + let resp = call_remote(signing_agent, zome_name, fn_name, None, request)?; + match resp { + ZomeCallResponse::Ok(result) => result.decode().map_err(|err| wasm_error!(err)), + ZomeCallResponse::NetworkError(err) => Err(wasm_error!(WasmErrorInner::Guest(format!( + "There was a network error: {:?}", + err + )))), + ZomeCallResponse::Unauthorized(..) => { + Err(wasm_error!(WasmErrorInner::Guest("Unauthorized".into()))) + } + ZomeCallResponse::CountersigningSession(_) => Err(wasm_error!(WasmErrorInner::Guest( + "Unexpected countersigning session".into() + ))), + } +} + +#[hdk_extern] +fn ingest_evm_signature_over_recipe_execution_request( + payload: EvmSignatureOverRecipeExecutionRequest, +) -> ExternResult<()> { + let signing_offer_record = get(payload.signing_offer_ah, GetOptions::default())?.ok_or( + wasm_error!(WasmErrorInner::Guest("EvmSigningOffer not found".into())), + )?; + let signing_offer: EvmSigningOffer = deserialize_record_entry(signing_offer_record)?; + let recipe_execution_record = get(payload.recipe_execution_ah, GetOptions::default())?.ok_or( + wasm_error!(WasmErrorInner::Guest("RecipeExecution not found".into())), + )?; + let recipe_execution: RecipeExecution = deserialize_record_entry(recipe_execution_record)?; + + if recipe_execution.recipe_ah != signing_offer.recipe_ah { + return Err(wasm_error!(WasmErrorInner::Guest( + "Executed Recipe doesn't match signing offer".into() + ))); + } + let Val::Arr(output_vec) = parse_single_json(&recipe_execution.output)? else { + return Err(wasm_error!(WasmErrorInner::Guest( + "Recipe output isn't an array".into() + )))?; + }; + if output_vec.len() != signing_offer.u256_items.len() { + return Err(wasm_error!(WasmErrorInner::Guest( + "Unexpected u256 count for signing".into() + )))?; + } + let u256_array = output_vec + .iter() + .zip(signing_offer.u256_items.into_iter()) + .map(|pair| match pair { + (Val::Str(hex_string), EvmU256Item::Hex) => EvmU256::from_str_radix(&hex_string, 16) + .map_err(|_| wasm_error!(WasmErrorInner::Guest("Invalid hex string".into()))), + (Val::Int(value), EvmU256Item::Uint) => { + if *value < 0 { + Err(wasm_error!(WasmErrorInner::Guest( + "Negative ints unsupported".into() + ))) + } else { + Ok(EvmU256::from(*value)) + } + } + _ => Err(wasm_error!(WasmErrorInner::Guest( + "Invalid U256 array element".into() + ))), + }) + .collect::>>()?; + + let signal = LocalHoloomSignal::EvmSignatureRequested { + request_id: payload.request_id, + requestor_pubkey: call_info()?.provenance, + u256_array, + }; + emit_signal(signal)?; + Ok(()) +} + +#[hdk_extern] +pub fn resolve_evm_signature_over_recipe_execution_request( + payload: ResolveEvmSignatureOverRecipeExecutionRequestPayload, +) -> ExternResult<()> { + let signal = RemoteHoloomSignal::EvmSignatureProvided { + request_id: payload.request_id, + signed_u256_array: payload.signed_u256_array, + }; + let signal_encoded = ExternIO::encode(signal) + .map_err(|err: SerializedBytesError| wasm_error!(WasmErrorInner::Serialize(err)))?; + let recipients = vec![payload.requestor]; + remote_signal(signal_encoded, recipients)?; + + Ok(()) +} + +#[hdk_extern] +pub fn reject_evm_signature_over_recipe_execution_request( + payload: RejectEvmSignatureOverRecipeExecutionRequestPayload, +) -> ExternResult<()> { + let signal = RemoteHoloomSignal::ExternalIdRejected { + request_id: payload.request_id, + reason: payload.reason, + }; + let signal_encoded = ExternIO::encode(signal) + .map_err(|err: SerializedBytesError| wasm_error!(WasmErrorInner::Serialize(err)))?; + let recipients = vec![payload.requestor]; + remote_signal(signal_encoded, recipients)?; + + Ok(()) +} diff --git a/crates/username_registry_coordinator/src/lib.rs b/crates/username_registry_coordinator/src/lib.rs index fd1e6c4..599e8c7 100644 --- a/crates/username_registry_coordinator/src/lib.rs +++ b/crates/username_registry_coordinator/src/lib.rs @@ -1,3 +1,4 @@ +pub mod evm_signing_offer; pub mod external_id_attestation; pub mod jq_execution; pub mod oracle_document; @@ -23,6 +24,10 @@ pub fn init(_: ()) -> ExternResult { zome_name.clone(), "ingest_external_id_attestation_request".into(), )); + functions.insert(( + zome_name.clone(), + "ingest_evm_signature_over_recipe_execution_request".into(), + )); } functions.insert((zome_name, "recv_remote_signal".into())); create_cap_grant(CapGrantEntry { @@ -47,6 +52,16 @@ fn recv_remote_signal(signal_io: ExternIO) -> ExternResult<()> { RemoteHoloomSignal::ExternalIdRejected { request_id, reason } => { emit_signal(LocalHoloomSignal::ExternalIdRejected { request_id, reason })? } + RemoteHoloomSignal::EvmSignatureProvided { + request_id, + signed_u256_array, + } => emit_signal(LocalHoloomSignal::EvmSignatureProvided { + request_id, + signed_u256_array, + })?, + RemoteHoloomSignal::EvmSignatureRequestRejected { request_id, reason } => { + emit_signal(LocalHoloomSignal::EvmSignatureRequestRejected { request_id, reason })? + } } Ok(()) diff --git a/crates/username_registry_coordinator/src/recipe_execution.rs b/crates/username_registry_coordinator/src/recipe_execution.rs index 2f040bc..7ff6d46 100644 --- a/crates/username_registry_coordinator/src/recipe_execution.rs +++ b/crates/username_registry_coordinator/src/recipe_execution.rs @@ -46,6 +46,9 @@ pub fn execute_recipe(payload: ExecuteRecipePayload) -> ExternResult { (RecipeArgument::String { value }, RecipeArgumentType::String) => { Val::str(value.clone()) } + (RecipeArgument::EvmAddress { value }, RecipeArgumentType::EvmAddress) => { + Val::str(value.to_string()) + } _ => { return Err(wasm_error!(WasmErrorInner::Guest( "Bad recipe argument".into() diff --git a/crates/username_registry_integrity/src/entry_types.rs b/crates/username_registry_integrity/src/entry_types.rs index 561d130..912fe6f 100644 --- a/crates/username_registry_integrity/src/entry_types.rs +++ b/crates/username_registry_integrity/src/entry_types.rs @@ -1,5 +1,6 @@ use hdi::prelude::*; use holoom_types::{ + evm_signing_offer::EvmSigningOffer, recipe::{Recipe, RecipeExecution}, ExternalIdAttestation, JqExecution, OracleDocument, OracleDocumentListSnapshot, UsernameAttestation, WalletAttestation, @@ -19,6 +20,7 @@ pub enum EntryTypes { JqExecution(JqExecution), Recipe(Recipe), RecipeExecution(RecipeExecution), + EvmSigningOffer(EvmSigningOffer), } impl EntryTypes { @@ -62,6 +64,10 @@ impl EntryTypes { EntryCreationAction::Create(action), recipe_execution, ), + EntryTypes::EvmSigningOffer(evm_signing_offer) => validate_create_evm_signing_offer( + EntryCreationAction::Create(action), + evm_signing_offer, + ), } } } diff --git a/crates/username_registry_integrity/src/link_types.rs b/crates/username_registry_integrity/src/link_types.rs index ff2eb33..ecc5035 100644 --- a/crates/username_registry_integrity/src/link_types.rs +++ b/crates/username_registry_integrity/src/link_types.rs @@ -11,6 +11,8 @@ pub enum LinkTypes { ExternalIdToAttestation, NameToOracleDocument, RelateOracleDocumentName, + NameToRecipe, + NameToSigningOffer, } impl LinkTypes { @@ -69,6 +71,15 @@ impl LinkTypes { tag, ) } + LinkTypes::NameToRecipe => { + validate_create_link_name_to_recipe(action, base_address, target_address, tag) + } + LinkTypes::NameToSigningOffer => validate_create_link_name_to_evm_signing_offer( + action, + base_address, + target_address, + tag, + ), } } @@ -138,6 +149,21 @@ impl LinkTypes { tag, ) } + + LinkTypes::NameToRecipe => validate_delete_link_name_to_recipe( + action, + original_action, + base_address, + target_address, + tag, + ), + LinkTypes::NameToSigningOffer => validate_delete_link_name_to_evm_signing_offer( + action, + original_action, + base_address, + target_address, + tag, + ), } } } diff --git a/crates/username_registry_validation/src/evm_signing_offer.rs b/crates/username_registry_validation/src/evm_signing_offer.rs new file mode 100644 index 0000000..62ad6f7 --- /dev/null +++ b/crates/username_registry_validation/src/evm_signing_offer.rs @@ -0,0 +1,9 @@ +use hdi::prelude::*; +use holoom_types::evm_signing_offer::EvmSigningOffer; + +pub fn validate_create_evm_signing_offer( + _action: EntryCreationAction, + _evm_signing_offer: EvmSigningOffer, +) -> ExternResult { + Ok(ValidateCallbackResult::Valid) +} diff --git a/crates/username_registry_validation/src/lib.rs b/crates/username_registry_validation/src/lib.rs index 9de6d32..0e79ba7 100644 --- a/crates/username_registry_validation/src/lib.rs +++ b/crates/username_registry_validation/src/lib.rs @@ -28,3 +28,9 @@ pub mod recipe_execution; pub use recipe_execution::*; pub mod relate_oracle_document_name; pub use relate_oracle_document_name::*; +pub mod evm_signing_offer; +pub use evm_signing_offer::*; +pub mod name_to_recipe; +pub use name_to_recipe::*; +pub mod name_to_evm_signing_offer; +pub use name_to_evm_signing_offer::*; diff --git a/crates/username_registry_validation/src/name_to_evm_signing_offer.rs b/crates/username_registry_validation/src/name_to_evm_signing_offer.rs new file mode 100644 index 0000000..2519d49 --- /dev/null +++ b/crates/username_registry_validation/src/name_to_evm_signing_offer.rs @@ -0,0 +1,21 @@ +use hdi::prelude::*; + +pub fn validate_create_link_name_to_evm_signing_offer( + _action: CreateLink, + _base_address: AnyLinkableHash, + _target_address: AnyLinkableHash, + _tag: LinkTag, +) -> ExternResult { + Ok(ValidateCallbackResult::Valid) +} +pub fn validate_delete_link_name_to_evm_signing_offer( + _action: DeleteLink, + _original_action: CreateLink, + _base_address: AnyLinkableHash, + _target_address: AnyLinkableHash, + _tag: LinkTag, +) -> ExternResult { + Ok(ValidateCallbackResult::Invalid( + "Cannot delete NameToEvmSigningOffer links".into(), + )) +} diff --git a/crates/username_registry_validation/src/name_to_recipe.rs b/crates/username_registry_validation/src/name_to_recipe.rs new file mode 100644 index 0000000..41d8bff --- /dev/null +++ b/crates/username_registry_validation/src/name_to_recipe.rs @@ -0,0 +1,21 @@ +use hdi::prelude::*; + +pub fn validate_create_link_name_to_recipe( + _action: CreateLink, + _base_address: AnyLinkableHash, + _target_address: AnyLinkableHash, + _tag: LinkTag, +) -> ExternResult { + Ok(ValidateCallbackResult::Valid) +} +pub fn validate_delete_link_name_to_recipe( + _action: DeleteLink, + _original_action: CreateLink, + _base_address: AnyLinkableHash, + _target_address: AnyLinkableHash, + _tag: LinkTag, +) -> ExternResult { + Ok(ValidateCallbackResult::Invalid( + "Cannot delete NameToRecipe links".into(), + )) +} diff --git a/crates/username_registry_validation/src/recipe_execution.rs b/crates/username_registry_validation/src/recipe_execution.rs index ea2f656..f9894e4 100644 --- a/crates/username_registry_validation/src/recipe_execution.rs +++ b/crates/username_registry_validation/src/recipe_execution.rs @@ -36,6 +36,9 @@ pub fn validate_create_recipe_execution( (RecipeArgument::String { value }, RecipeArgumentType::String) => { Val::str(value.clone()) } + (RecipeArgument::EvmAddress { value }, RecipeArgumentType::EvmAddress) => { + Val::str(value.to_string()) + } _ => { return Ok(ValidateCallbackResult::Invalid( "Bad recipe argument".into(), diff --git a/docker/evm-bytes-signer/Dockerfile b/docker/evm-bytes-signer/Dockerfile new file mode 100644 index 0000000..32c8ce4 --- /dev/null +++ b/docker/evm-bytes-signer/Dockerfile @@ -0,0 +1,15 @@ +FROM node:alpine +USER node +WORKDIR /home/node +ENV NODE_ENV=production +COPY package.json ./package.json +RUN npm i +COPY dist ./dist + +ENV HOLOCHAIN_HOST_NAME= +ENV HOLOCHAIN_ADMIN_WS_PORT= +ENV HOLOCHAIN_APP_WS_PORT= +ENV HOLOCHAIN_APP_ID= +ENV EVM_PRIVATE_KEY= + +CMD [ "npm", "start" ] diff --git a/package-lock.json b/package-lock.json index acb44b3..6f4bafd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1130,6 +1130,10 @@ "resolved": "packages/e2e", "link": true }, + "node_modules/@holoom/evm-bytes-signer": { + "resolved": "packages/evm-bytes-signer", + "link": true + }, "node_modules/@holoom/external-id-attestor": { "resolved": "packages/external-id-attestor", "link": true @@ -1501,7 +1505,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.0.tgz", "integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==", - "dev": true, "dependencies": { "@noble/hashes": "1.4.0" }, @@ -1524,7 +1527,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true, "engines": { "node": ">= 16" }, @@ -1761,77 +1763,33 @@ ] }, "node_modules/@scure/base": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.5.tgz", - "integrity": "sha512-Brj9FiG2W1MRQSTB212YVPRrcbjkv48FoZi/u4l/zds/ieRrqsh7aUf6CLwkAq61oKXr/ZlTzlY66gLIj3TFTQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.7.tgz", + "integrity": "sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g==", "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip32": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.2.tgz", - "integrity": "sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA==", - "dependencies": { - "@noble/curves": "~1.2.0", - "@noble/hashes": "~1.3.2", - "@scure/base": "~1.1.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32/node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz", + "integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==", "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32/node_modules/@noble/hashes": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", - "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", - "engines": { - "node": ">= 16" + "@noble/curves": "~1.4.0", + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip39": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", - "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.3.0.tgz", + "integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==", "dependencies": { - "@noble/hashes": "~1.3.0", - "@scure/base": "~1.1.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39/node_modules/@noble/hashes": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", - "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", - "engines": { - "node": ">= 16" + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -2234,9 +2192,9 @@ "dev": true }, "node_modules/abitype": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.0.tgz", - "integrity": "sha512-NMeMah//6bJ56H5XRj8QCV4AwuW6hB6zqz2LnhhLdcWVQOsXki6/Pn3APeqxCma62nXIcmZWdu1DlHWS74umVQ==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.5.tgz", + "integrity": "sha512-YzDhti7cjlfaBhHutMaboYB21Ha3rXR9QTkNJFzYC4kC8YclaiwPBBBJY8ejFdu2wnJeZCVZSMlQJ7fi8S6hsw==", "funding": { "url": "https://github.com/sponsors/wevm" }, @@ -4939,9 +4897,9 @@ } }, "node_modules/isows": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.3.tgz", - "integrity": "sha512-2cKei4vlmg2cxEjm3wVSqn8pcoRF/LX/wpifuuNquFO4SQmPwarClT+SUCA2lt+l581tTeZIPIZuIDo2jWN1fg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.4.tgz", + "integrity": "sha512-hEzjY+x9u9hPmBom9IIAqdJCwNLax+xrPb51vEPpERoFlIxgmZcHzsT5jKG06nvInKOBGvReAVz80Umed5CczQ==", "funding": [ { "type": "github", @@ -8238,9 +8196,9 @@ } }, "node_modules/viem": { - "version": "2.8.13", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.8.13.tgz", - "integrity": "sha512-jEbRUjsiBwmoDr3fnKL1Bh1GhK5ERhmZcPLeARtEaQoBTPB6bcO2siKhNPVOF8qrYRnGHGQrZHncBWMQhTjGYg==", + "version": "2.16.5", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.16.5.tgz", + "integrity": "sha512-QDESALYDyLSP+pIr7adH3QPZ+3is16aOVMXXZE0X1GVbgL7PDMZQ8xIF1X/B1hgyqkBl2HhMpUaq6ksUdBV/YA==", "funding": [ { "type": "github", @@ -8249,13 +8207,13 @@ ], "dependencies": { "@adraffy/ens-normalize": "1.10.0", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@scure/bip32": "1.3.2", - "@scure/bip39": "1.2.1", - "abitype": "1.0.0", - "isows": "1.0.3", - "ws": "8.13.0" + "@noble/curves": "1.4.0", + "@noble/hashes": "1.4.0", + "@scure/bip32": "1.4.0", + "@scure/bip39": "1.3.0", + "abitype": "1.0.5", + "isows": "1.0.4", + "ws": "8.17.1" }, "peerDependencies": { "typescript": ">=5.0.4" @@ -8266,32 +8224,10 @@ } } }, - "node_modules/viem/node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/viem/node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/viem/node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, @@ -8681,6 +8617,23 @@ "vite": "^5.1.6" } }, + "packages/evm-bytes-signer": { + "name": "@holoom/evm-bytes-signer", + "version": "0.0.0", + "dependencies": { + "@holochain/client": "^0.16.7", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "viem": "^2.16.5" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.12.7", + "nodemon": "^3.1.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.5" + } + }, "packages/external-id-attestor": { "name": "@holoom/external-id-attestor", "version": "0.0.0", diff --git a/package.json b/package.json index 6337c06..e5c0f64 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "test:dna": "npm run build:dna && cargo nextest run -j 1", "build:client": "npm run build -w @holoom/client", "build:external-id-attestor": "npm run build -w @holoom/external-id-attestor", + "build:evm-bytes-signer": "npm run build -w @holoom/evm-bytes-signer", "build:mock-oracle": "npm run build -w @holoom/mock-oracle", - "build:docker": "npm run build:dna && npm run build:external-id-attestor && npm run build:mock-oracle && scripts/build_docker_images.sh", + "build:docker": "npm run build:dna && npm run build:external-id-attestor && npm run build:mock-oracle && npm run build:evm-bytes-signer && scripts/build_docker_images.sh", "test:e2e": "npm run build:docker && npm run build:client && npm run e2e -w @holoom/e2e" }, "devDependencies": { diff --git a/packages/client/src/evm-bytes-signature-requestor-client.ts b/packages/client/src/evm-bytes-signature-requestor-client.ts new file mode 100644 index 0000000..17c7dd1 --- /dev/null +++ b/packages/client/src/evm-bytes-signature-requestor-client.ts @@ -0,0 +1,104 @@ +import type { + ActionHash, + AppAgentWebsocket, + AppSignal, +} from "@holochain/client"; +import { v4 as uuidV4 } from "uuid"; +import { + EvmSignatureOverRecipeExecutionRequest, + EvmSignatureProvided, + EvmSignatureRequestRejected, + LocalHoloomSignal, + SignedEvmU256Array, +} from "./types"; + +class RequestResolver { + resolve!: (signedU256Array: SignedEvmU256Array) => void; + reject!: (reason: string) => void; + prom = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + until() { + return this.prom; + } +} + +export class EvmBytesSignatureRequestorClient { + constructor(readonly appAgent: AppAgentWebsocket) { + appAgent.on("signal", (signal) => this.handleAppSignal(signal)); + } + + resolvers: { [requestId: string]: RequestResolver } = {}; + + async requestEvmSignature(arg: { + recipeExecutionAh: ActionHash; + signingOfferAh: ActionHash; + }): Promise { + const requestId = uuidV4(); + const resolver = new RequestResolver(); + this.resolvers[requestId] = resolver; + + const payload: EvmSignatureOverRecipeExecutionRequest = { + request_id: requestId, + recipe_execution_ah: arg.recipeExecutionAh, + signing_offer_ah: arg.signingOfferAh, + }; + await this.appAgent.callZome({ + role_name: "holoom", + zome_name: "username_registry", + fn_name: "send_request_for_evm_signature_over_recipe_execution", + payload, + }); + + const signedEvmU256Array = await resolver.until(); + return signedEvmU256Array; + } + + private handleAppSignal(signal: AppSignal) { + if (signal.zome_name !== "username_registry") return; + const localSignal = signal.payload as LocalHoloomSignal; + switch (localSignal.type) { + case "EvmSignatureProvided": { + this.handleEvmSignatureProvided(localSignal); + break; + } + case "EvmSignatureRequestRejected": { + this.handleEvmSignatureRequestRejected(localSignal); + break; + } + } + } + + private handleEvmSignatureProvided(signal: EvmSignatureProvided) { + try { + const resolver = this.resolvers[signal.request_id]; + if (!resolver) { + console.error(`Resolver for ${signal.request_id} not found`); + return; + } + resolver.resolve(signal.signed_u256_array); + } catch (err) { + console.error( + "ExternalIdAttestationRequestorClient failed to decode ExternalIdAttestation" + ); + const resolver = this.resolvers[signal.request_id]; + if (!resolver) { + console.error(`Resolver for ${signal.request_id} not found`); + return; + } + resolver.reject("Failed to decode ExternalIdAttestation"); + } + } + + private handleEvmSignatureRequestRejected( + signal: EvmSignatureRequestRejected + ) { + const resolver = this.resolvers[signal.request_id]; + if (!resolver) { + console.error(`Resolver for ${signal.request_id} not found`); + return; + } + resolver.reject(signal.reason); + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8d74c24..b99d79a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ export { HoloomClient } from "./holoom-client"; export { ExternalIdAttestationRequestorClient } from "./external-id-attestation-requestor-client"; +export { EvmBytesSignatureRequestorClient } from "./evm-bytes-signature-requestor-client"; export { FaceitAuthFlowClient } from "./faceit-auth-flow-client"; diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 92902e2..079f4fb 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -11,6 +11,18 @@ export type EvmSignature = [ number, // v ]; +export interface SignedEvmU256Array { + raw: Uint8Array[]; + signature: EvmSignature; + signer: Uint8Array; +} + +export interface EvmSignatureOverRecipeExecutionRequest { + request_id: String; + recipe_execution_ah: ActionHash; + signing_offer_ah: ActionHash; +} + export type ChainWalletSignature_Evm = { Evm: { evm_address: Uint8Array; @@ -140,7 +152,27 @@ export interface ExternalIdRejected { request_id: string; reason: string; } +export interface EvmSignatureRequested { + type: "EvmSignatureRequested"; + request_id: string; + requestor_pubkey: AgentPubKey; + u256_array: Uint8Array[]; +} +export interface EvmSignatureProvided { + type: "EvmSignatureProvided"; + request_id: string; + signed_u256_array: SignedEvmU256Array; +} +export interface EvmSignatureRequestRejected { + type: "EvmSignatureRequestRejected"; + request_id: string; + reason: string; +} + export type LocalHoloomSignal = | ExternalIdAttestationRequested | ExternalIdAttested - | ExternalIdRejected; + | ExternalIdRejected + | EvmSignatureRequested + | EvmSignatureProvided + | EvmSignatureRequestRejected; diff --git a/packages/e2e/src/main.ts b/packages/e2e/src/main.ts index 25ec545..c5adb70 100644 --- a/packages/e2e/src/main.ts +++ b/packages/e2e/src/main.ts @@ -4,6 +4,7 @@ import { HoloomClient, FaceitAuthFlowClient, ExternalIdAttestationRequestorClient, + EvmBytesSignatureRequestorClient, } from "@holoom/client"; import WebSdkApi, { ChaperoneState } from "@holo-host/web-sdk"; @@ -48,13 +49,22 @@ async function createClients() { const externalIdRequestor = new ExternalIdAttestationRequestorClient( holo as unknown as AppAgentWebsocket ); + const evmSignatureRequestor = new EvmBytesSignatureRequestorClient( + holo as unknown as AppAgentWebsocket + ); if (window.location.pathname.includes("/auth/callback")) { const { code, codeVerifier } = faceitAuthFlow.getCodes(); global.externalIdRequestProm = externalIdRequestor.requestExternalIdAttestation(codeVerifier, code); } else { } - return { holoom, faceitAuthFlow, externalIdRequestor }; + return { + holo, + holoom, + faceitAuthFlow, + externalIdRequestor, + evmSignatureRequestor, + }; } global.clientsProm = createClients() diff --git a/packages/e2e/tests/signing-offer.test.js b/packages/e2e/tests/signing-offer.test.js new file mode 100644 index 0000000..c32c6b4 --- /dev/null +++ b/packages/e2e/tests/signing-offer.test.js @@ -0,0 +1,130 @@ +const { startTestContainers } = require("./utils/testcontainers"); +const { loadPageAndRegister } = require("./utils/holo"); +const { verifyMessage, bytesToHex } = require("viem"); + +describe("signing-offer", () => { + let testContainers; + beforeEach(async () => { + testContainers = await startTestContainers({ evmSigner: true }); + }, 120_000); + afterEach(async () => { + await Promise.all([testContainers?.stop(), jestPuppeteer.resetPage()]); + }); + + it("Should produce signed u256 array", async () => { + debug("Started test"); + await loadPageAndRegister("test@test.com", "test1234"); + debug("Loaded chaperone and registered agent"); + + const recipe_ah = await page.evaluate(async () => { + const recipeRecord = await clients.holo.callZome({ + role_name: "holoom", + zome_name: "username_registry", + fn_name: "create_recipe", + payload: { + trusted_authors: [clients.holo.myPubKey], + arguments: [], + instructions: [ + [ + "$return", + { + type: "Jq", + input_var_names: { type: "List", var_names: [] }, + program: '["f39Fd6e51aad88F6F4ce6aB8827279cffFb92266", 123]', + }, + ], + ], + }, + }); + return Array.from(recipeRecord.signed_action.hashed.hash); + }); + debug("Created recipe"); + + await fetch("http://localhost:8002/evm-signing-offer", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer password", + }, + body: JSON.stringify({ + identifier: "123", + evm_signing_offer: { + recipe_ah, + u256_items: [{ type: "Hex" }, { type: "Uint" }], + }, + }), + }); + debug("Created EvmSigningOffer"); + + let signingOfferActionHash; + while (true) { + signingOfferActionHash = await page.evaluate(async () => { + const actionHash = await clients.holo.callZome({ + role_name: "holoom", + zome_name: "username_registry", + fn_name: "get_latest_evm_signing_offer_ah_for_name", + payload: "123", + }); + return actionHash ? Array.from(actionHash) : null; + }); + if (signingOfferActionHash) { + break; + } else { + await new Promise((r) => setTimeout(r, 500)); + } + } + debug("Polled until EvmSigningOffer gossiped"); + + const signature = await page.evaluate( + async (recipe_ah, evm_signing_offer_ah) => { + const executionRecord = await clients.holo.callZome({ + role_name: "holoom", + zome_name: "username_registry", + fn_name: "execute_recipe", + payload: { + recipe_ah: new Uint8Array(recipe_ah), + arguments: [], + }, + }); + while (true) { + try { + const signedContext = + await clients.evmSignatureRequestor.requestEvmSignature({ + recipeExecutionAh: executionRecord.signed_action.hashed.hash, + signingOfferAh: new Uint8Array(evm_signing_offer_ah), + }); + // Flatten signature + return [ + ...Array.from(signedContext.signature[0]), + ...Array.from(signedContext.signature[1]), + signedContext.signature[2], + ]; + } catch (err) { + if (err.message.includes("RecipeExecution not found")) { + await new Promise((r) => setTimeout(r, 500)); + } else { + throw err; + } + } + } + }, + recipe_ah, + Array.from(signingOfferActionHash) + ); + debug("Executed recipe and received signature for it"); + + const isValid = await verifyMessage({ + message: { + raw: new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 243, 159, 214, 229, 26, 173, 136, + 246, 244, 206, 106, 184, 130, 114, 121, 207, 255, 185, 34, 102, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 123, + ]), + }, + signature: bytesToHex(new Uint8Array(signature)), + address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }); + expect(isValid).toBe(true); + }, 120_000); +}); diff --git a/packages/e2e/tests/utils/testcontainers.js b/packages/e2e/tests/utils/testcontainers.js index f0468e1..e218163 100644 --- a/packages/e2e/tests/utils/testcontainers.js +++ b/packages/e2e/tests/utils/testcontainers.js @@ -103,6 +103,26 @@ function startMockAuthContainer(network) { .start(); } +function startEvmSignerContainer(network, authorityIp) { + return new GenericContainer("holoom/evm-bytes-signer") + .withNetwork(network) + .withExposedPorts({ host: 8002, container: 8002 }) + .withEnvironment({ + HOLOCHAIN_HOST_NAME: authorityIp, + HOLOCHAIN_ADMIN_WS_PORT: 3334, + HOLOCHAIN_APP_WS_PORT: 3336, + HOLOCHAIN_APP_ID: "holoom", + // First account of seed phrase: test test test test test test test test test test test junk + EVM_PRIVATE_KEY: + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ADMIN_TOKEN: "password", + PORT: "8002", + }) + .withLogConsumer(logConsumer("evm-bytes-signer")) + .withCommand(["npm", "start"]) + .start(); +} + function startMockOracleContainer(network, authorityIp) { return new GenericContainer("holoom/mock-oracle") .withNetwork(network) @@ -169,6 +189,14 @@ module.exports.startTestContainers = async (opts = {}) => { ); } + if (opts.evmSigner) { + containerProms.push( + authorityIpProm.then((authorityIp) => + startEvmSignerContainer(network, authorityIp) + ) + ); + } + const startedContainers = []; const failures = []; for (prom of containerProms) { @@ -187,6 +215,7 @@ module.exports.startTestContainers = async (opts = {}) => { }; if (failures.length) { + console.error(failures); debug(`${failures.length} container(s) failed to start`); await stop(); throw failures[0]; diff --git a/packages/evm-bytes-signer/package.json b/packages/evm-bytes-signer/package.json new file mode 100644 index 0000000..e2c6e4c --- /dev/null +++ b/packages/evm-bytes-signer/package.json @@ -0,0 +1,24 @@ +{ + "name": "@holoom/evm-bytes-signer", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "npx tsc", + "start": "node dist/index.js", + "dev": "nodemon src/index.ts" + }, + "dependencies": { + "@holochain/client": "^0.16.7", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "viem": "^2.16.5" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.12.7", + "nodemon": "^3.1.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.5" + } +} diff --git a/packages/evm-bytes-signer/src/bytes-signer.ts b/packages/evm-bytes-signer/src/bytes-signer.ts new file mode 100644 index 0000000..daf7559 --- /dev/null +++ b/packages/evm-bytes-signer/src/bytes-signer.ts @@ -0,0 +1,24 @@ +import { Hex, hexToBytes } from "viem"; +import { privateKeyToAccount, PrivateKeyAccount } from "viem/accounts"; +import { formatEvmSignature } from "./utils.js"; + +export class BytesSigner { + readonly account: PrivateKeyAccount; + readonly address: Uint8Array; + constructor(readonly privateKey: string) { + this.account = privateKeyToAccount(privateKey as Hex); + this.address = hexToBytes(this.account.address); + } + + async sign(u256_array: Uint8Array[]) { + console.log("signing u256_array", u256_array); + const packed = new Uint8Array( + u256_array.flatMap((u256) => Array.from(u256)) + ); + if (packed.length !== 32 * u256_array.length) { + throw Error("Bad packing of u256_array"); + } + const hex = await this.account.signMessage({ message: { raw: packed } }); + return formatEvmSignature(hex); + } +} diff --git a/packages/evm-bytes-signer/src/evm-bytes-signer-client.ts b/packages/evm-bytes-signer/src/evm-bytes-signer-client.ts new file mode 100644 index 0000000..ad314ce --- /dev/null +++ b/packages/evm-bytes-signer/src/evm-bytes-signer-client.ts @@ -0,0 +1,84 @@ +import type { AppAgentWebsocket, AppSignal } from "@holochain/client"; +import { + LocalHoloomSignal, + EvmSignatureRequested, + ResolveEvmSignatureOverRecipeExecutionRequestPayload, + RejectEvmSignatureOverRecipeExecutionRequestPayload, +} from "./types.js"; +import { BytesSigner } from "./bytes-signer.js"; + +export class EvmBytesSignerClient { + constructor( + readonly appAgent: AppAgentWebsocket, + readonly bytesSigner: BytesSigner + ) { + appAgent.on("signal", (signal) => this.handleAppSignal(signal)); + } + + async confirmRequest( + payload: ResolveEvmSignatureOverRecipeExecutionRequestPayload + ): Promise { + console.log("confirmRequest", payload); + await this.appAgent.callZome({ + role_name: "holoom", + zome_name: "username_registry", + fn_name: "resolve_evm_signature_over_recipe_execution_request", + payload, + }); + } + + async rejectRequest( + payload: RejectEvmSignatureOverRecipeExecutionRequestPayload + ): Promise { + console.log("rejectRequest", payload); + await this.appAgent.callZome({ + role_name: "holoom", + zome_name: "username_registry", + fn_name: "reject_evm_signature_over_recipe_execution_request", + payload, + }); + } + + handleAppSignal(signal: AppSignal) { + console.log("received signal", signal); + if (signal.zome_name !== "username_registry") return; + const localSignal = signal.payload as LocalHoloomSignal; + if (localSignal.type === "EvmSignatureRequested") { + this.handleEvmSignatureRequested(localSignal); + } + } + + async handleEvmSignatureRequested(signal: EvmSignatureRequested) { + console.log("handleEvmSignatureRequested"); + try { + const signature = await this.bytesSigner.sign(signal.u256_array); + // Will node complain about this orphaned promise? + this.confirmRequest({ + request_id: signal.request_id, + requestor: signal.requestor_pubkey, + signed_u256_array: { + signature, + signer: this.bytesSigner.address, + raw: signal.u256_array, + }, + }); + } catch (err) { + console.error(err); + // Will node complain about this orphaned promise? + this.rejectRequest({ + request_id: signal.request_id, + requestor: signal.requestor_pubkey, + reason: unknownErrToString(err), + }); + } + } +} + +function unknownErrToString(err: unknown) { + if (typeof err === "string") return err; + if (typeof err == "object") { + if (!err) return "Unknown"; + if ("message" in err) return `${err.message}`; + } + return `${err}`; +} diff --git a/packages/evm-bytes-signer/src/index.ts b/packages/evm-bytes-signer/src/index.ts new file mode 100644 index 0000000..3f3e94f --- /dev/null +++ b/packages/evm-bytes-signer/src/index.ts @@ -0,0 +1,78 @@ +import dotenv from "dotenv"; +import { AdminWebsocket, AppAgentWebsocket, Record } from "@holochain/client"; +import { BytesSigner } from "./bytes-signer.js"; +import { EvmBytesSignerClient } from "./evm-bytes-signer-client.js"; +import express, { Request, Response } from "express"; +import { CreateEvmSigningOfferPayload } from "./types.js"; + +async function main() { + dotenv.config(); + + const getEnv = (name: string) => { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} env var not defined`); + } + return value; + }; + + const hostName = getEnv("HOLOCHAIN_HOST_NAME"); + + const adminWebsocket = await AdminWebsocket.connect( + new URL(`ws://${hostName}:${getEnv("HOLOCHAIN_ADMIN_WS_PORT")}`) + ); + const cellIds = await adminWebsocket.listCellIds(); + await adminWebsocket.authorizeSigningCredentials(cellIds[0]); + + const appAgentClient = await AppAgentWebsocket.connect( + new URL(`ws://${hostName}:${getEnv("HOLOCHAIN_APP_WS_PORT")}`), + getEnv("HOLOCHAIN_APP_ID") + ); + + const accessTokenAssessor = new BytesSigner(getEnv("EVM_PRIVATE_KEY")); + + const _evmBytesSignerClient = new EvmBytesSignerClient( + appAgentClient, + accessTokenAssessor + ); + + console.log("EvmBytesSignerClient listening for incoming requests"); + + const app = express(); + app.use(express.json()); + app.post("/evm-signing-offer", async (req: Request, res: Response) => { + if (req.headers.authorization !== `Bearer ${getEnv("ADMIN_TOKEN")}`) { + console.log("Authorized"); + return res.status(401).send(); + } + console.log("POST: /evm-signing-offer", req.body); + + try { + const payload: CreateEvmSigningOfferPayload = { + identifier: req.body.identifier, + evm_signing_offer: { + recipe_ah: new Uint8Array(req.body.evm_signing_offer.recipe_ah), + u256_items: req.body.evm_signing_offer.u256_items, + }, + }; + const record: Record = await appAgentClient.callZome({ + role_name: "holoom", + zome_name: "username_registry", + fn_name: "create_evm_signing_offer", + payload, + }); + console.log("Created record", record); + const actionHash = Array.from(record.signed_action.hashed.hash); + res.status(200).send({ actionHash }); + } catch (err) { + console.error(err); + res.status(500).send({ err }); + } + }); + const port = getEnv("PORT"); + app.listen(port, () => { + console.log(`Listening on port ${port}`); + }); +} + +main().catch((err) => console.error(err)); diff --git a/packages/evm-bytes-signer/src/types.ts b/packages/evm-bytes-signer/src/types.ts new file mode 100644 index 0000000..e837223 --- /dev/null +++ b/packages/evm-bytes-signer/src/types.ts @@ -0,0 +1,79 @@ +import type { ActionHash, AgentPubKey, Record } from "@holochain/client"; + +export type EvmSignature = [ + Uint8Array, // r + Uint8Array, // s + number, // v +]; + +export interface SignedEvmU256Array { + raw: Uint8Array[]; + signature: EvmSignature; + signer: Uint8Array; +} + +export type EvmU256Item = { type: "Uint" } | { type: "Hex" }; + +export interface EvmSigningOffer { + recipe_ah: ActionHash; + u256_items: EvmU256Item[]; +} + +export interface CreateEvmSigningOfferPayload { + identifier: string; + evm_signing_offer: EvmSigningOffer; +} + +export interface ResolveEvmSignatureOverRecipeExecutionRequestPayload { + request_id: String; + requestor: AgentPubKey; + signed_u256_array: SignedEvmU256Array; +} + +export interface RejectEvmSignatureOverRecipeExecutionRequestPayload { + request_id: String; + requestor: AgentPubKey; + reason: String; +} + +export interface ExternalIdAttestationRequested { + type: "ExternalIdAttestationRequested"; + request_id: string; + requestor_pubkey: AgentPubKey; + code_verifier: string; + code: string; +} +export interface ExternalIdAttested { + type: "ExternalIdAttested"; + request_id: string; + record: Record; +} +export interface ExternalIdRejected { + type: "ExternalIdRejected"; + request_id: string; + reason: string; +} +export interface EvmSignatureRequested { + type: "EvmSignatureRequested"; + request_id: string; + requestor_pubkey: AgentPubKey; + u256_array: Uint8Array[]; +} +export interface EvmSignatureProvided { + type: "EvmSignatureProvided"; + request_id: string; + signed_u256_array: SignedEvmU256Array; +} +export interface EvmSignatureRequestRejected { + type: "EvmSignatureRequestRejected"; + request_id: string; + reason: string; +} + +export type LocalHoloomSignal = + | ExternalIdAttestationRequested + | ExternalIdAttested + | ExternalIdRejected + | EvmSignatureRequested + | EvmSignatureProvided + | EvmSignatureRequestRejected; diff --git a/packages/evm-bytes-signer/src/utils.ts b/packages/evm-bytes-signer/src/utils.ts new file mode 100644 index 0000000..0b6286d --- /dev/null +++ b/packages/evm-bytes-signer/src/utils.ts @@ -0,0 +1,10 @@ +import { EvmSignature } from "./types.js"; +import { Hex, hexToBytes } from "viem"; + +export function formatEvmSignature(hex: Hex): EvmSignature { + const bytes = hexToBytes(hex); + const r = new Uint8Array(bytes.slice(0, 32)); + const s = new Uint8Array(bytes.slice(32, 64)); + const v = bytes[64]; + return [r, s, v]; +} diff --git a/packages/evm-bytes-signer/tsconfig.json b/packages/evm-bytes-signer/tsconfig.json new file mode 100644 index 0000000..fba8bb8 --- /dev/null +++ b/packages/evm-bytes-signer/tsconfig.json @@ -0,0 +1,109 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "NodeNext" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/scripts/build_docker_images.sh b/scripts/build_docker_images.sh index 9424cc6..ae58d40 100755 --- a/scripts/build_docker_images.sh +++ b/scripts/build_docker_images.sh @@ -21,4 +21,9 @@ docker build \ -f docker/external-id-attestor/Dockerfile \ packages/external-id-attestor # context +docker build \ + -t holoom/evm-bytes-signer \ + -f docker/evm-bytes-signer/Dockerfile \ + packages/evm-bytes-signer # context + bash scripts/build_rocket_bookworm.sh