diff --git a/Cargo.lock b/Cargo.lock index 4840a3c..52f4b51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9196,6 +9196,7 @@ dependencies = [ "bincode", "hdk", "holoom_types", + "indexmap 2.2.5", "jaq_wrapper", "serde", "serde_json", @@ -9238,6 +9239,7 @@ dependencies = [ "hdi", "holo_hash", "holoom_types", + "indexmap 2.2.5", "jaq_wrapper", "serde", "serde_json", diff --git a/crates/holoom_dna_tests/src/tests/username_registry/mod.rs b/crates/holoom_dna_tests/src/tests/username_registry/mod.rs index bd2abad..33cb2e1 100644 --- a/crates/holoom_dna_tests/src/tests/username_registry/mod.rs +++ b/crates/holoom_dna_tests/src/tests/username_registry/mod.rs @@ -1,4 +1,5 @@ mod oracle; +mod recipe; mod user_metadata; mod username_attestation; mod wallet_attestation; diff --git a/crates/holoom_dna_tests/src/tests/username_registry/recipe.rs b/crates/holoom_dna_tests/src/tests/username_registry/recipe.rs new file mode 100644 index 0000000..2a25ba5 --- /dev/null +++ b/crates/holoom_dna_tests/src/tests/username_registry/recipe.rs @@ -0,0 +1,203 @@ +use hdk::prelude::*; + +use holochain::conductor::api::error::ConductorApiResult; +use holoom_types::{ + recipe::{ + ExecuteRecipePayload, JqInstructionArgumentNames, Recipe, RecipeArgument, + RecipeArgumentType, RecipeExecution, RecipeInstruction, + }, + ExternalIdAttestation, OracleDocument, +}; +use username_registry_utils::deserialize_record_entry; + +use crate::TestSetup; + +#[tokio::test(flavor = "multi_thread")] +async fn can_execute_basic_recipe() { + let setup = TestSetup::authority_and_alice().await; + setup.conductors.exchange_peer_info().await; + + // Materials: + + // Doc: foo/1234 -> { value: 1, owner: "1234" } + // Doc: foo/5678 -> { value: 4, owner: "5678" } + // Doc: foo -> [foo/1234, foo/5678] + // ExternalId: authority_agent_pub_key -> { id: 1234, display_name: "some-user-1" } + // ExternalId: alice_agent_pub_key -> { id: 5678, display_name: "some-user-2" } + + // Recipe: + // Calculate value share of caller + // { + // "$arguments": [{ name" "greeting", type: "string" }], + // "foo_name_list_name": { inst: "get_doc", var_name: `"foo"` }, + // "foo_name_list": { inst: "get_docs", var_name: `"foo"` }, + // "foos": { inst: "get_docs", var_name: "foo_name_list" }, + // "caller_external_id": { inst: "get_caller_external_id" }, + // "$return": { + // inst: "jq", + // input_vars: ["foos", "caller_external_id", "greeting"], + // program: ` + // .caller_external_id.external_id as $id | + // .foos as $foos | + // "\(.greeting) \(.caller_external_id.display_name)" as $msg | + // [$foos[].value] | add as $total | + // $foos[] | select(.owner==$id) | .value / $total | + // { share: ., msg: $msg } + // ` + // } + // } + + // Expected outputs: + // Authority with greeting: 'Hi' -> { share: 0.2, msg: "Hi some-user-1" } + // Alice with greeting: 'Hello' -> { share: 0.8, msg: "Hello some-user-2" } + + let _foo1_record: Record = setup + .authority_call( + "username_registry", + "create_oracle_document", + OracleDocument { + name: "foo/1234".into(), + json_data: "{\"value\":1,\"owner\":\"1234\"}".into(), + }, + ) + .await + .unwrap(); + + let _foo2_record: Record = setup + .authority_call( + "username_registry", + "create_oracle_document", + OracleDocument { + name: "foo/5678".into(), + json_data: "{\"value\":4,\"owner\":\"5678\"}".into(), + }, + ) + .await + .unwrap(); + + let _foo_name_list_record: Record = setup + .authority_call( + "username_registry", + "create_oracle_document", + OracleDocument { + name: "foo".into(), + json_data: "[\"foo/1234\",\"foo/5678\"]".into(), + }, + ) + .await + .unwrap(); + + let res: ConductorApiResult = setup + .authority_call( + "username_registry", + "create_external_id_attestation", + ExternalIdAttestation { + request_id: "".into(), + internal_pubkey: setup.authority_pubkey(), + external_id: "1234".into(), + display_name: "some-user-1".into(), + }, + ) + .await; + assert!(res.is_ok()); + + let res: ConductorApiResult = setup + .authority_call( + "username_registry", + "create_external_id_attestation", + ExternalIdAttestation { + request_id: "".into(), + internal_pubkey: setup.alice_pubkey(), + external_id: "5678".into(), + display_name: "some-user-2".into(), + }, + ) + .await; + assert!(res.is_ok()); + + let recipe_record: Record = setup + .authority_call( + "username_registry", + "create_recipe", + Recipe { + trusted_authors: vec![setup.authority_pubkey()], + arguments: vec![("greeting".into(),RecipeArgumentType::String)], + instructions: vec![ + ( + "foo_name_list_name".into(), + RecipeInstruction::Constant { + value: "\"foo\"".into(), + }, + ), + ( + "foo_name_list".into(), + RecipeInstruction::GetLatestDocWithIdentifier { + var_name: "foo_name_list_name".into(), + }, + ), + ( + "foos".into(), + RecipeInstruction::GetDocsListedByVar { + var_name: "foo_name_list".into(), + }, + ), + ( + "caller_external_id".into(), + RecipeInstruction::GetCallerExternalId, + ), + ( + "$return".into(), + RecipeInstruction::Jq { + input_var_names: JqInstructionArgumentNames::List{var_names: vec!["foos".into(),"caller_external_id".into(), "greeting".into()]}, + program: ".caller_external_id.external_id as $id | .foos as $foos | \"\\(.greeting) \\(.caller_external_id.display_name)\" as $msg | [$foos[].value] | add as $total | $foos[] | select(.owner==$id) | .value / $total | { share: ., msg: $msg }".into() + } + ) + ], + }, + ) + .await + .unwrap(); + + // Make both agents know recipe + setup.consistency().await; + + let authority_execution_record: Record = setup + .authority_call( + "username_registry", + "execute_recipe", + ExecuteRecipePayload { + recipe_ah: recipe_record.action_address().clone(), + arguments: vec![RecipeArgument::String { value: "Hi".into() }], + }, + ) + .await + .unwrap(); + + let authority_execution: RecipeExecution = + deserialize_record_entry(authority_execution_record).unwrap(); + assert_eq!( + authority_execution.output, + String::from("{\"share\":0.2,\"msg\":\"Hi some-user-1\"}") + ); + + let alice_execution_record: Record = setup + .alice_call( + "username_registry", + "execute_recipe", + ExecuteRecipePayload { + recipe_ah: recipe_record.action_address().clone(), + arguments: vec![RecipeArgument::String { + value: "Hello".into(), + }], + }, + ) + .await + .unwrap(); + + let alice_execution: RecipeExecution = + deserialize_record_entry(alice_execution_record).unwrap(); + assert_eq!( + alice_execution.output, + String::from("{\"share\":0.8,\"msg\":\"Hello some-user-2\"}") + ); +} diff --git a/crates/holoom_types/src/lib.rs b/crates/holoom_types/src/lib.rs index c786f14..f422cc1 100644 --- a/crates/holoom_types/src/lib.rs +++ b/crates/holoom_types/src/lib.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; pub mod external_id; pub use external_id::*; pub mod metadata; +pub mod recipe; pub use metadata::*; pub mod wallet; pub use wallet::*; @@ -45,12 +46,3 @@ pub struct SignableBytes(pub Vec); pub struct HoloomDnaProperties { pub authority_agent: String, } - -pub fn get_authority_agent() -> ExternResult { - let dna_props = HoloomDnaProperties::try_from_dna_properties()?; - AgentPubKey::try_from(dna_props.authority_agent).map_err(|_| { - wasm_error!(WasmErrorInner::Guest( - "Failed to deserialize AgentPubKey from dna properties".into() - )) - }) -} diff --git a/crates/holoom_types/src/recipe.rs b/crates/holoom_types/src/recipe.rs new file mode 100644 index 0000000..bae5c8b --- /dev/null +++ b/crates/holoom_types/src/recipe.rs @@ -0,0 +1,74 @@ +use hdi::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +pub enum RecipeArgumentType { + String, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +pub enum JqInstructionArgumentNames { + Single { var_name: String }, + List { var_names: Vec }, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +pub enum RecipeInstruction { + Constant { + value: String, + }, + GetLatestDocWithIdentifier { + var_name: String, + }, + Jq { + input_var_names: JqInstructionArgumentNames, + program: String, + }, + GetDocsListedByVar { + var_name: String, + }, + GetCallerExternalId, + GetCallerAgentPublicKey, +} + +#[hdk_entry_helper] +#[derive(Clone, PartialEq)] +pub struct Recipe { + pub trusted_authors: Vec, + pub arguments: Vec<(String, RecipeArgumentType)>, + pub instructions: Vec<(String, RecipeInstruction)>, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +pub enum RecipeArgument { + String { value: String }, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +pub enum RecipeInstructionExecution { + Constant, // In memory + GetLatestDocWithIdentifier { doc_ah: ActionHash }, + Jq, // In memory + GetDocsListedByVar { doc_ahs: Vec }, + GetCallerExternalId { attestation_ah: ActionHash }, + GetCallerAgentPublicKey, // In memory +} + +#[hdk_entry_helper] +#[derive(Clone, PartialEq)] +pub struct RecipeExecution { + pub recipe_ah: ActionHash, + pub arguments: Vec, + pub instruction_executions: Vec, + pub output: String, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +pub struct ExecuteRecipePayload { + pub recipe_ah: ActionHash, + pub arguments: Vec, +} diff --git a/crates/jaq_wrapper/src/lib.rs b/crates/jaq_wrapper/src/lib.rs index dbca4e8..fcbe73a 100644 --- a/crates/jaq_wrapper/src/lib.rs +++ b/crates/jaq_wrapper/src/lib.rs @@ -1,7 +1,10 @@ use hdi::prelude::*; -use jaq_interpret::{Ctx, Filter, FilterT, ParseCtx, RcIter, Val}; +use jaq_interpret::{Ctx, Filter, FilterT, ParseCtx, RcIter}; use std::io::{self, BufRead}; +// Re-export Val +pub use jaq_interpret::Val; + pub enum JqProgramInput { Single(String), Slurp(Vec), @@ -29,7 +32,7 @@ impl JqProgramInput { } } -fn compile_filter(program_str: &str) -> ExternResult { +pub fn compile_filter(program_str: &str) -> ExternResult { let (maybe_main, errs) = jaq_parse::parse(program_str, jaq_parse::main()); let main = maybe_main.ok_or(wasm_error!(format!( "jq program compilation failed with {} error(s)", @@ -42,7 +45,7 @@ fn compile_filter(program_str: &str) -> ExternResult { Ok(filter) } -fn run_filter(filter: Filter, input: Val) -> ExternResult { +pub fn run_filter(filter: Filter, input: Val) -> ExternResult { // Seems jaq is designed to pipe errors forwards, whilst tracking a global reference - hence // the iterator gubbins. let vars = vec![]; @@ -92,7 +95,7 @@ fn run_filter(filter: Filter, input: Val) -> ExternResult { ))) } -fn parse_single_json(json: &str) -> ExternResult { +pub fn parse_single_json(json: &str) -> ExternResult { let mut iter = json_read(json.as_bytes()); let val = iter .next() diff --git a/crates/username_registry_coordinator/Cargo.toml b/crates/username_registry_coordinator/Cargo.toml index 18abe34..f3e39b9 100644 --- a/crates/username_registry_coordinator/Cargo.toml +++ b/crates/username_registry_coordinator/Cargo.toml @@ -16,4 +16,5 @@ holoom_types = { workspace = true } username_registry_validation = { workspace = true } username_registry_utils = { workspace = true } jaq_wrapper = { workspace = true } +indexmap = "2.2.5" serde_json = { workspace = true } diff --git a/crates/username_registry_coordinator/src/external_id_attestation.rs b/crates/username_registry_coordinator/src/external_id_attestation.rs index d5da707..d90499c 100644 --- a/crates/username_registry_coordinator/src/external_id_attestation.rs +++ b/crates/username_registry_coordinator/src/external_id_attestation.rs @@ -1,10 +1,11 @@ use hdk::prelude::*; use holoom_types::{ - get_authority_agent, ConfirmExternalIdRequestPayload, ExternalIdAttestation, + ConfirmExternalIdRequestPayload, ExternalIdAttestation, IngestExternalIdAttestationRequestPayload, LocalHoloomSignal, RejectExternalIdRequestPayload, RemoteHoloomSignal, SendExternalIdAttestationRequestPayload, }; use username_registry_integrity::{EntryTypes, LinkTypes}; +use username_registry_utils::{get_authority_agent, hash_identifier}; #[hdk_extern] pub fn send_external_id_attestation_request( @@ -96,6 +97,7 @@ pub fn reject_external_id_request(payload: RejectExternalIdRequestPayload) -> Ex Ok(()) } +#[hdk_extern] pub fn create_external_id_attestation(attestation: ExternalIdAttestation) -> ExternResult { let base_address = attestation.internal_pubkey.clone(); let attestation_action_hash = create_entry(EntryTypes::ExternalIdAttestation(attestation))?; @@ -113,3 +115,36 @@ pub fn create_external_id_attestation(attestation: ExternalIdAttestation) -> Ext Ok(record) } + +pub fn get_external_id_attestations_for_agent( + agent_pubkey: AgentPubKey, +) -> ExternResult> { + let links = get_links(agent_pubkey, LinkTypes::AgentToExternalIdAttestation, None)?; + let maybe_records = links + .into_iter() + .map(|link| { + let action_hash = ActionHash::try_from(link.target).map_err(|_| { + wasm_error!(WasmErrorInner::Guest( + "ExternalIdToAttestation link doesn't point at action".into() + )) + })?; + get(action_hash, GetOptions::default()) + }) + .collect::>>()?; + Ok(maybe_records.into_iter().flatten().collect()) +} + +pub fn get_attestation_for_external_id(external_id: String) -> ExternResult> { + let base = hash_identifier(external_id)?; + let mut links = get_links(base, LinkTypes::ExternalIdToAttestation, 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( + "ExternalIdToAttestation link doesn't point at action".into() + )) + })?; + get(action_hash, GetOptions::default()) +} diff --git a/crates/username_registry_coordinator/src/lib.rs b/crates/username_registry_coordinator/src/lib.rs index 0d9f8c3..b358b23 100644 --- a/crates/username_registry_coordinator/src/lib.rs +++ b/crates/username_registry_coordinator/src/lib.rs @@ -2,11 +2,14 @@ pub mod external_id_attestation; pub mod jq_execution; pub mod oracle_document; pub mod oracle_document_list_snapshot; +pub mod recipe; +pub mod recipe_execution; pub mod user_metadata; pub mod username_attestation; pub mod wallet_attestation; use hdk::prelude::*; -use holoom_types::{get_authority_agent, LocalHoloomSignal, RemoteHoloomSignal}; +use holoom_types::{LocalHoloomSignal, RemoteHoloomSignal}; +use username_registry_utils::get_authority_agent; #[hdk_extern] pub fn init(_: ()) -> ExternResult { diff --git a/crates/username_registry_coordinator/src/oracle_document.rs b/crates/username_registry_coordinator/src/oracle_document.rs index 6f39915..5a69037 100644 --- a/crates/username_registry_coordinator/src/oracle_document.rs +++ b/crates/username_registry_coordinator/src/oracle_document.rs @@ -22,6 +22,7 @@ pub fn create_oracle_document(oracle_document: OracleDocument) -> ExternResult ExternResult> { let base_address = hash_identifier(name)?; let mut links = get_links(base_address, LinkTypes::NameToOracleDocument, None)?; @@ -37,6 +38,14 @@ pub fn get_latest_oracle_document_ah_for_name(name: String) -> ExternResult ExternResult> { + let Some(action_hash) = get_latest_oracle_document_ah_for_name(name)? else { + return Ok(None); + }; + get(action_hash, GetOptions::default()) +} + #[hdk_extern] pub fn relate_oracle_document(payload: RelateOracleDocumentPayload) -> ExternResult<()> { let base_address = hash_identifier(payload.relation)?; diff --git a/crates/username_registry_coordinator/src/recipe.rs b/crates/username_registry_coordinator/src/recipe.rs new file mode 100644 index 0000000..aa96aad --- /dev/null +++ b/crates/username_registry_coordinator/src/recipe.rs @@ -0,0 +1,13 @@ +use hdk::prelude::*; +use holoom_types::recipe::Recipe; +use username_registry_integrity::EntryTypes; + +#[hdk_extern] +pub fn create_recipe(recipe: Recipe) -> ExternResult { + let recipe_ah = create_entry(EntryTypes::Recipe(recipe))?; + let record = get(recipe_ah, GetOptions::default())?.ok_or(wasm_error!( + WasmErrorInner::Guest(String::from("Could not find the newly created Recipe")) + ))?; + + Ok(record) +} diff --git a/crates/username_registry_coordinator/src/recipe_execution.rs b/crates/username_registry_coordinator/src/recipe_execution.rs new file mode 100644 index 0000000..2f040bc --- /dev/null +++ b/crates/username_registry_coordinator/src/recipe_execution.rs @@ -0,0 +1,212 @@ +use std::{collections::HashMap, rc::Rc}; + +use hdk::prelude::*; +use holoom_types::{recipe::*, ExternalIdAttestation, OracleDocument}; +use indexmap::IndexMap; +use jaq_wrapper::{compile_filter, parse_single_json, run_filter, Val}; +use username_registry_integrity::EntryTypes; +use username_registry_utils::deserialize_record_entry; + +use crate::{ + external_id_attestation::get_external_id_attestations_for_agent, + oracle_document::{ + get_latest_oracle_document_ah_for_name, get_latest_oracle_document_for_name, + }, +}; + +#[hdk_extern] +pub fn create_recipe_execution(recipe_execution: RecipeExecution) -> ExternResult { + let recipe_execution_ah = create_entry(EntryTypes::RecipeExecution(recipe_execution))?; + let record = get(recipe_execution_ah, GetOptions::default())?.ok_or(wasm_error!( + WasmErrorInner::Guest(String::from( + "Could not find the newly created RecipeExecution" + )) + ))?; + + Ok(record) +} + +#[hdk_extern] +pub fn execute_recipe(payload: ExecuteRecipePayload) -> ExternResult { + let recipe_record = get(payload.recipe_ah.clone(), GetOptions::default())?.ok_or( + wasm_error!(WasmErrorInner::Guest("Recipe not found".into())), + )?; + let recipe: Recipe = deserialize_record_entry(recipe_record)?; + + let mut vars: HashMap = HashMap::default(); + let mut instruction_executions: Vec = Vec::default(); + + if payload.arguments.len() != recipe.arguments.len() { + return Err(wasm_error!(WasmErrorInner::Guest( + "Incorrect number of arguments".into() + ))); + } + for (arg, (arg_name, arg_type)) in payload.arguments.iter().zip(recipe.arguments) { + let val = match (arg, arg_type) { + (RecipeArgument::String { value }, RecipeArgumentType::String) => { + Val::str(value.clone()) + } + _ => { + return Err(wasm_error!(WasmErrorInner::Guest( + "Bad recipe argument".into() + ))) + } + }; + vars.insert(arg_name, val); + } + + for (out_var_name, instruction) in recipe.instructions { + if vars.contains_key(&out_var_name) { + unreachable!("Bad impl: A valid Recipe doesn't reassign vars"); + } + let (val, instruction_execution) = match instruction { + RecipeInstruction::Constant { value } => { + let val = parse_single_json(&value)?; + (val, RecipeInstructionExecution::Constant) + } + RecipeInstruction::GetCallerAgentPublicKey => { + let val = Val::Str(Rc::new(agent_info()?.agent_initial_pubkey.to_string())); + (val, RecipeInstructionExecution::GetCallerAgentPublicKey) + } + RecipeInstruction::GetDocsListedByVar { var_name } => { + let list_val = vars + .get(&var_name) + .expect("Bad impl: A valid recipe doesn't use unassigned vars"); + let Val::Arr(item_vals) = list_val else { + return Err(wasm_error!(WasmErrorInner::Guest(format!( + "var '{}' expected to contain array", + &var_name + )))); + }; + let doc_ahs = item_vals + .iter() + .map(|val| { + let Val::Str(identifier) = val else { + return Err(wasm_error!(WasmErrorInner::Guest(format!( + "var '{}' expected to contain array of string elements", + &var_name + )))); + }; + get_latest_oracle_document_ah_for_name(identifier.as_ref().clone())?.ok_or( + wasm_error!(WasmErrorInner::Guest(format!( + "No OracleDocument for identifier '{}'", + &identifier + ))), + ) + }) + .collect::>>()?; + let doc_vals = doc_ahs + .iter() + .map(|doc_ah| { + let doc_record = get(doc_ah.clone(), GetOptions::default())?.ok_or( + wasm_error!(WasmErrorInner::Guest("OracleDocument not found".into())), + )?; + let doc: OracleDocument = deserialize_record_entry(doc_record)?; + let val = parse_single_json(&doc.json_data)?; + Ok(val) + }) + .collect::>>()?; + let val = Val::arr(doc_vals); + let instruction_execution = + RecipeInstructionExecution::GetDocsListedByVar { doc_ahs }; + (val, instruction_execution) + } + RecipeInstruction::GetCallerExternalId => { + let mut attestation_records = + get_external_id_attestations_for_agent(agent_info()?.agent_initial_pubkey)?; + if attestation_records.len() != 1 { + return Err(wasm_error!(WasmErrorInner::Guest( + "TODO: support ExternalIdAttestation selecting".into() + ))); + } + let attestation_record = attestation_records.pop().expect("Length check above"); + let attestation_ah = attestation_record.action_address().clone(); + let attestation: ExternalIdAttestation = + deserialize_record_entry(attestation_record)?; + let val = Val::obj(IndexMap::from([ + ( + Rc::new(String::from("agent_pubkey")), + Val::str(attestation.internal_pubkey.to_string()), + ), + ( + Rc::new(String::from("external_id")), + Val::str(attestation.external_id), + ), + ( + Rc::new(String::from("display_name")), + Val::str(attestation.display_name), + ), + ])); + let instruction_execution = + RecipeInstructionExecution::GetCallerExternalId { attestation_ah }; + (val, instruction_execution) + } + RecipeInstruction::GetLatestDocWithIdentifier { var_name } => { + let identifier_val = vars + .get(&var_name) + .expect("Bad impl: A valid recipe doesn't use unassigned vars"); + let Val::Str(identifier) = identifier_val else { + return Err(wasm_error!(WasmErrorInner::Guest(format!( + "var '{}' expected to contain string", + &var_name + )))); + }; + let doc_record = get_latest_oracle_document_for_name(identifier.as_ref().clone())? + .ok_or(wasm_error!(WasmErrorInner::Guest(format!( + "No OracleDocument found for identifier '{}'", + identifier + ))))?; + let doc_ah = doc_record.action_address().clone(); + let doc: OracleDocument = deserialize_record_entry(doc_record)?; + let val = parse_single_json(&doc.json_data)?; + let instruction_execution = + RecipeInstructionExecution::GetLatestDocWithIdentifier { doc_ah }; + (val, instruction_execution) + } + RecipeInstruction::Jq { + input_var_names, + program, + } => { + let input_val = match input_var_names { + JqInstructionArgumentNames::Single { var_name } => vars + .get(&var_name) + .expect("Bad impl: A valid recipe doesn't use unassigned vars") + .clone(), + JqInstructionArgumentNames::List { var_names } => { + let map: IndexMap, Val> = var_names + .into_iter() + .map(|var_name| { + let val = vars + .get(&var_name) + .expect("Bad impl: A valid recipe doesn't use unassigned vars") + .clone(); + (Rc::new(var_name), val) + }) + .collect(); + Val::obj(map) + } + }; + let filter = compile_filter(&program)?; + let val = run_filter(filter, input_val)?; + let instruction_execution = RecipeInstructionExecution::Jq; + (val, instruction_execution) + } + }; + vars.insert(out_var_name, val); + instruction_executions.push(instruction_execution) + } + + let return_val = vars + .remove("$return") + .expect("Bad impl: A valid recipe has a $return"); + let output = return_val.to_string(); + + let recipe_execution = RecipeExecution { + recipe_ah: payload.recipe_ah, + arguments: payload.arguments, + instruction_executions, + output, + }; + + create_recipe_execution(recipe_execution) +} diff --git a/crates/username_registry_coordinator/src/username_attestation.rs b/crates/username_registry_coordinator/src/username_attestation.rs index 590f36b..fe4d80c 100644 --- a/crates/username_registry_coordinator/src/username_attestation.rs +++ b/crates/username_registry_coordinator/src/username_attestation.rs @@ -1,6 +1,7 @@ -use holoom_types::{get_authority_agent, SignedUsername, UsernameAttestation}; use hdk::prelude::*; +use holoom_types::{SignedUsername, UsernameAttestation}; use username_registry_integrity::*; +use username_registry_utils::get_authority_agent; #[hdk_extern] pub fn create_username_attestation( diff --git a/crates/username_registry_integrity/src/entry_types.rs b/crates/username_registry_integrity/src/entry_types.rs index 7cdd9b7..561d130 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::{ + recipe::{Recipe, RecipeExecution}, ExternalIdAttestation, JqExecution, OracleDocument, OracleDocumentListSnapshot, UsernameAttestation, WalletAttestation, }; @@ -16,6 +17,8 @@ pub enum EntryTypes { OracleDocument(OracleDocument), OracleDocumentListSnapshot(OracleDocumentListSnapshot), JqExecution(JqExecution), + Recipe(Recipe), + RecipeExecution(RecipeExecution), } impl EntryTypes { @@ -52,6 +55,13 @@ impl EntryTypes { EntryTypes::JqExecution(jq_execution) => { validate_create_jq_execution(EntryCreationAction::Create(action), jq_execution) } + EntryTypes::Recipe(recipe) => { + validate_create_recipe(EntryCreationAction::Create(action), recipe) + } + EntryTypes::RecipeExecution(recipe_execution) => validate_create_recipe_execution( + EntryCreationAction::Create(action), + recipe_execution, + ), } } } diff --git a/crates/username_registry_integrity/src/link_types.rs b/crates/username_registry_integrity/src/link_types.rs index 9350bb7..ff2eb33 100644 --- a/crates/username_registry_integrity/src/link_types.rs +++ b/crates/username_registry_integrity/src/link_types.rs @@ -8,6 +8,7 @@ pub enum LinkTypes { AgentMetadata, AgentToWalletAttestations, AgentToExternalIdAttestation, + ExternalIdToAttestation, NameToOracleDocument, RelateOracleDocumentName, } @@ -48,6 +49,12 @@ impl LinkTypes { tag, ) } + LinkTypes::ExternalIdToAttestation => validate_create_link_external_id_to_attestation( + action, + base_address, + target_address, + tag, + ), LinkTypes::NameToOracleDocument => validate_create_link_name_to_oracle_document( action, base_address, @@ -108,6 +115,13 @@ impl LinkTypes { tag, ) } + LinkTypes::ExternalIdToAttestation => validate_delete_link_external_id_to_attestation( + action, + original_action, + base_address, + target_address, + tag, + ), LinkTypes::NameToOracleDocument => validate_delete_link_name_to_oracle_document( action, original_action, diff --git a/crates/username_registry_utils/src/lib.rs b/crates/username_registry_utils/src/lib.rs index 37765ca..d2d032b 100644 --- a/crates/username_registry_utils/src/lib.rs +++ b/crates/username_registry_utils/src/lib.rs @@ -1,4 +1,5 @@ use hdi::prelude::*; +use holoom_types::HoloomDnaProperties; pub fn deserialize_record_entry(record: Record) -> ExternResult where @@ -22,3 +23,12 @@ pub fn hash_identifier(identifier: String) -> ExternResult { .map_err(|err| wasm_error!(err))?; hash_entry(Entry::App(AppEntryBytes(bytes))) } + +pub fn get_authority_agent() -> ExternResult { + let dna_props = HoloomDnaProperties::try_from_dna_properties()?; + AgentPubKey::try_from(dna_props.authority_agent).map_err(|_| { + wasm_error!(WasmErrorInner::Guest( + "Failed to deserialize AgentPubKey from dna properties".into() + )) + }) +} diff --git a/crates/username_registry_validation/Cargo.toml b/crates/username_registry_validation/Cargo.toml index f7c0590..3a53277 100644 --- a/crates/username_registry_validation/Cargo.toml +++ b/crates/username_registry_validation/Cargo.toml @@ -18,3 +18,4 @@ ed25519-dalek = { workspace = true } bs58 = { workspace = true } jaq_wrapper = { workspace = true } username_registry_utils = { workspace = true } +indexmap = "2.2.5" diff --git a/crates/username_registry_validation/src/agent_external_id_attestation.rs b/crates/username_registry_validation/src/agent_external_id_attestation.rs index b8877c1..8e23cbc 100644 --- a/crates/username_registry_validation/src/agent_external_id_attestation.rs +++ b/crates/username_registry_validation/src/agent_external_id_attestation.rs @@ -1,5 +1,6 @@ use hdi::prelude::*; -use holoom_types::{get_authority_agent, ExternalIdAttestation}; +use holoom_types::ExternalIdAttestation; +use username_registry_utils::get_authority_agent; pub fn validate_create_link_agent_to_external_id_attestations( action: CreateLink, diff --git a/crates/username_registry_validation/src/agent_username_attestation.rs b/crates/username_registry_validation/src/agent_username_attestation.rs index 91a02df..9d023a1 100644 --- a/crates/username_registry_validation/src/agent_username_attestation.rs +++ b/crates/username_registry_validation/src/agent_username_attestation.rs @@ -1,5 +1,6 @@ use hdi::prelude::*; -use holoom_types::{get_authority_agent, UsernameAttestation}; +use holoom_types::UsernameAttestation; +use username_registry_utils::get_authority_agent; pub fn validate_create_link_agent_to_username_attestations( action: CreateLink, diff --git a/crates/username_registry_validation/src/external_id_attestation.rs b/crates/username_registry_validation/src/external_id_attestation.rs index c9291a0..73bd571 100644 --- a/crates/username_registry_validation/src/external_id_attestation.rs +++ b/crates/username_registry_validation/src/external_id_attestation.rs @@ -1,5 +1,6 @@ use hdi::prelude::*; -use holoom_types::{get_authority_agent, ExternalIdAttestation}; +use holoom_types::ExternalIdAttestation; +use username_registry_utils::get_authority_agent; pub fn validate_create_external_id_attestation( action: EntryCreationAction, diff --git a/crates/username_registry_validation/src/external_id_to_attestation.rs b/crates/username_registry_validation/src/external_id_to_attestation.rs new file mode 100644 index 0000000..d9f20e3 --- /dev/null +++ b/crates/username_registry_validation/src/external_id_to_attestation.rs @@ -0,0 +1,49 @@ +use hdi::prelude::*; +use holoom_types::ExternalIdAttestation; +use username_registry_utils::{get_authority_agent, hash_identifier}; + +pub fn validate_create_link_external_id_to_attestation( + action: CreateLink, + base_address: AnyLinkableHash, + target_address: AnyLinkableHash, + _tag: LinkTag, +) -> ExternResult { + // Only the authority can create link + let authority_agent = get_authority_agent()?; + if action.author != authority_agent { + return Ok(ValidateCallbackResult::Invalid( + "Only the Username Registry Authority can create external ID attestation links".into(), + )); + } + + // Check the entry type for the given action hash + let action_hash = ActionHash::try_from(target_address).map_err(|e| wasm_error!(e))?; + let record = must_get_valid_record(action_hash)?; + let external_id_attestation: ExternalIdAttestation = record + .entry() + .to_app_option() + .map_err(|e| wasm_error!(e))? + .ok_or(wasm_error!(WasmErrorInner::Guest(String::from( + "Linked action must reference an entry" + ))))?; + + let expected_base_address = hash_identifier(external_id_attestation.external_id)?; + if AnyLinkableHash::from(expected_base_address) != base_address { + return Ok(ValidateCallbackResult::Invalid( + "ExternalIdToAttestation base_address not derived from external_id".into(), + )); + } + + Ok(ValidateCallbackResult::Valid) +} +pub fn validate_delete_link_external_id_to_attestation( + _action: DeleteLink, + _original_action: CreateLink, + _base: AnyLinkableHash, + _target: AnyLinkableHash, + _tag: LinkTag, +) -> ExternResult { + Ok(ValidateCallbackResult::Invalid(String::from( + "External ID Attestation links cannot be deleted", + ))) +} diff --git a/crates/username_registry_validation/src/lib.rs b/crates/username_registry_validation/src/lib.rs index aec0c3c..9de6d32 100644 --- a/crates/username_registry_validation/src/lib.rs +++ b/crates/username_registry_validation/src/lib.rs @@ -12,6 +12,8 @@ pub mod agent_wallet_attestation; pub use agent_wallet_attestation::*; pub mod external_id_attestation; pub use external_id_attestation::*; +pub mod external_id_to_attestation; +pub use external_id_to_attestation::*; pub mod agent_external_id_attestation; pub use agent_external_id_attestation::*; pub mod name_oracle_document; @@ -20,5 +22,9 @@ pub mod oracle_document_list_snapshot; pub use oracle_document_list_snapshot::*; pub mod jq_execution; pub use jq_execution::*; +pub mod recipe; +pub use recipe::*; +pub mod recipe_execution; +pub use recipe_execution::*; pub mod relate_oracle_document_name; pub use relate_oracle_document_name::*; diff --git a/crates/username_registry_validation/src/recipe.rs b/crates/username_registry_validation/src/recipe.rs new file mode 100644 index 0000000..d9df086 --- /dev/null +++ b/crates/username_registry_validation/src/recipe.rs @@ -0,0 +1,62 @@ +use hdi::prelude::*; +use holoom_types::recipe::{JqInstructionArgumentNames, Recipe, RecipeInstruction}; + +pub fn validate_create_recipe( + _action: EntryCreationAction, + recipe: Recipe, +) -> ExternResult { + if recipe.trusted_authors.is_empty() { + return Ok(ValidateCallbackResult::Invalid( + "Recipe needs at least 1 trusted author".into(), + )); + } + + match recipe.instructions.last() { + None => { + return Ok(ValidateCallbackResult::Invalid( + "Recipe must contain at least 1 instruction".into(), + )) + } + Some((var_name, _)) => { + if var_name != "$return" { + return Ok(ValidateCallbackResult::Invalid( + "Last instruction must be named '$return'".into(), + )); + } + } + } + + let mut declared_vars_names: HashSet = HashSet::default(); + for (arg_name, _) in recipe.arguments { + declared_vars_names.insert(arg_name); + } + + for (out_var_name, inst) in recipe.instructions { + let var_dependencies = match inst { + RecipeInstruction::Constant { .. } + | RecipeInstruction::GetCallerAgentPublicKey + | RecipeInstruction::GetCallerExternalId => Vec::new(), + RecipeInstruction::GetDocsListedByVar { var_name } => vec![var_name], + RecipeInstruction::GetLatestDocWithIdentifier { var_name } => vec![var_name], + RecipeInstruction::Jq { + input_var_names, .. + } => match input_var_names { + JqInstructionArgumentNames::List { var_names } => var_names, + JqInstructionArgumentNames::Single { var_name } => vec![var_name], + }, + }; + for dependency in var_dependencies { + if !declared_vars_names.contains(&dependency) { + return Ok(ValidateCallbackResult::Invalid( + "var used before declaration".into(), + )); + } + } + if declared_vars_names.contains(&out_var_name) { + return Ok(ValidateCallbackResult::Invalid("var redeclared".into())); + } + declared_vars_names.insert(out_var_name); + } + + Ok(ValidateCallbackResult::Valid) +} diff --git a/crates/username_registry_validation/src/recipe_execution.rs b/crates/username_registry_validation/src/recipe_execution.rs new file mode 100644 index 0000000..ea2f656 --- /dev/null +++ b/crates/username_registry_validation/src/recipe_execution.rs @@ -0,0 +1,220 @@ +use std::{collections::HashMap, rc::Rc}; + +use hdi::prelude::*; +use holoom_types::{ + recipe::{ + JqInstructionArgumentNames, Recipe, RecipeArgument, RecipeArgumentType, RecipeExecution, + RecipeInstruction, RecipeInstructionExecution, + }, + ExternalIdAttestation, OracleDocument, +}; +use indexmap::IndexMap; +use jaq_wrapper::{compile_filter, parse_single_json, run_filter, Val}; +use username_registry_utils::deserialize_record_entry; + +pub fn validate_create_recipe_execution( + action: EntryCreationAction, + recipe_execution: RecipeExecution, +) -> ExternResult { + let recipe_record = must_get_valid_record(recipe_execution.recipe_ah)?; + let recipe: Recipe = deserialize_record_entry(recipe_record)?; + + let mut vars: HashMap = HashMap::default(); + + if recipe_execution.arguments.len() != recipe.arguments.len() { + return Ok(ValidateCallbackResult::Invalid( + "Incorrect number of arguments".into(), + )); + } + + for (arg, (arg_name, arg_type)) in recipe_execution + .arguments + .into_iter() + .zip(recipe.arguments.into_iter()) + { + let val = match (arg, arg_type) { + (RecipeArgument::String { value }, RecipeArgumentType::String) => { + Val::str(value.clone()) + } + _ => { + return Ok(ValidateCallbackResult::Invalid( + "Bad recipe argument".into(), + )) + } + }; + vars.insert(arg_name, val); + } + + if recipe_execution.instruction_executions.len() != recipe.instructions.len() { + return Ok(ValidateCallbackResult::Invalid( + "Incorrect number of instruction executions".into(), + )); + } + + for (instruction_execution, (out_var_name, instruction)) in recipe_execution + .instruction_executions + .into_iter() + .zip(recipe.instructions.into_iter()) + { + if vars.contains_key(&out_var_name) { + unreachable!("Bad impl: A valid Recipe doesn't reassign vars"); + } + + let val = match (instruction_execution, instruction) { + (RecipeInstructionExecution::Constant, RecipeInstruction::Constant { value }) => { + // TODO: validate constant value in validate_create_recipe + parse_single_json(&value)? + } + ( + RecipeInstructionExecution::GetCallerAgentPublicKey, + RecipeInstruction::GetCallerAgentPublicKey, + ) => Val::str(action.author().to_string()), + ( + RecipeInstructionExecution::GetDocsListedByVar { doc_ahs }, + RecipeInstruction::GetDocsListedByVar { var_name }, + ) => { + let list_val = vars + .get(&var_name) + .expect("Bad impl: A valid recipe doesn't use unassigned vars"); + let Val::Arr(item_vals) = list_val else { + return Ok(ValidateCallbackResult::Invalid(format!( + "var '{}' expected to contain array", + &var_name + ))); + }; + let mut expected_names = Vec::new(); + for val in item_vals.iter() { + match val { + Val::Str(identifier) => expected_names.push(identifier.as_ref().clone()), + _ => { + return Ok(ValidateCallbackResult::Invalid(format!( + "var '{}' expected to contain array of string elements", + &var_name + ))) + } + } + } + + let docs = doc_ahs + .iter() + .map(|doc_ah| { + let doc_record = must_get_valid_record(doc_ah.clone())?; + let doc: OracleDocument = deserialize_record_entry(doc_record)?; + Ok(doc) + // let val = parse_single_json(&doc.json_data)?; + // Ok(val) + }) + .collect::>>()?; + + let actual_names: Vec = docs.iter().map(|doc| doc.name.clone()).collect(); + if expected_names != actual_names { + return Ok(ValidateCallbackResult::Invalid( + "Listed document name doesn't match".into(), + )); + } + let doc_vals = docs + .iter() + .map(|doc| { + let val = parse_single_json(&doc.json_data)?; + Ok(val) + }) + .collect::>>()?; + Val::arr(doc_vals) + } + ( + RecipeInstructionExecution::GetCallerExternalId { attestation_ah }, + RecipeInstruction::GetCallerExternalId, + ) => { + let attestation_record = must_get_valid_record(attestation_ah)?; + let attestation: ExternalIdAttestation = + deserialize_record_entry(attestation_record)?; + Val::obj(IndexMap::from([ + ( + Rc::new(String::from("agent_pubkey")), + Val::str(attestation.internal_pubkey.to_string()), + ), + ( + Rc::new(String::from("external_id")), + Val::str(attestation.external_id), + ), + ( + Rc::new(String::from("display_name")), + Val::str(attestation.display_name), + ), + ])) + } + ( + RecipeInstructionExecution::GetLatestDocWithIdentifier { doc_ah }, + RecipeInstruction::GetLatestDocWithIdentifier { var_name }, + ) => { + let name_val = vars + .get(&var_name) + .expect("Bad impl: A valid recipe doesn't use unassigned vars"); + let Val::Str(name) = name_val else { + return Ok(ValidateCallbackResult::Invalid(format!( + "var '{}' expected to contain string", + &var_name + ))); + }; + let expected_name = name.as_ref().clone(); + let doc_record = must_get_valid_record(doc_ah)?; + let doc: OracleDocument = deserialize_record_entry(doc_record)?; + + if doc.name != expected_name { + return Ok(ValidateCallbackResult::Invalid( + "Specified document name doesn't match".into(), + )); + } + parse_single_json(&doc.json_data)? + } + ( + RecipeInstructionExecution::Jq, + RecipeInstruction::Jq { + input_var_names, + program, + }, + ) => { + let input_val = match input_var_names { + JqInstructionArgumentNames::Single { var_name } => vars + .get(&var_name) + .expect("Bad impl: A valid recipe doesn't use unassigned vars") + .clone(), + JqInstructionArgumentNames::List { var_names } => { + let map: IndexMap, Val> = var_names + .into_iter() + .map(|var_name| { + let val = vars + .get(&var_name) + .expect("Bad impl: A valid recipe doesn't use unassigned vars") + .clone(); + (Rc::new(var_name), val) + }) + .collect(); + Val::obj(map) + } + }; + let filter = compile_filter(&program)?; + run_filter(filter, input_val)? + } + _ => { + return Ok(ValidateCallbackResult::Invalid( + "Bad RecipeInstructionExecution".into(), + )) + } + }; + vars.insert(out_var_name, val); + } + + let return_val = vars + .remove("$return") + .expect("Bad impl: A valid recipe has a $return"); + let output = return_val.to_string(); + + if output != recipe_execution.output { + return Ok(ValidateCallbackResult::Invalid( + "Provided output doesn't match execution's".into(), + )); + } + + Ok(ValidateCallbackResult::Valid) +} diff --git a/crates/username_registry_validation/src/username_attestation.rs b/crates/username_registry_validation/src/username_attestation.rs index d120160..201b85e 100644 --- a/crates/username_registry_validation/src/username_attestation.rs +++ b/crates/username_registry_validation/src/username_attestation.rs @@ -1,5 +1,6 @@ use hdi::prelude::*; -use holoom_types::{get_authority_agent, UsernameAttestation}; +use holoom_types::UsernameAttestation; +use username_registry_utils::get_authority_agent; pub fn validate_create_username_attestation( action: EntryCreationAction, diff --git a/packages/client/src/holoom-client.ts b/packages/client/src/holoom-client.ts index 58c13e4..1af5c25 100644 --- a/packages/client/src/holoom-client.ts +++ b/packages/client/src/holoom-client.ts @@ -1,10 +1,13 @@ -import type { AppAgentWebsocket, Record } from "@holochain/client"; +import type { ActionHash, AppAgentWebsocket, Record } from "@holochain/client"; import type { PublicKey as SolanaPublicKey } from "@solana/web3.js"; import { BoundWallet, ChainWalletSignature_Evm, ChainWalletSignature_Solana, + ExecuteRecipePayload, JqExecution, + Recipe, + RecipeExecution, UsernameAttestation, WalletAttestation, } from "./types"; @@ -252,4 +255,24 @@ export class HoloomClient { }); return JSON.parse(decodeAppEntry(record).output); } + + async createRecipe(recipe: Recipe): Promise { + const record: Record = await this.appAgent.callZome({ + role_name: "holoom", + zome_name: "username_registry", + fn_name: "create_recipe", + payload: recipe, + }); + return record; + } + + async executeRecipe(payload: ExecuteRecipePayload): Promise { + const record: Record = await this.appAgent.callZome({ + role_name: "holoom", + zome_name: "username_registry", + fn_name: "create_recipe", + payload, + }); + return decodeAppEntry(record).output; + } } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 733b43a..92902e2 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -1,4 +1,4 @@ -import type { AgentPubKey, Record } from "@holochain/client"; +import type { ActionHash, AgentPubKey, Record } from "@holochain/client"; export interface UsernameAttestation { agent: AgentPubKey; @@ -8,7 +8,7 @@ export interface UsernameAttestation { export type EvmSignature = [ Uint8Array, // r Uint8Array, // s - number // v + number, // v ]; export type ChainWalletSignature_Evm = { @@ -76,6 +76,53 @@ export interface RejectExternalIdRequestPayload { reason: string; } +export type RecipeArgumentType = { type: "String" }; + +export type RecipeInstruction = + | { type: "Constant"; value: string } + | { type: "GetLatestDocWithIdentifier"; var_name: string } + | { + input_var_names: JqInstructionArgumentNames; + program: string; + type: "Jq"; + } + | { type: "GetDocsListedByVar"; var_name: string } + | { type: "GetCallerExternalId" } + | { type: "GetCallerAgentPublicKey" }; +export type JqInstructionArgumentNames = + | { type: "Single"; var_name: string } + | { type: "List"; var_names: string[] }; + +export interface Recipe { + trusted_authors: AgentPubKey[]; + arguments: [string, RecipeArgumentType][]; + instructions: [string, RecipeInstruction][]; +} + +export type RecipeArgument = { type: "String"; value: string }; + +export interface ExecuteRecipePayload { + recipe_ah: ActionHash; + arguments: RecipeArgument[]; +} + +export type RecipeInstructionExecution = + | { type: "Constant" } + | { type: "GetLatestDocWithIdentifier"; doc_ah: ActionHash } + | { type: "Jq" } + | { type: "GetDocsListedByVar"; doc_ahs: ActionHash[] } + | { type: "GetCallerExternalId"; attestation_ah: ActionHash } + | { type: "GetCallerAgentPublicKey" }; + +export interface RecipeExecution { + recipe_ah: ActionHash; + arguments: RecipeArgument[]; + instruction_executions: RecipeInstructionExecution[]; + output: String; +} + +// Signals + export interface ExternalIdAttestationRequested { type: "ExternalIdAttestationRequested"; request_id: string;