From 17239f8dedef7b6fe77a15fe8d5135bc05e5fab6 Mon Sep 17 00:00:00 2001 From: 8e8b2c <138928994+8e8b2c@users.noreply.github.com> Date: Tue, 16 Apr 2024 15:21:08 +0100 Subject: [PATCH] feat(user_registry): Get/set user metadata (#7) Adds update_metadata_item and get_metadata_item_value coordinator functions for managing a user curated key-value store - implemented by encoding key-value pairs into link tags on the user's agent pub key. * chore: refactor e2e test * feat: get/set user metadata + e2e test * feat(user_registry): get another user's metadata + test refactor --- .github/workflows/e2e.yml | 1 + .vscode/settings.json | 2 +- Cargo.lock | 3 + Cargo.toml | 1 + crates/game_identity_tests/Cargo.toml | 1 + crates/game_identity_tests/src/lib.rs | 118 ++- crates/game_identity_tests/src/tests/mod.rs | 2 + .../{ => src}/tests/signer.rs | 2 +- .../src/tests/username_registry/mod.rs | 3 + .../tests/username_registry/user_metadata.rs | 63 ++ .../username_registry/username_attestation.rs | 407 ++++++++++ .../username_registry/wallet_attestation.rs | 149 ++++ .../tests/username_registry.rs | 719 ------------------ crates/game_identity_types/src/lib.rs | 19 + .../username_registry_coordinator/Cargo.toml | 1 + .../username_registry_coordinator/src/lib.rs | 1 + .../src/user_metadata.rs | 71 ++ crates/username_registry_integrity/src/lib.rs | 24 + .../username_registry_validation/Cargo.toml | 1 + .../username_registry_validation/src/lib.rs | 2 + .../src/user_metadata.rs | 44 ++ package.json | 2 +- .../src/holochain-game-identity-client.ts | 20 + packages/e2e/tests/index.test.js | 146 ---- packages/e2e/tests/metadata.test.js | 70 ++ packages/e2e/tests/username.test.js | 52 ++ packages/e2e/tests/utils/holo.js | 24 + packages/e2e/tests/utils/testcontainers.js | 74 ++ 28 files changed, 1153 insertions(+), 869 deletions(-) create mode 100644 crates/game_identity_tests/src/tests/mod.rs rename crates/game_identity_tests/{ => src}/tests/signer.rs (95%) create mode 100644 crates/game_identity_tests/src/tests/username_registry/mod.rs create mode 100644 crates/game_identity_tests/src/tests/username_registry/user_metadata.rs create mode 100644 crates/game_identity_tests/src/tests/username_registry/username_attestation.rs create mode 100644 crates/game_identity_tests/src/tests/username_registry/wallet_attestation.rs delete mode 100644 crates/game_identity_tests/tests/username_registry.rs create mode 100644 crates/username_registry_coordinator/src/user_metadata.rs create mode 100644 crates/username_registry_validation/src/user_metadata.rs delete mode 100644 packages/e2e/tests/index.test.js create mode 100644 packages/e2e/tests/metadata.test.js create mode 100644 packages/e2e/tests/username.test.js create mode 100644 packages/e2e/tests/utils/holo.js create mode 100644 packages/e2e/tests/utils/testcontainers.js diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fa98039..44bfb4b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -65,6 +65,7 @@ jobs: path: "**/node_modules" key: node_modules-${{ hashFiles('**/package-lock.json')}} - run: npm i + - run: npx puppeteer browsers install chrome - run: npm run build:client - name: Start frontend in background run: npm run dev -w @holochain-game-identity/e2e & diff --git a/.vscode/settings.json b/.vscode/settings.json index bd94beb..bc5f7bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["Dalek", "Holochain", "Solana", "zome", "zomes"] + "cSpell.words": ["Dalek", "Holochain", "pubkey", "Solana", "zome", "zomes"] } diff --git a/Cargo.lock b/Cargo.lock index 9a4a9b3..a3cbd64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2751,6 +2751,7 @@ dependencies = [ "hdk", "holochain", "holochain_keystore", + "serde", "tokio", "username_registry_validation", ] @@ -8522,6 +8523,7 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" name = "username_registry_coordinator" version = "0.0.1" dependencies = [ + "bincode", "game_identity_types", "hdk", "username_registry_integrity", @@ -8542,6 +8544,7 @@ dependencies = [ name = "username_registry_validation" version = "0.0.1" dependencies = [ + "bincode", "bs58 0.5.0", "ed25519-dalek", "game_identity_types", diff --git a/Cargo.toml b/Cargo.toml index 20207f2..ab41e04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ hdi = "=0.3.6" hdk = "=0.2.6" holo_hash = { version = "=0.2.6", features = ["encoding"] } serde = "=1.0.166" +bincode = "1.3.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/game_identity_tests/Cargo.toml b/crates/game_identity_tests/Cargo.toml index ebc9f25..fe579af 100644 --- a/crates/game_identity_tests/Cargo.toml +++ b/crates/game_identity_tests/Cargo.toml @@ -8,6 +8,7 @@ crate-type = ["cdylib", "rlib"] name = "game_identity_tests" [dependencies] +serde = { workspace = true } game_identity_types = { workspace = true } hdk = { workspace = true, features = ["encoding", "test_utils"] } holochain = { workspace = true, default-features = false, features = [ diff --git a/crates/game_identity_tests/src/lib.rs b/crates/game_identity_tests/src/lib.rs index baf4efe..8ab833e 100644 --- a/crates/game_identity_tests/src/lib.rs +++ b/crates/game_identity_tests/src/lib.rs @@ -1,6 +1,15 @@ +use std::time::Duration; + use game_identity_types::GameIdentityDnaProperties; use hdk::prelude::*; -use holochain::{prelude::DnaFile, sweettest::SweetDnaFile}; +use holochain::{ + conductor::{api::error::ConductorApiResult, config::ConductorConfig}, + prelude::DnaFile, + sweettest::{consistency, SweetAgents, SweetCell, SweetConductorBatch, SweetDnaFile}, +}; + +#[cfg(test)] +mod tests; async fn load_dna() -> DnaFile { // Use prebuilt dna file @@ -24,3 +33,110 @@ pub async fn game_identity_dna_with_authority(authority_agent: &AgentPubKey) -> quantum_time: None, }) } + +pub struct TestSetup { + pub conductors: SweetConductorBatch, + cells: Vec, +} + +impl TestSetup { + pub async fn new(user_count: usize) -> Self { + // Set up conductors + let mut conductors: SweetConductorBatch = + SweetConductorBatch::from_config(1 + user_count, ConductorConfig::default()).await; + + let authority_agent_pubkey = SweetAgents::one(conductors[0].keystore()).await; + + let dnas = &[game_identity_dna_with_authority(&authority_agent_pubkey).await]; + + let authority_app = conductors[0] + .setup_app_for_agent("game_identity", authority_agent_pubkey.clone(), dnas) + .await + .unwrap(); + let (authority_cell,) = authority_app.into_tuple(); + let mut cells = Vec::from([authority_cell]); + for i in 1..1 + user_count { + let user_app = conductors[i] + .setup_app("game_identity", dnas) + .await + .unwrap(); + let (user_cell,) = user_app.into_tuple(); + cells.push(user_cell); + } + + TestSetup { conductors, cells } + } + + pub async fn authority_only() -> Self { + Self::new(0).await + } + + pub async fn authority_and_alice() -> Self { + Self::new(1).await + } + + pub async fn authority_and_alice_bob() -> Self { + Self::new(2).await + } + + pub async fn authority_call( + &self, + zome_name: &str, + fn_name: &str, + payload: I, + ) -> ConductorApiResult + where + I: serde::Serialize + std::fmt::Debug, + O: serde::de::DeserializeOwned + std::fmt::Debug, + { + self.conductors[0] + .call_fallible(&self.cells[0].zome(zome_name), fn_name, payload) + .await + } + + pub async fn alice_call( + &self, + zome_name: &str, + fn_name: &str, + payload: I, + ) -> ConductorApiResult + where + I: serde::Serialize + std::fmt::Debug, + O: serde::de::DeserializeOwned + std::fmt::Debug, + { + self.conductors[1] + .call_fallible(&self.cells[1].zome(zome_name), fn_name, payload) + .await + } + + pub async fn bob_call( + &self, + zome_name: &str, + fn_name: &str, + payload: I, + ) -> ConductorApiResult + where + I: serde::Serialize + std::fmt::Debug, + O: serde::de::DeserializeOwned + std::fmt::Debug, + { + self.conductors[2] + .call_fallible(&self.cells[2].zome(zome_name), fn_name, payload) + .await + } + + pub fn authority_pubkey(&self) -> AgentPubKey { + self.cells[0].agent_pubkey().clone() + } + + pub fn alice_pubkey(&self) -> AgentPubKey { + self.cells[1].agent_pubkey().clone() + } + + pub fn bob_pubkey(&self) -> AgentPubKey { + self.cells[1].agent_pubkey().clone() + } + + pub async fn consistency(&self) { + consistency(self.cells.iter(), 100, Duration::from_secs(10)).await; + } +} diff --git a/crates/game_identity_tests/src/tests/mod.rs b/crates/game_identity_tests/src/tests/mod.rs new file mode 100644 index 0000000..644a0e4 --- /dev/null +++ b/crates/game_identity_tests/src/tests/mod.rs @@ -0,0 +1,2 @@ +mod signer; +mod username_registry; diff --git a/crates/game_identity_tests/tests/signer.rs b/crates/game_identity_tests/src/tests/signer.rs similarity index 95% rename from crates/game_identity_tests/tests/signer.rs rename to crates/game_identity_tests/src/tests/signer.rs index b005b2b..8d68841 100644 --- a/crates/game_identity_tests/tests/signer.rs +++ b/crates/game_identity_tests/src/tests/signer.rs @@ -1,4 +1,4 @@ -use game_identity_tests::game_identity_dna_with_authority; +use crate::game_identity_dna_with_authority; use game_identity_types::SignableBytes; use hdk::prelude::fake_agent_pubkey_1; use holochain::{conductor::config::ConductorConfig, prelude::Signature, sweettest::*}; diff --git a/crates/game_identity_tests/src/tests/username_registry/mod.rs b/crates/game_identity_tests/src/tests/username_registry/mod.rs new file mode 100644 index 0000000..74512bc --- /dev/null +++ b/crates/game_identity_tests/src/tests/username_registry/mod.rs @@ -0,0 +1,3 @@ +mod user_metadata; +mod username_attestation; +mod wallet_attestation; diff --git a/crates/game_identity_tests/src/tests/username_registry/user_metadata.rs b/crates/game_identity_tests/src/tests/username_registry/user_metadata.rs new file mode 100644 index 0000000..2c6dfd6 --- /dev/null +++ b/crates/game_identity_tests/src/tests/username_registry/user_metadata.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; + +use game_identity_types::{GetMetadataItemValuePayload, UpdateMetadataItemPayload}; +use holochain::conductor::api::error::ConductorApiError; + +use crate::TestSetup; + +#[tokio::test(flavor = "multi_thread")] +async fn users_can_only_update_their_own_metadata() { + let setup = TestSetup::authority_and_alice_bob().await; + setup.conductors.exchange_peer_info().await; + + // Alice starts with no metadata + let initial_metadata: HashMap = setup + .alice_call("username_registry", "get_metadata", setup.alice_pubkey()) + .await + .unwrap(); + assert_eq!(initial_metadata, HashMap::default()); + + // Bob cannot set Alice's metadata + let res1: Result<(), ConductorApiError> = setup + .bob_call( + "username_registry", + "update_metadata_item", + UpdateMetadataItemPayload { + agent_pubkey: setup.alice_pubkey(), + name: "foo".into(), + value: "bar".into(), + }, + ) + .await; + assert!(res1.is_err()); + + // Alice sets an item + let _: () = setup + .alice_call( + "username_registry", + "update_metadata_item", + UpdateMetadataItemPayload { + agent_pubkey: setup.alice_pubkey(), + name: "foo".into(), + value: "bar2".into(), + }, + ) + .await + .unwrap(); + + setup.consistency().await; + + // Bob sees new item + let value1: String = setup + .bob_call( + "username_registry", + "get_metadata_item_value", + GetMetadataItemValuePayload { + agent_pubkey: setup.alice_pubkey(), + name: "foo".into(), + }, + ) + .await + .unwrap(); + assert_eq!(value1, String::from("bar2")); +} diff --git a/crates/game_identity_tests/src/tests/username_registry/username_attestation.rs b/crates/game_identity_tests/src/tests/username_registry/username_attestation.rs new file mode 100644 index 0000000..146cac2 --- /dev/null +++ b/crates/game_identity_tests/src/tests/username_registry/username_attestation.rs @@ -0,0 +1,407 @@ +use game_identity_types::{SignableBytes, SignedUsername, UsernameAttestation}; +use hdk::prelude::*; +use holochain::conductor::api::error::ConductorApiError; + +use crate::TestSetup; + +#[tokio::test(flavor = "multi_thread")] +async fn only_authority_can_create_username_attestation() { + let setup = TestSetup::authority_and_alice().await; + + // Authority creates a UsernameAttestation for alice + let _: Record = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "a_cool_guy1".into(), + agent: setup.alice_pubkey(), + }, + ) + .await + .unwrap(); + + // Alice cannot create an UsernameAttestation + let result: Result = setup + .alice_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "a_cool_guy2".into(), + agent: setup.alice_pubkey(), + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn same_username_cannot_be_registered_twice() { + // Set up conductors + let setup = TestSetup::authority_only().await; + + // Authority creates an UsernameAttestation + let _: Record = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "a_cool_guy".into(), + agent: fake_agent_pub_key(0), + }, + ) + .await + .unwrap(); + + // Authority creates a UsernameAttestation with an identical username + let result: Result = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "a_cool_guy".into(), + agent: fake_agent_pub_key(1), + }, + ) + .await; + + assert!(result.is_err()); + + // Authority creates a UsernameAttestation with a different username + let _: Record = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "a_cool_guy2".into(), + agent: fake_agent_pub_key(2), + }, + ) + .await + .unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn same_agent_cannot_be_registered_twice() { + // Set up conductors + let setup = TestSetup::authority_only().await; + + // Authority creates an UsernameAttestation + let _: Record = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "a_cool_guy".into(), + agent: fake_agent_pubkey_1(), + }, + ) + .await + .unwrap(); + + // Authority creates a UsernameAttestation with an identical agent + let result: Result = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "a_different_guy".into(), + agent: fake_agent_pubkey_1(), + }, + ) + .await; + + assert!(result.is_err()); + + // Authority creates a UsernameAttestation with a different agent + let _: Record = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "a_third_guy".into(), + agent: fake_agent_pubkey_2(), + }, + ) + .await + .unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn username_must_be_within_character_limit() { + let setup = TestSetup::authority_only().await; + + // Authority creates an username of 5 characters + let result1: Result = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "abcde".into(), + agent: fake_agent_pubkey_1(), + }, + ) + .await; + + assert!(result1.is_err()); + + // Alice creates an username of 33 characters + let result2: Result = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "abcdeabcdeabcdeabcdeabcdeabcdeabc".into(), + agent: fake_agent_pubkey_1(), + }, + ) + .await; + + assert!(result2.is_err()); + + // Alice creates an username of 15 characters + let result3: Result = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "abcdeabcdeabcde".into(), + agent: fake_agent_pubkey_1(), + }, + ) + .await; + + assert!(result3.is_ok()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn nobody_can_delete_username_attestation() { + let setup = TestSetup::authority_and_alice().await; + setup.conductors.exchange_peer_info().await; + + // Authority creates a UsernameAttestation + let record: Record = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "asodijsadvjsadlkj".into(), + agent: fake_agent_pubkey_1(), + }, + ) + .await + .unwrap(); + + setup.consistency().await; + + // Authority cannot delete a UsernameAttestation + let result: Result = setup + .authority_call( + "username_registry", + "delete_username_attestation", + record.action_address(), + ) + .await; + + assert!(result.is_err()); + + // Alice cannot delete a UsernameAttestation + let result2: Result = setup + .alice_call( + "username_registry", + "delete_username_attestation", + record.action_address(), + ) + .await; + + assert!(result2.is_err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn all_can_get_username_attestations() { + let setup = TestSetup::authority_and_alice().await; + setup.conductors.exchange_peer_info().await; + + // Authority creates an UsernameAttestation + let record: Record = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "asodijsadvjsadlkj".into(), + agent: fake_agent_pubkey_1(), + }, + ) + .await + .unwrap(); + + setup.consistency().await; + + // Authority gets the UsernameAttestation + let maybe_record: Option = setup + .authority_call( + "username_registry", + "get_username_attestation", + record.action_address(), + ) + .await + .unwrap(); + + assert!(maybe_record.is_some()); + + // Alice gets the UsernameAttestation + let maybe_record2: Option = setup + .alice_call( + "username_registry", + "get_username_attestation", + record.action_address(), + ) + .await + .unwrap(); + + assert!(maybe_record2.is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn all_can_get_username_attestation_for_agent() { + let setup = TestSetup::authority_and_alice().await; + setup.conductors.exchange_peer_info().await; + + // Authority creates an UsernameAttestation + let _: Record = setup + .authority_call( + "username_registry", + "create_username_attestation", + UsernameAttestation { + username: "username1".into(), + agent: fake_agent_pubkey_1(), + }, + ) + .await + .unwrap(); + + // Authority gets the UsernameAttestation + let maybe_record: Option = setup + .authority_call( + "username_registry", + "get_username_attestation_for_agent", + fake_agent_pubkey_1(), + ) + .await + .unwrap(); + let entry = maybe_record + .unwrap() + .entry() + .to_app_option::() + .unwrap() + .unwrap(); + + assert_eq!(entry.username, "username1"); + assert_eq!(entry.agent, fake_agent_pubkey_1()); + + // Alice gets the UsernameAttestation + setup.consistency().await; + + let maybe_record2: Option = setup + .alice_call( + "username_registry", + "get_username_attestation_for_agent", + fake_agent_pubkey_1(), + ) + .await + .unwrap(); + let entry2 = maybe_record2 + .unwrap() + .entry() + .to_app_option::() + .unwrap() + .unwrap(); + + assert_eq!(entry2.username, "username1"); + assert_eq!(entry2.agent, fake_agent_pubkey_1()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cannot_get_username_attestation_for_agent_that_doesnt_exist() { + let setup = TestSetup::authority_only().await; + + // Authority tries to get UsernameAttestation + let res: Option = setup + .authority_call( + "username_registry", + "get_username_attestation_for_agent", + fake_agent_pubkey_1(), + ) + .await + .unwrap(); + + assert!(res.is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_attest_username_via_remote_call() { + let setup = TestSetup::authority_and_alice().await; + setup.conductors.exchange_peer_info().await; + + // Alice creates a UsernameAttestation + let record: Record = setup + .alice_call( + "username_registry", + "sign_username_to_attest", + "asodijsadvjsadlkj".to_string(), + ) + .await + .unwrap(); + + setup.consistency().await; + + // UsernameAttestation has been created + let result: Result, ConductorApiError> = setup + .authority_call( + "username_registry", + "get_username_attestation", + record.action_address(), + ) + .await; + + let same_record = result + .expect(" get_username_attestation should have succeeded") + .expect("Record should exist"); + assert_eq!(same_record.action_address(), record.action_address()); + let entry = record + .entry() + .to_app_option::() + .unwrap() + .unwrap(); + assert_ne!(entry.agent, setup.authority_pubkey()); + assert_eq!(entry.agent, setup.alice_pubkey()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn authority_wont_ingest_invalid_username_signature() { + let setup = TestSetup::authority_and_alice().await; + setup.conductors.exchange_peer_info().await; + + // Alice signs username + let signature: Signature = setup + .alice_call("signer", "sign_message", SignableBytes("whatever".into())) + .await + .unwrap(); + let invalid_signed_username = SignedUsername { + username: "a_different_name".into(), + signature, + signer: setup.alice_pubkey(), + }; + + // Authority ingests signed username + let result: Result = setup + .authority_call( + "username_registry", + "ingest_signed_username", + invalid_signed_username, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/game_identity_tests/src/tests/username_registry/wallet_attestation.rs b/crates/game_identity_tests/src/tests/username_registry/wallet_attestation.rs new file mode 100644 index 0000000..1e74d05 --- /dev/null +++ b/crates/game_identity_tests/src/tests/username_registry/wallet_attestation.rs @@ -0,0 +1,149 @@ +use game_identity_types::{ChainWalletSignature, EvmAddress, EvmSignature, WalletAttestation}; +use hdk::prelude::*; +use holochain::conductor::api::error::ConductorApiResult; +use std::str::FromStr; +use username_registry_validation::{evm_signing_message, solana_signing_message}; + +use crate::TestSetup; + +#[tokio::test(flavor = "multi_thread")] +async fn checks_validity_of_evm_wallet_attestation() { + let setup = TestSetup::authority_and_alice().await; + + // Create WalletAttestation for alice at address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + + // First account of seed phrase: test test test test test test test test test test test junk + let signer_private_key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + use ethers_signers::{LocalWallet, Signer}; + let signer_wallet = LocalWallet::from_str(signer_private_key).unwrap(); + let wallet_address = signer_wallet.address(); + let wallet_address = EvmAddress::try_from(wallet_address.as_bytes()).unwrap(); + assert_eq!( + wallet_address.to_checksum(None), + String::from("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") + ); + let message: String = setup + .alice_call( + "username_registry", + "get_evm_wallet_binding_message", + wallet_address, + ) + .await + .unwrap(); + + let signature = signer_wallet.sign_message(message).await.unwrap(); + let signature_bytes = signature.to_vec(); + let signature = EvmSignature::try_from(&signature_bytes[..]).unwrap(); + + let chain_wallet_signature = ChainWalletSignature::Evm { + evm_address: wallet_address, + evm_signature: signature, + }; + + // Genuine attestation should be accepted + let res: ConductorApiResult = setup + .alice_call( + "username_registry", + "attest_wallet_signature", + chain_wallet_signature, + ) + .await; + assert!(res.is_ok()); + + let prev_action = res.unwrap().action_address().clone(); + let malicious_message = + evm_signing_message(&wallet_address, fake_agent_pubkey_1(), prev_action.clone()); + let signature = signer_wallet.sign_message(malicious_message).await.unwrap(); + let signature_bytes = signature.to_vec(); + let signature = EvmSignature::try_from(&signature_bytes[..]).unwrap(); + + let malicious_attestation = WalletAttestation { + chain_wallet_signature: ChainWalletSignature::Evm { + evm_address: wallet_address, + evm_signature: signature, + }, + agent: setup.alice_pubkey(), + prev_action, + }; + + // Malicious attestation should be rejected + let res: ConductorApiResult = setup + .alice_call( + "username_registry", + "create_wallet_attestation", + malicious_attestation, + ) + .await; + assert!(res.is_err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checks_validity_of_solana_wallet_attestation() { + let setup = TestSetup::authority_and_alice().await; + + // Create WalletAttestation for alice at address oeYf6KAJkLYhBuR8CiGc6L4D4Xtfepr85fuDgA9kq96 + + // First account of seed phrase: test test test test test test test test test test test junk + let private_key = + "4Cfc4TJ6dsWwLcw8aJ5uhx7UJKPR5VGXTu2iJr5bVRoTDsxzb6qfJrzR5HNhBcwGwsXqGeHzDR3eUWLr7MRnska8"; + let private_key_bytes = bs58::decode(private_key).into_vec().unwrap(); + + use ed25519_dalek::{Signer, SigningKey, SECRET_KEY_LENGTH}; + + let signing_key = SigningKey::try_from(&private_key_bytes[..SECRET_KEY_LENGTH]).unwrap(); + let solana_address = signing_key.verifying_key(); + + assert_eq!( + bs58::encode(solana_address.as_bytes()).into_string(), + String::from("oeYf6KAJkLYhBuR8CiGc6L4D4Xtfepr85fuDgA9kq96") + ); + + let message: String = setup + .alice_call( + "username_registry", + "get_solana_wallet_binding_message", + solana_address, + ) + .await + .unwrap(); + let solana_signature = signing_key.try_sign(message.as_bytes()).unwrap(); + + let chain_wallet_signature = ChainWalletSignature::Solana { + solana_address, + solana_signature, + }; + + // Genuine attestation should be accepted + let res: ConductorApiResult = setup + .alice_call( + "username_registry", + "attest_wallet_signature", + chain_wallet_signature, + ) + .await; + assert!(res.is_ok()); + + let prev_action = res.unwrap().action_address().clone(); + let malicious_message = + solana_signing_message(&solana_address, fake_agent_pubkey_1(), prev_action.clone()); + let solana_signature = signing_key.try_sign(malicious_message.as_bytes()).unwrap(); + + let malicious_attestation = WalletAttestation { + chain_wallet_signature: ChainWalletSignature::Solana { + solana_address, + solana_signature, + }, + agent: setup.alice_pubkey(), + prev_action, + }; + + // Genuine attestation should be rejected + let res: ConductorApiResult = setup + .alice_call( + "username_registry", + "create_wallet_attestation", + malicious_attestation, + ) + .await; + assert!(res.is_err()); +} diff --git a/crates/game_identity_tests/tests/username_registry.rs b/crates/game_identity_tests/tests/username_registry.rs deleted file mode 100644 index 3fdf164..0000000 --- a/crates/game_identity_tests/tests/username_registry.rs +++ /dev/null @@ -1,719 +0,0 @@ -use game_identity_types::{ - ChainWalletSignature, EvmAddress, EvmSignature, SignableBytes, SignedUsername, - UsernameAttestation, WalletAttestation, -}; -use hdk::prelude::*; -use holochain::{ - conductor::{ - api::error::{ConductorApiError, ConductorApiResult}, - config::ConductorConfig, - }, - sweettest::*, -}; -use std::{str::FromStr, time::Duration}; -use username_registry_validation::{evm_signing_message, solana_signing_message}; - -use game_identity_tests::game_identity_dna_with_authority; - -#[tokio::test(flavor = "multi_thread")] -async fn only_authority_can_create_username_attestation() { - // Set up conductors - let mut conductors: SweetConductorBatch = - SweetConductorBatch::from_config(2, ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductors[0].keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductors[0] - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - let app2 = conductors[1] - .setup_app("game_identity", dnas) - .await - .unwrap(); - let (bob,) = app2.into_tuple(); - - // Alice creates an UsernameAttestation - let _: Record = conductors[0] - .call( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "a_cool_guy1".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - // Bob cannot create an UsernameAttestation - let result: Result = conductors[1] - .call_fallible( - &bob.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "a_cool_guy2".into(), - agent: bob.agent_pubkey().clone(), - }, - ) - .await; - - assert!(result.is_err()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn same_username_cannot_be_registered_twice() { - // Set up conductors - let mut conductor = SweetConductor::from_config(ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductor.keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductor - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - - // Alice creates an UsernameAttestation - let _: Record = conductor - .call( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "a_cool_guy".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - // Alice creates a UsernameAttestation with an identical username - let other_agentpubkey1 = SweetAgents::one(conductor.keystore()).await; - let result: Result = conductor - .call_fallible( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "a_cool_guy".into(), - agent: other_agentpubkey1.clone(), - }, - ) - .await; - - assert!(result.is_err()); - - // Alice creates a UsernameAttestation with a different username - let other_agentpubkey2 = SweetAgents::one(conductor.keystore()).await; - let _: Record = conductor - .call( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "a_cool_guy2".into(), - agent: other_agentpubkey2.clone(), - }, - ) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn same_agent_cannot_be_registered_twice() { - // Set up conductors - let mut conductor = SweetConductor::from_config(ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductor.keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductor - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - - // Alice creates an UsernameAttestation - let _: Record = conductor - .call( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "a_cool_guy".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - // Alice creates a UsernameAttestation with an identical agent - let result: Result = conductor - .call_fallible( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "a_different_guy".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - assert!(result.is_err()); - - // Alice creates a UsernameAttestation with a different agent - let other_agentpubkey1 = SweetAgents::one(conductor.keystore()).await; - let _: Record = conductor - .call( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "a_third_guy".into(), - agent: other_agentpubkey1.clone(), - }, - ) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn username_must_be_within_character_limit() { - // Set up conductors - let mut conductor = SweetConductor::from_config(ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductor.keystore()).await; - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductor - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - - // Alice creates an username of 5 characters - let result1: Result = conductor - .call_fallible( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "abcde".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - assert!(result1.is_err()); - - // Alice creates an username of 33 characters - let result2: Result = conductor - .call_fallible( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "abcdeabcdeabcdeabcdeabcdeabcdeabc".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - assert!(result2.is_err()); - - // Alice creates an username of 15 characters - let result3: Result = conductor - .call_fallible( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "abcdeabcdeabcde".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - assert!(result3.is_ok()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn nobody_can_delete_username_attestation() { - // Set up conductors - let mut conductors: SweetConductorBatch = - SweetConductorBatch::from_config(2, ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductors[0].keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductors[0] - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - let app2 = conductors[1] - .setup_app("game_identity", dnas) - .await - .unwrap(); - let (bob,) = app2.into_tuple(); - conductors.exchange_peer_info().await; - - // Alice creates a UsernameAttestation - let record: Record = conductors[0] - .call( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "asodijsadvjsadlkj".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - consistency([&alice, &bob], 100, Duration::from_secs(10)).await; - - // Alice cannot delete a UsernameAttestation - let result: Result = conductors[0] - .call_fallible( - &alice.zome("username_registry"), - "delete_username_attestation", - record.action_address(), - ) - .await; - - assert!(result.is_err()); - - // bob cannot delete a UsernameAttestation - let result2: Result = conductors[1] - .call_fallible( - &bob.zome("username_registry"), - "delete_username_attestation", - record.action_address(), - ) - .await; - - assert!(result2.is_err()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn all_can_get_username_attestations() { - // Set up conductors - let mut conductors: SweetConductorBatch = - SweetConductorBatch::from_config(2, ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductors[0].keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductors[0] - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - let app2 = conductors[1] - .setup_app("game_identity", dnas) - .await - .unwrap(); - let (bob,) = app2.into_tuple(); - conductors.exchange_peer_info().await; - - // Alice creates an UsernameAttestation - let record: Record = conductors[0] - .call( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "asodijsadvjsadlkj".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - consistency([&alice, &bob], 100, Duration::from_secs(10)).await; - - // Alice gets the UsernameAttestation - let maybe_record: Option = conductors[0] - .call( - &alice.zome("username_registry"), - "get_username_attestation", - record.action_address(), - ) - .await; - - assert!(maybe_record.is_some()); - - // Bob gets the UsernameAttestation - let maybe_record2: Option = conductors[1] - .call( - &bob.zome("username_registry"), - "get_username_attestation", - record.action_address(), - ) - .await; - - assert!(maybe_record2.is_some()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn all_can_get_username_attestation_for_agent() { - // Set up conductors - let mut conductors: SweetConductorBatch = - SweetConductorBatch::from_config(2, ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductors[0].keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductors[0] - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - let app2 = conductors[1] - .setup_app("game_identity", dnas) - .await - .unwrap(); - let (bob,) = app2.into_tuple(); - conductors.exchange_peer_info().await; - - // Alice creates an UsernameAttestation - let _: Record = conductors[0] - .call( - &alice.zome("username_registry"), - "create_username_attestation", - UsernameAttestation { - username: "username1".into(), - agent: alice_agentpubkey.clone(), - }, - ) - .await; - - // Alice gets the UsernameAttestation - let maybe_record: Option = conductors[0] - .call( - &alice.zome("username_registry"), - "get_username_attestation_for_agent", - alice_agentpubkey.clone(), - ) - .await; - let entry = maybe_record - .unwrap() - .entry() - .to_app_option::() - .unwrap() - .unwrap(); - - assert_eq!(entry.username, "username1"); - assert_eq!(entry.agent, alice_agentpubkey.clone()); - - // Bob gets the UsernameAttestation - consistency([&alice, &bob], 100, Duration::from_secs(10)).await; - - let maybe_record2: Option = conductors[1] - .call( - &bob.zome("username_registry"), - "get_username_attestation_for_agent", - alice_agentpubkey.clone(), - ) - .await; - let entry2 = maybe_record2 - .unwrap() - .entry() - .to_app_option::() - .unwrap() - .unwrap(); - - assert_eq!(entry2.username, "username1"); - assert_eq!(entry2.agent, alice_agentpubkey.clone()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn cannot_get_username_attestation_for_agent_that_doesnt_exist() { - // Set up conductors - let mut conductors: SweetConductorBatch = - SweetConductorBatch::from_config(1, ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductors[0].keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductors[0] - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - - // Alice tries to get UsernameAttestation - let res: Option = conductors[0] - .call( - &alice.zome("username_registry"), - "get_username_attestation_for_agent", - fake_agent_pubkey_1(), - ) - .await; - - assert!(res.is_none()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn can_attest_username_via_remote_call() { - // Set up conductors - let mut conductors: SweetConductorBatch = - SweetConductorBatch::from_config(2, ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductors[0].keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductors[0] - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - let app2 = conductors[1] - .setup_app("game_identity", dnas) - .await - .unwrap(); - let (bob,) = app2.into_tuple(); - - conductors.exchange_peer_info().await; - - // Alice creates a UsernameAttestation - let record: Record = conductors[1] - .call( - &bob.zome("username_registry"), - "sign_username_to_attest", - "asodijsadvjsadlkj".to_string(), - ) - .await; - - consistency([&alice, &bob], 100, Duration::from_secs(10)).await; - - // UsernameAttestation has been created - let result: Result, ConductorApiError> = conductors[0] - .call_fallible( - &alice.zome("username_registry"), - "get_username_attestation", - record.action_address(), - ) - .await; - - let same_record = result - .expect(" get_username_attestation should have succeeded") - .expect("Record should exist"); - assert_eq!(same_record.action_address(), record.action_address()); - let entry = record - .entry() - .to_app_option::() - .unwrap() - .unwrap(); - assert_ne!(entry.agent, alice_agentpubkey); - assert_eq!(&entry.agent, bob.agent_pubkey()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn authority_wont_ingest_invalid_username_signature() { - // Set up conductors - let mut conductors: SweetConductorBatch = - SweetConductorBatch::from_config(2, ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductors[0].keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&alice_agentpubkey).await]; - - let app = conductors[0] - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - let app2 = conductors[1] - .setup_app("game_identity", dnas) - .await - .unwrap(); - let (bob,) = app2.into_tuple(); - - conductors.exchange_peer_info().await; - - let signature: Signature = conductors[1] - .call( - &bob.zome("signer"), - "sign_message", - SignableBytes("whatever".into()), - ) - .await; - let invalid_signed_username = SignedUsername { - username: "a_different_name".into(), - signature, - signer: bob.agent_pubkey().clone(), - }; - - // Alice creates a UsernameAttestation - let result: Result = conductors[0] - .call_fallible( - &alice.zome("username_registry"), - "ingest_signed_username", - invalid_signed_username, - ) - .await; - - assert!(result.is_err()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn checks_validity_of_evm_wallet_attestation() { - // Set up conductors - let mut conductors: SweetConductorBatch = - SweetConductorBatch::from_config(1, ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductors[0].keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&fake_agent_pubkey_1()).await]; - - let app = conductors[0] - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - - // Create WalletAttestation for alice at address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - - // First account of seed phrase: test test test test test test test test test test test junk - let signer_private_key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; - use ethers_signers::{LocalWallet, Signer}; - let signer_wallet = LocalWallet::from_str(signer_private_key).unwrap(); - let wallet_address = signer_wallet.address(); - let wallet_address = EvmAddress::try_from(wallet_address.as_bytes()).unwrap(); - assert_eq!( - wallet_address.to_checksum(None), - String::from("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") - ); - let message: String = conductors[0] - .call( - &alice.zome("username_registry"), - "get_evm_wallet_binding_message", - wallet_address, - ) - .await; - - let signature = signer_wallet.sign_message(message).await.unwrap(); - let signature_bytes = signature.to_vec(); - let signature = EvmSignature::try_from(&signature_bytes[..]).unwrap(); - - let chain_wallet_signature = ChainWalletSignature::Evm { - evm_address: wallet_address, - evm_signature: signature, - }; - - // Genuine attestation should be accepted - let res: ConductorApiResult = conductors[0] - .call_fallible( - &alice.zome("username_registry"), - "attest_wallet_signature", - chain_wallet_signature, - ) - .await; - assert!(res.is_ok()); - - let prev_action = res.unwrap().action_address().clone(); - let malicious_message = - evm_signing_message(&wallet_address, fake_agent_pubkey_1(), prev_action.clone()); - let signature = signer_wallet.sign_message(malicious_message).await.unwrap(); - let signature_bytes = signature.to_vec(); - let signature = EvmSignature::try_from(&signature_bytes[..]).unwrap(); - - let malicious_attestation = WalletAttestation { - chain_wallet_signature: ChainWalletSignature::Evm { - evm_address: wallet_address, - evm_signature: signature, - }, - agent: alice_agentpubkey.clone(), - prev_action, - }; - - // Malicious attestation should be rejected - let res: ConductorApiResult = conductors[0] - .call_fallible( - &alice.zome("username_registry"), - "create_wallet_attestation", - malicious_attestation, - ) - .await; - assert!(res.is_err()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn checks_validity_of_solana_wallet_attestation() { - // Set up conductors - let mut conductors: SweetConductorBatch = - SweetConductorBatch::from_config(1, ConductorConfig::default()).await; - - let alice_agentpubkey = SweetAgents::one(conductors[0].keystore()).await; - - let dnas = &[game_identity_dna_with_authority(&fake_agent_pubkey_1()).await]; - - let app = conductors[0] - .setup_app_for_agent("game_identity", alice_agentpubkey.clone(), dnas) - .await - .unwrap(); - let (alice,) = app.into_tuple(); - - // Create WalletAttestation for alice at address oeYf6KAJkLYhBuR8CiGc6L4D4Xtfepr85fuDgA9kq96 - - // First account of seed phrase: test test test test test test test test test test test junk - let private_key = - "4Cfc4TJ6dsWwLcw8aJ5uhx7UJKPR5VGXTu2iJr5bVRoTDsxzb6qfJrzR5HNhBcwGwsXqGeHzDR3eUWLr7MRnska8"; - let private_key_bytes = bs58::decode(private_key).into_vec().unwrap(); - - use ed25519_dalek::{Signer, SigningKey, SECRET_KEY_LENGTH}; - - let signing_key = SigningKey::try_from(&private_key_bytes[..SECRET_KEY_LENGTH]).unwrap(); - let solana_address = signing_key.verifying_key(); - - assert_eq!( - bs58::encode(solana_address.as_bytes()).into_string(), - String::from("oeYf6KAJkLYhBuR8CiGc6L4D4Xtfepr85fuDgA9kq96") - ); - - let message: String = conductors[0] - .call( - &alice.zome("username_registry"), - "get_solana_wallet_binding_message", - solana_address, - ) - .await; - let solana_signature = signing_key.try_sign(message.as_bytes()).unwrap(); - - let chain_wallet_signature = ChainWalletSignature::Solana { - solana_address, - solana_signature, - }; - - // Genuine attestation should be accepted - let res: ConductorApiResult = conductors[0] - .call_fallible( - &alice.zome("username_registry"), - "attest_wallet_signature", - chain_wallet_signature, - ) - .await; - assert!(res.is_ok()); - - let prev_action = res.unwrap().action_address().clone(); - let malicious_message = - solana_signing_message(&solana_address, fake_agent_pubkey_1(), prev_action.clone()); - let solana_signature = signing_key.try_sign(malicious_message.as_bytes()).unwrap(); - - let malicious_attestation = WalletAttestation { - chain_wallet_signature: ChainWalletSignature::Solana { - solana_address, - solana_signature, - }, - agent: alice_agentpubkey.clone(), - prev_action, - }; - - // Genuine attestation should be rejected - let res: ConductorApiResult = conductors[0] - .call_fallible( - &alice.zome("username_registry"), - "create_wallet_attestation", - malicious_attestation, - ) - .await; - assert!(res.is_err()); -} diff --git a/crates/game_identity_types/src/lib.rs b/crates/game_identity_types/src/lib.rs index b62a172..af1e9f9 100644 --- a/crates/game_identity_types/src/lib.rs +++ b/crates/game_identity_types/src/lib.rs @@ -15,6 +15,25 @@ pub struct SignedUsername { pub signer: AgentPubKey, } +#[derive(Serialize, Deserialize, Debug)] +pub struct MetadataItem { + pub name: String, + pub value: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdateMetadataItemPayload { + pub agent_pubkey: AgentPubKey, + pub name: String, + pub value: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetMetadataItemValuePayload { + pub agent_pubkey: AgentPubKey, + pub name: String, +} + pub type EvmAddress = alloy_primitives::Address; pub type EvmSignature = alloy_primitives::Signature; pub type SolanaAddress = ed25519_dalek::VerifyingKey; diff --git a/crates/username_registry_coordinator/Cargo.toml b/crates/username_registry_coordinator/Cargo.toml index 24ce604..c5ddbf7 100644 --- a/crates/username_registry_coordinator/Cargo.toml +++ b/crates/username_registry_coordinator/Cargo.toml @@ -9,6 +9,7 @@ name = "username_registry_coordinator" [dependencies] hdk = { workspace = true } +bincode = { workspace = true } username_registry_integrity = { workspace = true } game_identity_types = { workspace = true } username_registry_validation = { workspace = true } diff --git a/crates/username_registry_coordinator/src/lib.rs b/crates/username_registry_coordinator/src/lib.rs index a385b9f..9dbc012 100644 --- a/crates/username_registry_coordinator/src/lib.rs +++ b/crates/username_registry_coordinator/src/lib.rs @@ -1,3 +1,4 @@ +pub mod user_metadata; pub mod username_attestation; pub mod wallet_attestation; use game_identity_types::get_authority_agent; diff --git a/crates/username_registry_coordinator/src/user_metadata.rs b/crates/username_registry_coordinator/src/user_metadata.rs new file mode 100644 index 0000000..294a5a3 --- /dev/null +++ b/crates/username_registry_coordinator/src/user_metadata.rs @@ -0,0 +1,71 @@ +use std::collections::HashMap; + +use game_identity_types::{GetMetadataItemValuePayload, MetadataItem, UpdateMetadataItemPayload}; +use hdk::prelude::*; +use username_registry_integrity::*; + +#[hdk_extern] +pub fn update_metadata_item(payload: UpdateMetadataItemPayload) -> ExternResult<()> { + let links = get_links(payload.agent_pubkey.clone(), LinkTypes::AgentMetadata, None)?; + for link in links { + let existing_item: MetadataItem = + bincode::deserialize(&link.tag.into_inner()).map_err(|_| { + wasm_error!(WasmErrorInner::Guest( + "Failed to deserialize MetadataItem".into() + )) + })?; + if existing_item.name == payload.name { + // Remove old MetadataItem + delete_link(link.create_link_hash)?; + } + } + let item = MetadataItem { + name: payload.name, + value: payload.value, + }; + let tag_bytes = bincode::serialize(&item).map_err(|_| { + wasm_error!(WasmErrorInner::Guest( + "Failed to serialize MetadataItem".into() + )) + })?; + create_link( + payload.agent_pubkey.clone(), + payload.agent_pubkey, // unused and irrelevant + LinkTypes::AgentMetadata, + LinkTag(tag_bytes), + )?; + Ok(()) +} + +#[hdk_extern] +pub fn get_metadata_item_value( + payload: GetMetadataItemValuePayload, +) -> ExternResult> { + let links = get_links(payload.agent_pubkey, LinkTypes::AgentMetadata, None)?; + for link in links { + let item: MetadataItem = bincode::deserialize(&link.tag.into_inner()).map_err(|_| { + wasm_error!(WasmErrorInner::Guest( + "Failed to deserialize MetadataItem".into() + )) + })?; + if payload.name == item.name { + return Ok(Some(item.value)); + } + } + Ok(None) +} + +#[hdk_extern] +pub fn get_metadata(agent_pubkey: AgentPubKey) -> ExternResult> { + let links = get_links(agent_pubkey, LinkTypes::AgentMetadata, None)?; + let mut out = HashMap::default(); + for link in links { + let item: MetadataItem = bincode::deserialize(&link.tag.into_inner()).map_err(|_| { + wasm_error!(WasmErrorInner::Guest( + "Failed to deserialize MetadataItem".into() + )) + })?; + out.insert(item.name, item.value); + } + Ok(out) +} diff --git a/crates/username_registry_integrity/src/lib.rs b/crates/username_registry_integrity/src/lib.rs index c3ac216..a83f32e 100644 --- a/crates/username_registry_integrity/src/lib.rs +++ b/crates/username_registry_integrity/src/lib.rs @@ -14,6 +14,7 @@ pub enum EntryTypes { #[hdk_link_types] pub enum LinkTypes { AgentToUsernameAttestations, + AgentMetadata, AgentToWalletAttestations, } #[hdk_extern] @@ -127,6 +128,9 @@ pub fn validate(op: Op) -> ExternResult { tag, ) } + LinkTypes::AgentMetadata => { + validate_create_link_user_metadata(action, base_address, target_address, tag) + } LinkTypes::AgentToWalletAttestations => { validate_create_link_agent_to_wallet_attestations( action, @@ -153,6 +157,13 @@ pub fn validate(op: Op) -> ExternResult { tag, ) } + LinkTypes::AgentMetadata => validate_delete_link_user_metadata( + action, + original_action, + base_address, + target_address, + tag, + ), LinkTypes::AgentToWalletAttestations => { validate_delete_link_agent_to_wallet_attestations( action, @@ -352,6 +363,12 @@ pub fn validate(op: Op) -> ExternResult { tag, ) } + LinkTypes::AgentMetadata => validate_create_link_user_metadata( + action, + base_address, + target_address, + tag, + ), LinkTypes::AgentToWalletAttestations => { validate_create_link_agent_to_wallet_attestations( action, @@ -397,6 +414,13 @@ pub fn validate(op: Op) -> ExternResult { create_link.tag, ) } + LinkTypes::AgentMetadata => validate_delete_link_user_metadata( + action, + create_link.clone(), + base_address, + create_link.target_address, + create_link.tag, + ), LinkTypes::AgentToWalletAttestations => { validate_delete_link_agent_to_wallet_attestations( action, diff --git a/crates/username_registry_validation/Cargo.toml b/crates/username_registry_validation/Cargo.toml index 1ac680c..c80db45 100644 --- a/crates/username_registry_validation/Cargo.toml +++ b/crates/username_registry_validation/Cargo.toml @@ -11,6 +11,7 @@ name = "username_registry_validation" hdi = { workspace = true } holo_hash = { workspace = true } serde = { workspace = true } +bincode = { workspace = true } game_identity_types = { workspace = true } ed25519-dalek = { workspace = true } bs58 = { workspace = true } diff --git a/crates/username_registry_validation/src/lib.rs b/crates/username_registry_validation/src/lib.rs index eca62a8..9321ba1 100644 --- a/crates/username_registry_validation/src/lib.rs +++ b/crates/username_registry_validation/src/lib.rs @@ -2,6 +2,8 @@ pub mod username_attestation; pub use username_attestation::*; pub mod wallet_attestation; pub use wallet_attestation::*; +pub mod user_metadata; +pub use user_metadata::*; pub mod agent_username_attestation; pub use agent_username_attestation::*; pub mod agent_wallet_attestation; diff --git a/crates/username_registry_validation/src/user_metadata.rs b/crates/username_registry_validation/src/user_metadata.rs new file mode 100644 index 0000000..f94f795 --- /dev/null +++ b/crates/username_registry_validation/src/user_metadata.rs @@ -0,0 +1,44 @@ +use game_identity_types::MetadataItem; +use hdi::prelude::*; + +pub fn validate_create_link_user_metadata( + action: CreateLink, + base_address: AnyLinkableHash, + _target_address: AnyLinkableHash, + tag: LinkTag, +) -> ExternResult { + let agent_pubkey = AgentPubKey::try_from(base_address).map_err(|e| wasm_error!(e))?; + + if action.author != agent_pubkey { + return Ok(ValidateCallbackResult::Invalid( + "Only the owner can embed metadata in their link tags".into(), + )); + } + // The contents of the target_address is unused and irrelevant + + // Check the tag is valid + let _item: MetadataItem = bincode::deserialize(&tag.into_inner()).map_err(|_| { + wasm_error!(WasmErrorInner::Guest( + "Failed to deserialize MetadataItem".into() + )) + })?; + + Ok(ValidateCallbackResult::Valid) +} +pub fn validate_delete_link_user_metadata( + action: DeleteLink, + _original_action: CreateLink, + base_address: AnyLinkableHash, + _target_address: AnyLinkableHash, + _tag: LinkTag, +) -> ExternResult { + let agent_pubkey = AgentPubKey::try_from(base_address).map_err(|e| wasm_error!(e))?; + + if action.author != agent_pubkey { + return Ok(ValidateCallbackResult::Invalid( + "Only the owner can delete their metadata tags".into(), + )); + } + + Ok(ValidateCallbackResult::Valid) +} diff --git a/package.json b/package.json index f84af7d..062c273 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test:happ": "npm run build:happ && cargo nextest run -j 1", "build:client": "npm run build -w @holochain-game-identity/client", "build:docker": "npm run build:zomes && scripts/build_docker_images.sh", - "test:e2e": "npm run e2e -w @holochain-game-identity/e2e" + "test:e2e": "npm run build:zomes && npm run build:docker && npm run build:client && npm run e2e -w @holochain-game-identity/e2e" }, "devDependencies": { "typescript": "^5.4.2" diff --git a/packages/client/src/holochain-game-identity-client.ts b/packages/client/src/holochain-game-identity-client.ts index 7be0be8..7f1ce31 100644 --- a/packages/client/src/holochain-game-identity-client.ts +++ b/packages/client/src/holochain-game-identity-client.ts @@ -65,6 +65,26 @@ export class HolochainGameIdentityClient { }); } + async setMetadata(name: string, value: string) { + await this.appAgent.callZome({ + role_name: "game_identity", + zome_name: "username_registry", + fn_name: "update_metadata_item", + payload: { agent_pubkey: this.appAgent.myPubKey, name, value }, + }); + } + + async getMetadata(name: string): Promise { + const value = await this.appAgent.callZome({ + role_name: "game_identity", + zome_name: "username_registry", + fn_name: "get_metadata_item_value", + payload: { agent_pubkey: this.appAgent.myPubKey, name }, + }); + if (!value) return null; + return value; + } + async getEvmWalletBindingMessage(evmAddress: Hex) { const message: string = await this.appAgent.callZome({ role_name: "game_identity", diff --git a/packages/e2e/tests/index.test.js b/packages/e2e/tests/index.test.js deleted file mode 100644 index 099dbf6..0000000 --- a/packages/e2e/tests/index.test.js +++ /dev/null @@ -1,146 +0,0 @@ -const { GenericContainer, Network } = require("testcontainers"); - -const BOOTSTRAP_PORT = 51804; -const SIGNAL_PORT = 51805; - -function startLocalServicesContainer(network) { - return new GenericContainer("game-identity/local-services") - .withNetwork(network) - .withEnvironment({ - BOOTSTRAP_PORT, - SIGNAL_PORT, - }) - .withCommand("/run.sh") - .start(); -} - -function startAuthorityContainer(network, localServicesIp) { - return new GenericContainer("game-identity/authority-agent-sandbox") - .withNetwork(network) - .withEnvironment({ - BOOTSTRAP_SERVER_OVERRIDE: `http://${localServicesIp}:${BOOTSTRAP_PORT}`, - SIGNAL_SERVER_OVERRIDE: `ws://${localServicesIp}:${SIGNAL_PORT}`, - }) - .withCommand("/run.sh") - .start(); -} - -function startHoloContainer(network, localServicesIp) { - return new GenericContainer("game-identity/holo-dev-server") - .withExposedPorts( - { host: 24274, container: 24274 }, - { host: 9999, container: 9999 } - ) - .withNetwork(network) - .withEnvironment({ - BOOTSTRAP_SERVER: `http://${localServicesIp}:${BOOTSTRAP_PORT}`, - SIGNAL_SERVER: `ws://${localServicesIp}:${SIGNAL_PORT}`, - }) - .withCommand("/run.sh") - .start(); -} - -async function loadPageAndRegister(email, password) { - await page.goto("http://localhost:5173"); - let frame; - while (true) { - const frames = await page.frames(); - frame = frames[1]; - try { - await frame.waitForSelector("#email", { timeout: 1000 }); - break; - } catch { - // Loaded iframe before "holoport" was ready - await page.reload(); - } - } - await frame.type("#email", email); - await frame.type("#password", password); - await frame.type("#confirm-password", password); - await frame.click("#submit-button"); - - // Wait until form processes and client ready - await page.evaluate(async () => { - await window.gameIdentityClientProm; - }); -} - -describe("HolochainGameIdentityClient", () => { - let network; - let localServicesContainer; - let authorityContainer; - let holoContainer; - beforeEach(async () => { - debug("Setup started"); - network = await new Network().start(); - debug("Network created"); - localServicesContainer = await startLocalServicesContainer(network); - const localServiceIp = localServicesContainer.getIpAddress( - network.getName() - ); - debug("Started local-services"); - // The next two containers only depend on local-services, and can be - // loaded in parallel. - const authorityContainerProm = startAuthorityContainer( - network, - localServiceIp - ); - const holoContainerProm = startHoloContainer(network, localServiceIp); - authorityContainer = await authorityContainerProm; - holoContainer = await holoContainerProm; - debug("Started authority-agent-sandbox and holo-dev-server"); - }, 60_000); - - afterEach(async () => { - debug("Teardown started"); - await Promise.all([ - Promise.all([ - localServicesContainer.stop(), - authorityContainer.stop(), - holoContainer.stop(), - ]).then(() => network.stop()), - jestPuppeteer.resetPage(), - ]); - debug("Teardown finished"); - }); - - it("should register only one username", async () => { - debug("Started test"); - await loadPageAndRegister("test@test.com", "test1234"); - debug("Loaded chaperone and registered agent"); - - // Starts with no username - await expect( - page.evaluate(() => window.gameIdentityClient.getUsername()) - ).resolves.toBeNull(); - debug("Checked username initially null"); - - // First register succeeds - await expect( - page.evaluate(() => - window.gameIdentityClient.registerUsername("test1234") - ) - ).resolves.toBeUndefined(); - debug("Registered username"); - - // Poll username until define (gossiping) - while (true) { - const result = await page.evaluate(() => - window.gameIdentityClient.getUsername() - ); - if (result) { - expect(result).toBe("test1234"); - break; - } - } - debug("Polled username until correctly gossiped"); - - // Second registration fails - await expect( - page.evaluate(() => - window.gameIdentityClient.registerUsername("test1234") - ) - ).rejects.toSatisfy((error) => error.message.includes("InvalidCommit")); - debug("Checked second registration fails"); - }, 120_000); -}); diff --git a/packages/e2e/tests/metadata.test.js b/packages/e2e/tests/metadata.test.js new file mode 100644 index 0000000..e934439 --- /dev/null +++ b/packages/e2e/tests/metadata.test.js @@ -0,0 +1,70 @@ +const { startTestContainers } = require("./utils/testcontainers"); +const { loadPageAndRegister } = require("./utils/holo"); + +describe("metadata", () => { + let testContainers; + beforeEach(async () => { + testContainers = await startTestContainers(); + }, 60_000); + afterEach(async () => { + await Promise.all([testContainers.stop(), jestPuppeteer.resetPage()]); + }); + + it("Should manage metadata like key-value store", async () => { + debug("Started test"); + await loadPageAndRegister("test@test.com", "test1234"); + debug("Loaded chaperone and registered agent"); + + await expect( + page.evaluate(() => + window.gameIdentityClient.getMetadata("profile-picture") + ) + ).resolves.toBeNull(); + debug("Checked profile-picture metadata initially null"); + + await expect( + page.evaluate(() => + window.gameIdentityClient.setMetadata("profile-picture", "image1.jpg") + ) + ).resolves.toBeUndefined(); + debug("Set profile-picture metadata"); + + await expect( + page.evaluate(() => + window.gameIdentityClient.getMetadata("profile-picture") + ) + ).resolves.toBe("image1.jpg"); + debug("Checked profile-picture metadata set"); + + await expect( + page.evaluate(() => + window.gameIdentityClient.setMetadata("location", "moon") + ) + ).resolves.toBeUndefined(); + debug("Set location metadata"); + + await expect( + page.evaluate(() => window.gameIdentityClient.getMetadata("location")) + ).resolves.toBe("moon"); + debug("Checked location metadata set"); + + await expect( + page.evaluate(() => + window.gameIdentityClient.setMetadata("profile-picture", "image2.jpg") + ) + ).resolves.toBeUndefined(); + debug("Replace profile-picture metadata"); + + await expect( + page.evaluate(() => + window.gameIdentityClient.getMetadata("profile-picture") + ) + ).resolves.toBe("image2.jpg"); + debug("Checked profile-picture metadata replaced"); + + await expect( + page.evaluate(() => window.gameIdentityClient.getMetadata("location")) + ).resolves.toBe("moon"); + debug("Checked location metadata unchanged"); + }, 120_000); +}); diff --git a/packages/e2e/tests/username.test.js b/packages/e2e/tests/username.test.js new file mode 100644 index 0000000..86e616b --- /dev/null +++ b/packages/e2e/tests/username.test.js @@ -0,0 +1,52 @@ +const { startTestContainers } = require("./utils/testcontainers"); +const { loadPageAndRegister } = require("./utils/holo"); + +describe("username", () => { + let testContainers; + beforeEach(async () => { + testContainers = await startTestContainers(); + }, 60_000); + afterEach(async () => { + await Promise.all([testContainers.stop(), jestPuppeteer.resetPage()]); + }); + + it("should register only one username", async () => { + debug("Started test"); + await loadPageAndRegister("test@test.com", "test1234"); + debug("Loaded chaperone and registered agent"); + + // Starts with no username + await expect( + page.evaluate(() => window.gameIdentityClient.getUsername()) + ).resolves.toBeNull(); + debug("Checked username initially null"); + + // First register succeeds + await expect( + page.evaluate(() => + window.gameIdentityClient.registerUsername("test1234") + ) + ).resolves.toBeUndefined(); + debug("Registered username"); + + // Poll username until define (gossiping) + while (true) { + const result = await page.evaluate(() => + window.gameIdentityClient.getUsername() + ); + if (result) { + expect(result).toBe("test1234"); + break; + } + } + debug("Polled username until correctly gossiped"); + + // Second registration fails + await expect( + page.evaluate(() => + window.gameIdentityClient.registerUsername("test1234") + ) + ).rejects.toSatisfy((error) => error.message.includes("InvalidCommit")); + debug("Checked second registration fails"); + }, 120_000); +}); diff --git a/packages/e2e/tests/utils/holo.js b/packages/e2e/tests/utils/holo.js new file mode 100644 index 0000000..ff69f54 --- /dev/null +++ b/packages/e2e/tests/utils/holo.js @@ -0,0 +1,24 @@ +module.exports.loadPageAndRegister = async (email, password) => { + await page.goto("http://localhost:5173"); + let frame; + while (true) { + const frames = await page.frames(); + frame = frames[1]; + try { + await frame.waitForSelector("#email", { timeout: 1000 }); + break; + } catch { + // Loaded iframe before "holoport" was ready + await page.reload(); + } + } + await frame.type("#email", email); + await frame.type("#password", password); + await frame.type("#confirm-password", password); + await frame.click("#submit-button"); + + // Wait until form processes and client ready + await page.evaluate(async () => { + await window.gameIdentityClientProm; + }); +}; diff --git a/packages/e2e/tests/utils/testcontainers.js b/packages/e2e/tests/utils/testcontainers.js new file mode 100644 index 0000000..5ca9339 --- /dev/null +++ b/packages/e2e/tests/utils/testcontainers.js @@ -0,0 +1,74 @@ +const { GenericContainer, Network } = require("testcontainers"); + +const BOOTSTRAP_PORT = 51804; +const SIGNAL_PORT = 51805; + +function startLocalServicesContainer(network) { + return new GenericContainer("game-identity/local-services") + .withNetwork(network) + .withEnvironment({ + BOOTSTRAP_PORT, + SIGNAL_PORT, + }) + .withCommand("/run.sh") + .start(); +} + +function startAuthorityContainer(network, localServicesIp) { + return new GenericContainer("game-identity/authority-agent-sandbox") + .withNetwork(network) + .withEnvironment({ + BOOTSTRAP_SERVER_OVERRIDE: `http://${localServicesIp}:${BOOTSTRAP_PORT}`, + SIGNAL_SERVER_OVERRIDE: `ws://${localServicesIp}:${SIGNAL_PORT}`, + }) + .withCommand("/run.sh") + .start(); +} + +function startHoloContainer(network, localServicesIp) { + return new GenericContainer("game-identity/holo-dev-server") + .withExposedPorts( + { host: 24274, container: 24274 }, + { host: 9999, container: 9999 } + ) + .withNetwork(network) + .withEnvironment({ + BOOTSTRAP_SERVER: `http://${localServicesIp}:${BOOTSTRAP_PORT}`, + SIGNAL_SERVER: `ws://${localServicesIp}:${SIGNAL_PORT}`, + }) + .withCommand("/run.sh") + .start(); +} + +module.exports.startTestContainers = async () => { + debug("Begin test container setup"); + network = await new Network().start(); + debug("Network created"); + localServicesContainer = await startLocalServicesContainer(network); + const localServiceIp = localServicesContainer.getIpAddress(network.getName()); + debug("Started local-services"); + // The next two containers only depend on local-services, and can be + // loaded in parallel. + const authorityContainerProm = startAuthorityContainer( + network, + localServiceIp + ); + const holoContainerProm = startHoloContainer(network, localServiceIp); + authorityContainer = await authorityContainerProm; + holoContainer = await holoContainerProm; + debug("Started authority-agent-sandbox and holo-dev-server"); + debug("Finished test container setup"); + + const stop = async () => { + debug("Begin test container teardown"); + await Promise.all([ + localServicesContainer.stop(), + authorityContainer.stop(), + holoContainer.stop(), + ]); + await network.stop(); + debug("Finished test container teardown"); + }; + + return { stop }; +};