diff --git a/examples/emissions/co2-sensor/Dockerfile b/examples/emissions/Dockerfile similarity index 78% rename from examples/emissions/co2-sensor/Dockerfile rename to examples/emissions/Dockerfile index a0a86d9..e13aef5 100644 --- a/examples/emissions/co2-sensor/Dockerfile +++ b/examples/emissions/Dockerfile @@ -1,3 +1,5 @@ +# An image with holochain, lair-keystore, hc and node tsx + # We use ubuntu as it's glibc version is compatible with the prebuilt binaries FROM ubuntu @@ -24,13 +26,9 @@ RUN chmod 755 /usr/local/bin/hc /usr/local/bin/holochain /usr/local/bin/lair-key WORKDIR /home/node RUN /bin/bash -c "source $NVM_DIR/nvm.sh && npm i tsx" -# Copy the actual server script -COPY ./co2-sensor.ts ./co2-sensor.ts +COPY ./start.sh ./start.sh +RUN chmod +x ./start.sh # So container runs with nvm loaded SHELL ["/bin/bash", "--login", "-c"] - -# The ./packages directory is mounted from the locally built npm workspace. We -# npm install at launch to avoid the need for the package.json to know the -# latest version or features of the workspace. -CMD npm install ./packages/types ./packages/sandbox; npx tsx ./co2-sensor.ts +CMD ["./start.sh"] \ No newline at end of file diff --git a/examples/emissions/co2-sensor/co2-sensor.ts b/examples/emissions/agents/co2-sensor.ts similarity index 90% rename from examples/emissions/co2-sensor/co2-sensor.ts rename to examples/emissions/agents/co2-sensor.ts index 3830332..faa2c9f 100644 --- a/examples/emissions/co2-sensor/co2-sensor.ts +++ b/examples/emissions/agents/co2-sensor.ts @@ -18,6 +18,14 @@ async function main() { { bootstrapServerUrl: new URL("https://bootstrap-0.infra.holochain.org"), signalingServerUrl: new URL("wss://sbd-0.main.infra.holo.host"), + iceServers: [ + "stun:stun-0.main.infra.holo.host:443", + "stun:stun-1.main.infra.holo.host:443", + ], + ephemeralPorts: { + min: "40000", + max: "40255", + }, password: "password", } ); @@ -46,7 +54,7 @@ async function ensureListedAsPublisher( function arrEqual(arr1: Uint8Array, arr2: Uint8Array): boolean { if (arr1.length !== arr2.length) return false; for (let i = 0; i < arr1.length; i++) { - if (arr1![i] !== arr2[i]) return false; + if (arr1[i] !== arr2[i]) return false; } return true; } diff --git a/examples/emissions/agents/token-mint-signer.ts b/examples/emissions/agents/token-mint-signer.ts new file mode 100644 index 0000000..cd28fe0 --- /dev/null +++ b/examples/emissions/agents/token-mint-signer.ts @@ -0,0 +1,219 @@ +/** + * A mock CO₂ sensor, that emits a random measure in grams roughly every 2 + * seconds. The "measurements" are published by a sandboxed holochain agent + * that serves no other role in the network. + */ + +import { ensureAndConnectToHapp } from "@holoom/sandbox"; +import { + UsernameRegistryCoordinator, + RecordsCoordinator, + Recipe, + SignedEvmSigningOffer, + EvmU256Item, +} from "@holoom/types"; +import { encodeHashToBase64, AppClient, AgentPubKey } from "@holochain/client"; +import { + BytesSigner, + EvmBytesSignerClient, + OfferCreator, +} from "@holoom/authority"; +import { decodeAppEntry } from "@holoom/client"; + +async function main() { + // Create a conductor sandbox (with holoom installed) at the specified + // directory if it doesn't already exist, and connect to it. + const { appWs } = await ensureAndConnectToHapp( + "/sandbox", + "/workdir/holoom.happ", + "emissions-local-test-2024-09-04T12:59", + { + bootstrapServerUrl: new URL("https://bootstrap-0.infra.holochain.org"), + signalingServerUrl: new URL("wss://sbd-0.main.infra.holo.host"), + iceServers: [ + "stun:stun-0.main.infra.holo.host:443", + "stun:stun-1.main.infra.holo.host:443", + ], + ephemeralPorts: { + min: "40300", + max: "40555", + }, + password: "password", + } + ); + const app = new TokenMintSigner(appWs); + await app.run(); +} + +// Auto creates a recipe + offer as defined below and listens for signing requests +class TokenMintSigner { + bytesSigner: BytesSigner; + offerCreator: OfferCreator; + usernameRegistryCoordinator: UsernameRegistryCoordinator; + recordsCoordinator: RecordsCoordinator; + evmBytesSignerClient: EvmBytesSignerClient; + constructor(appClient: AppClient) { + // First private key of seed phrase: + // test test test test test test test test test test test junk + const EVM_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + this.bytesSigner = new BytesSigner(EVM_PRIVATE_KEY); + this.offerCreator = new OfferCreator(appClient, this.bytesSigner); + this.usernameRegistryCoordinator = new UsernameRegistryCoordinator( + appClient + ); + this.recordsCoordinator = new RecordsCoordinator(appClient); + this.evmBytesSignerClient = new EvmBytesSignerClient( + appClient, + this.bytesSigner + ); + } + + async run() { + await this.autoPublishSigningOfferAndRecipe(); + // Start listening for requests + await this.evmBytesSignerClient.setup(); + } + + // In a more realistic setup this step would be action manually by a human who + // has convinced themselves of which agent(s) they want to use as a data + // source. This example automates this step to reduce test tedium. + async autoPublishSigningOfferAndRecipe() { + console.log("Waiting for co2-sensor author to appear"); + const co2SensorAuthor = await this.untilTrustedAuthorSelected(); + console.log( + `Trusting ${encodeHashToBase64(co2SensorAuthor)} as co2-sensor author` + ); + + await this.ensureRecipe(...recipeForMint(co2SensorAuthor)); + } + + async untilTrustedAuthorSelected() { + while (true) { + const publishers = + await this.usernameRegistryCoordinator.getAllPublishers(); + const pair = publishers.find(([_, tag]) => tag === "co2-sensor"); + if (pair) return pair[0]; + await new Promise((r) => setTimeout(r, 1000)); + } + } + + async ensureRecipe(expectedRecipe: Recipe, expectU256Items: EvmU256Item[]) { + const offerAhs = + await this.usernameRegistryCoordinator.getSigningOfferAhsForEvmAddress( + this.bytesSigner.address + ); + + for (const offerAh of offerAhs) { + const offerRecord = await this.recordsCoordinator.getRecord(offerAh); + if (!offerRecord) { + console.warn( + `Signing offer record ${encodeHashToBase64(offerAh)} not found` + ); + continue; + } + const signedSigningOffer = + decodeAppEntry(offerRecord); + const recipeRecord = await this.recordsCoordinator.getRecord( + signedSigningOffer.offer.recipe_ah + ); + if (!recipeRecord) { + console.warn(`Recipe record ${encodeHashToBase64(offerAh)} not found`); + continue; + } + const recipe = decodeAppEntry(recipeRecord); + if ( + deepEqual(recipe, expectedRecipe) && + deepEqual(signedSigningOffer.offer.u256_items, expectU256Items) + ) { + console.log( + `Found existing matching signing offer ${encodeHashToBase64( + offerAh + )} and recipe ${encodeHashToBase64( + recipeRecord.signed_action.hashed.hash + )}` + ); + } + } + const createdRecipeRecord = + await this.usernameRegistryCoordinator.createRecipe(expectedRecipe); + const createdSigningOfferRecord = await this.offerCreator.createOffer( + "mint-credit", + createdRecipeRecord.signed_action.hashed.hash, + expectU256Items + ); + console.log( + `Create recipe ${encodeHashToBase64( + createdRecipeRecord.signed_action.hashed.hash + )} with offer ${createdSigningOfferRecord.signed_action.hashed.hash}` + ); + } +} + +const JQ_RANGE_TO_NAMES = ` +[range(.from | tonumber; .until | tonumber)] | +map("co2-sensor/time/\\(.)") +`; + +const JQ_ADD_READINGS = ` +map(.gramsCo2) | add | [.] +`; + +function recipeForMint( + trustedCo2SensorAuthor: AgentPubKey +): [Recipe, EvmU256Item[]] { + const recipe: Recipe = { + trusted_authors: [trustedCo2SensorAuthor], + arguments: [ + ["from", { type: "String" }], + ["until", { type: "String" }], + ], + instructions: [ + [ + "reading_names", + { + type: "Jq", + input_var_names: { type: "List", var_names: ["from", "until"] }, + program: JQ_RANGE_TO_NAMES, + }, + ], + ["readings", { type: "GetDocsListedByVar", var_name: "reading_names" }], + [ + "$return", + { + type: "Jq", + input_var_names: { type: "Single", var_name: "readings" }, + program: JQ_ADD_READINGS, + }, + ], + ], + }; + const items: EvmU256Item[] = [{ type: "Uint" }]; + return [recipe, items]; +} + +function deepEqual(x: unknown, y: unknown) { + if (x === y) { + return true; + } + // Not shallowly equal, therefore only possible to be deeply equal if both + // are instances. + if (typeof x !== "object" || !x || typeof y !== "object" || !y) { + return false; + } + if (Object.keys(x).length != Object.keys(y).length) { + return false; + } + for (const prop in x) { + if (!y.hasOwnProperty(prop)) { + return false; + } + if (!deepEqual(x[prop as keyof typeof x], y[prop as keyof typeof x])) { + return false; + } + } + + return true; +} + +main(); diff --git a/examples/emissions/docker-compose.yaml b/examples/emissions/docker-compose.yaml index 66b718e..165c8cd 100644 --- a/examples/emissions/docker-compose.yaml +++ b/examples/emissions/docker-compose.yaml @@ -1,7 +1,19 @@ services: co2-sensor: - build: ./co2-sensor + build: . volumes: + - ./agents/co2-sensor.ts:/home/node/agent.ts - ../../packages:/home/node/packages - - ../../workdir:/workdir \ No newline at end of file + - ../../workdir:/workdir + ports: + - 40000-40255:40000-40255 + + token-mint-signer: + build: . + volumes: + - ./agents/token-mint-signer.ts:/home/node/agent.ts + - ../../packages:/home/node/packages + - ../../workdir:/workdir + ports: + - 40300-40555:40300-40555 \ No newline at end of file diff --git a/examples/emissions/start.sh b/examples/emissions/start.sh new file mode 100644 index 0000000..ed2bfe6 --- /dev/null +++ b/examples/emissions/start.sh @@ -0,0 +1,20 @@ +#! /bin/bash +set -e +npm install ./packages/types ./packages/authority ./packages/client ./packages/sandbox + +# Link installed modules to volume for latest changes +cd ./packages/types +npm link +cd ../authority +npm link +cd ../client +npm link +cd ../sandbox +npm link +cd ../.. +npm link @holoom/types +npm link @holoom/authority +npm link @holoom/client +npm link @holoom/sandbox + +npx tsx watch ./agent.ts diff --git a/packages/authority/src/evm-bytes-signer/offer-creator.ts b/packages/authority/src/evm-bytes-signer/offer-creator.ts index ca6df50..a717025 100644 --- a/packages/authority/src/evm-bytes-signer/offer-creator.ts +++ b/packages/authority/src/evm-bytes-signer/offer-creator.ts @@ -1,6 +1,5 @@ import { ActionHash, AppClient, Record } from "@holochain/client"; import { - CreateEvmSigningOfferPayload, EvmSigningOffer, EvmU256Item, RecordsCoordinator, @@ -23,7 +22,7 @@ export class OfferCreator { async createOffer( identifier: string, - recipeAh: number[], + recipeAh: ActionHash, items: EvmU256Item[] ) { const offer: EvmSigningOffer = { @@ -45,8 +44,7 @@ export class OfferCreator { }, }); console.log("Created record", record); - const actionHash = Array.from(record.signed_action.hashed.hash); - return actionHash; + return record; } private async untilRecipeGossiped(recipeAh: ActionHash) { diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index d4e5125..05c70a6 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -5,6 +5,8 @@ import yaml from "yaml"; export interface SandboxOptions { bootstrapServerUrl: URL; signalingServerUrl: URL; + iceServers: string[]; + ephemeralPorts?: { min: string; max: string }; password: string; } @@ -86,12 +88,25 @@ export async function createSandbox(path: string, options: SandboxOptions) { }); await createConductorPromise; - // Disable dpki + // Tweak conductor config const conductorConfigPath = `${path}/conductor-config.yaml`; const conductorConfig = yaml.parse( await fs.readFile(conductorConfigPath, "utf8") ); + // Disable dpki conductorConfig.dpki.no_dpki = true; + // Set WebRTC config + conductorConfig.network.transport_pool[0].webrtc_config = options.iceServers + .length + ? { iceServers: options.iceServers.map((url) => ({ url })) } + : null; + // Set ephemeral port range + if (options.ephemeralPorts) { + conductorConfig.network.tuning_params.tx5_min_ephemeral_udp_port = + options.ephemeralPorts.min; + conductorConfig.network.tuning_params.tx5_max_ephemeral_udp_port = + options.ephemeralPorts.max; + } await fs.writeFile(conductorConfigPath, yaml.stringify(conductorConfig)); } diff --git a/packages/tryorama/src/e2e/signing-offer.test.ts b/packages/tryorama/src/e2e/signing-offer.test.ts index 79f7b0b..90c1932 100644 --- a/packages/tryorama/src/e2e/signing-offer.test.ts +++ b/packages/tryorama/src/e2e/signing-offer.test.ts @@ -68,7 +68,7 @@ test("e2e signing offer", async () => { // This would normally be behind an admin authorised POST endpoint await evmBytesSignerService.offerCreator.createOffer( "123", - Array.from(recipeRecord.signed_action.hashed.hash), + recipeRecord.signed_action.hashed.hash, [ { type: "Uint" }, { type: "Hex" }, @@ -95,7 +95,8 @@ test("e2e signing offer", async () => { }); const evmSignedResult = await new HoloomClient( - alice.appWs as AppClient + alice.appWs as AppClient, + authority.agentPubKey ).requestEvmSignatureOverRecipeExecutionResult( recipeExecutionRecord.signed_action.hashed.hash, signingOfferAh