From b7bedc5476063f0fddd23bcbe734220cf2015163 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 7 Sep 2026 10:56:08 +0000 Subject: [PATCH] The tests work --- package-lock.json | 95 +- package.json | 9 +- src/cli/autocomplete/offline-engine.ts | 3 +- src/cli/commands/invitation.ts | 34 +- src/cli/commands/resource.ts | 10 +- src/cli/commands/template.ts | 8 +- src/services/app.ts | 3 +- src/services/history.ts | 3 +- src/services/invitation.ts | 2 +- .../in-memory-blockchain-provider.ts | 264 ++ src/utils/sync-server.ts | 2 +- tests/cli/commands/invitation.test.ts | 57 +- tests/cli/commands/receive.test.ts | 4 +- tests/cli/commands/resource.test.ts | 45 +- tests/cli/commands/template.test.ts | 7 +- tests/cli/mocks/engine.ts | 21 +- tests/cli/mocks/template-p2pkh.ts | 2839 ++++++++--------- tests/utils/load-template-from-file.test.ts | 2 +- tests/utils/sync-server.test.ts | 6 +- 19 files changed, 1855 insertions(+), 1559 deletions(-) create mode 100644 src/utils/blockchain/in-memory-blockchain-provider.ts diff --git a/package-lock.json b/package-lock.json index d34ae9f..3cbc0a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,11 @@ "@electrum-cash/protocol": "^2.3.1", "@generalprotocols/oracle-client": "^0.0.1-development.11945476152", "@xo-cash/crypto": "^0.0.2", - "@xo-cash/engine": "file:../engine", + "@xo-cash/engine": "^0.0.2-cli.20260907103534754", "@xo-cash/state": "^0.0.3", "@xo-cash/templates": "^0.0.3", "@xo-cash/types": "^0.0.5", - "@xo-cash/utils": "^0.0.5-test.20260831095544025", + "@xo-cash/utils": "^0.0.6-cli.20260907041319224", "better-sqlite3": "^12.6.2", "clipboardy": "^5.1.0", "ink": "^6.6.0", @@ -43,38 +43,6 @@ "vitest": "^4.1.2" } }, - "../engine": { - "name": "@xo-cash/engine", - "version": "0.0.2", - "license": "MIT", - "dependencies": { - "@bitauth/libauth": "^3.1.0-next.8", - "@electrum-cash/application": "^0.2.3-development.13447192992", - "@electrum-cash/network": "^4.2.2", - "@electrum-cash/protocol": "^2.3.1", - "@electrum-cash/servers": "^3.1.0", - "@xo-cash/crypto": "0.0.3", - "@xo-cash/primitives": "0.0.2", - "@xo-cash/state": "0.0.3", - "@xo-cash/templates": "0.0.3", - "@xo-cash/types": "0.0.5", - "@xo-cash/utils": "0.0.4", - "eventemitter3": "^5.0.1" - }, - "devDependencies": { - "@generalprotocols/cspell-dictionary": "^1.0.1", - "@vitest/coverage-v8": "^4.0.17", - "@viz-kit/esbuild-analyzer": "^1.0.0", - "@xo-cash/eslint-config": "1.0.1", - "cspell": "^9.6.0", - "prettier": "^3.6.2", - "tsdown": "^0.20.0-beta.4", - "typedoc": "^0.28.16", - "typedoc-plugin-coverage": "^4.0.2", - "typescript": "^5.3.2", - "vitest": "^4.0.17" - } - }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz", @@ -1198,8 +1166,57 @@ } }, "node_modules/@xo-cash/engine": { - "resolved": "../engine", - "link": true + "version": "0.0.2-cli.20260907103534754", + "resolved": "https://verdaccio.harvmaster.com/@xo-cash/engine/-/engine-0.0.2-cli.20260907103534754.tgz", + "integrity": "sha512-HcR5uECJ1SRLZaCfzCsSGTUMmeU4v7dMVgiLhxdbBLUFEUYKEax5bX1BuKrfaqp7+FlN772skK/rAt6TzuKFfQ==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@electrum-cash/application": "^0.2.3-development.13447192992", + "@electrum-cash/network": "^4.2.2", + "@electrum-cash/protocol": "^2.3.1", + "@electrum-cash/servers": "^3.1.0", + "@xo-cash/crypto": "0.0.3", + "@xo-cash/primitives": "0.0.2", + "@xo-cash/state": "0.0.3", + "@xo-cash/templates": "0.0.3", + "@xo-cash/types": "0.0.5", + "@xo-cash/utils": "^0.0.6-cli.20260907035204932", + "eventemitter3": "^5.0.1" + } + }, + "node_modules/@xo-cash/engine/node_modules/@xo-cash/crypto": { + "version": "0.0.3", + "resolved": "https://verdaccio.harvmaster.com/@xo-cash/crypto/-/crypto-0.0.3.tgz", + "integrity": "sha512-f4OXZQ5psIaDOgfAVRCtl8IeMXbdXvtLOft42ux+SUGRnrvZ40Yf//XaLshrzzrG0s/3Co1ijO7fC/nQYvNcjA==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@xo-cash/primitives": "0.0.2", + "@xo-cash/types": "0.0.5", + "@xo-cash/utils": "0.0.4" + } + }, + "node_modules/@xo-cash/engine/node_modules/@xo-cash/utils": { + "version": "0.0.4", + "resolved": "https://verdaccio.harvmaster.com/@xo-cash/utils/-/utils-0.0.4.tgz", + "integrity": "sha512-QsVXnf9/z5+5ywMx5BPVmREuE8OCWIKwmQMM6osu2kVJQJplqZdYy+hGRnOwPGlrrvmWr61BU9u7dBIrDwDDnw==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@xo-cash/primitives": "0.0.2", + "@xo-cash/types": "0.0.4", + "zod": "^4.3.6" + } + }, + "node_modules/@xo-cash/engine/node_modules/@xo-cash/utils/node_modules/@xo-cash/types": { + "version": "0.0.4", + "resolved": "https://verdaccio.harvmaster.com/@xo-cash/types/-/types-0.0.4.tgz", + "integrity": "sha512-XBB/jNC1orDcMUi5Q0g+fnPXdKdb+n7OyqmJ8pxFrfM0y9OAdLjfddMih/JWfOWQtBkvY0CtdaZapgBSrvLeSw==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8" + } }, "node_modules/@xo-cash/primitives": { "version": "0.0.2", @@ -1283,9 +1300,9 @@ } }, "node_modules/@xo-cash/utils": { - "version": "0.0.5-test.20260831095544025", - "resolved": "https://verdaccio.harvmaster.com/@xo-cash/utils/-/utils-0.0.5-test.20260831095544025.tgz", - "integrity": "sha512-XWiG8tPPmXy/zrDYUjM1fILL7STaV0THE4xoj6PosuSgh1mUQNdr71Wanbhkou/Pc4HbOcT+KO+DbLz0dGMYRw==", + "version": "0.0.6-clio.20260907035202101", + "resolved": "https://verdaccio.harvmaster.com/@xo-cash/utils/-/utils-0.0.6-clio.20260907035202101.tgz", + "integrity": "sha512-DBou0RivCxg4PvyC6eWpFWXsxh2gTSD7BPeYIToqkdjweVzszvsWIUJ8tb27MxL56fW6CbGhDXyElciXEqbQmg==", "license": "MIT", "dependencies": { "@bitauth/libauth": "^3.1.0-next.8", diff --git a/package.json b/package.json index f588079..060cff6 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts", "syntax": "tsc --noEmit", "start": "SYNC_SERVER_URL=https://v2.sync.xo.harvmaster.com node dist/index.js", - "test": "vitest --run --passWithNoTests", + "test": "vitest --run --passWithNoTests --test-timeout=15000", "test:watch": "vitest", "test:coverage": "vitest run --coverage --passWithNoTests", "format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore", @@ -31,9 +31,6 @@ "cli", "tui" ], - "overrides": { - "@xo-cash/utils": "^0.0.5-test.20260831095544025" - }, "author": "General Protocols", "license": "ISC", "description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet", @@ -43,11 +40,11 @@ "@electrum-cash/protocol": "^2.3.1", "@generalprotocols/oracle-client": "^0.0.1-development.11945476152", "@xo-cash/crypto": "^0.0.2", - "@xo-cash/engine": "file:../engine", + "@xo-cash/engine": "^0.0.2-cli.20260907103534754", "@xo-cash/state": "^0.0.3", "@xo-cash/templates": "^0.0.3", "@xo-cash/types": "^0.0.5", - "@xo-cash/utils": "^0.0.5-test.20260831095544025", + "@xo-cash/utils": "^0.0.6-cli.20260907041319224", "better-sqlite3": "^12.6.2", "clipboardy": "^5.1.0", "ink": "^6.6.0", diff --git a/src/cli/autocomplete/offline-engine.ts b/src/cli/autocomplete/offline-engine.ts index 933bc12..ca7f040 100644 --- a/src/cli/autocomplete/offline-engine.ts +++ b/src/cli/autocomplete/offline-engine.ts @@ -13,6 +13,7 @@ import { createStorageAdapter, State, StorageType } from "@xo-cash/state"; import { convertMnemonicToSeedBytes } from "@xo-cash/crypto"; import { binToHex, hash256 } from "@bitauth/libauth"; import { createHash } from "crypto"; +import { InMemoryBlockchainProvider } from "../../utils/blockchain/in-memory-blockchain-provider.js"; /** * Options for creating an offline engine. @@ -64,7 +65,7 @@ export async function createOfflineEngine( const state = new State(storageAdapter); // Create a minimal blockchain monitor (no electrum initialization) - const blockchainMonitor = new BlockchainMonitor(state); + const blockchainMonitor = new BlockchainMonitor(state, new InMemoryBlockchainProvider()); // Engine constructor is private; bypass for offline read-only completions. type EngineConstructor = new ( diff --git a/src/cli/commands/invitation.ts b/src/cli/commands/invitation.ts index 56a5052..f28fa0f 100644 --- a/src/cli/commands/invitation.ts +++ b/src/cli/commands/invitation.ts @@ -382,10 +382,19 @@ export const handleInvitationCommand = async ( const template = await resolveTemplate(deps, templateQuery); const templateIdentifier = generateTemplateIdentifier(template); + // Read the variables that were passed in via `-var- ` + // TODO: Move this to markers: InvVarHere + const variables = parseVariablesFromOptions(options); + deps.io.verbose(`Variables: ${formatObject(variables)}`); + // if (variables.length > 0) { + // // await invitationInstance.addVariables(variables); + // } + // Create an XOInvitation. We will convert this into our own invitation instance afterwards const rawInvitation = await deps.app.engine.createInvitation({ templateIdentifier, actionIdentifier, + variables, }); deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`); @@ -395,13 +404,6 @@ export const handleInvitationCommand = async ( `Invitation instance created: ${formatObject(invitationInstance.data)}`, ); - // Read the variables that were passed in via `-var- ` - const variables = parseVariablesFromOptions(options); - deps.io.verbose(`Variables: ${formatObject(variables)}`); - if (variables.length > 0) { - await invitationInstance.addVariables(variables); - } - // Build the parameters for the append call. This will resolve the inputs and outputs for the invitation. const params = await buildAppendParams(deps, invitationInstance, options); if (!params) { @@ -411,6 +413,8 @@ export const handleInvitationCommand = async ( ); } + // TOOD MARKER: InvVarHere + // Append the inputs and outputs to the invitation const { inputs, outputs } = params; deps.io.verbose(`Inputs: ${formatObject(inputs)}`); @@ -419,10 +423,18 @@ export const handleInvitationCommand = async ( await invitationInstance.append({ inputs, outputs }); } if (inputs.length > 0) { - const feeAwareChange = await invitationInstance.addFeeAwareChange(); - deps.io.out( - `Miner fee: ${feeAwareChange.feeSatoshis} satoshis; change: ${feeAwareChange.changeAmountSatoshis} satoshis`, - ); + try { + const feeAwareChange = await invitationInstance.addFeeAwareChange(); + deps.io.out( + `Miner fee: ${feeAwareChange.feeSatoshis} satoshis; change: ${feeAwareChange.changeAmountSatoshis} satoshis`, + ); + } catch(error) { + deps.io.err(`Failed to add fee-aware change: ${error instanceof Error ? error.message : "unknown error"}`); + throw new CommandError( + "invitation.create.add_fee_aware_change_failed", + `Failed to add fee-aware change: ${error instanceof Error ? error.message : "unknown error"}`, + ); + } } // Write the invitation to a file in the working directory diff --git a/src/cli/commands/resource.ts b/src/cli/commands/resource.ts index d6dfdbe..c60a884 100644 --- a/src/cli/commands/resource.ts +++ b/src/cli/commands/resource.ts @@ -227,13 +227,13 @@ export const handleResourceCommand = async ( } // Unreserve the resources - await deps.app.engine.unreserveResources( - [{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }], - target.reservedBy, - ); + await deps.app.engine.archiveInvitation(target.reservedBy); + // TODO: This should ideally not archive the invitation, but instead just release the resource so the user can add a different resource to the same invitation. + // Maybe make this method only available on invitations that haven't been synced yet? That way the user has some time to undo it without scrapping the whole invitation, + // but is still safe from the other participants from spending the resource? deps.io.out( - `Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`, + `Archived invitation ${bold(target.reservedBy)} and released its resources`, ); // TODO: What do I want to return here? diff --git a/src/cli/commands/template.ts b/src/cli/commands/template.ts index e57b61c..ac5ffec 100644 --- a/src/cli/commands/template.ts +++ b/src/cli/commands/template.ts @@ -1,6 +1,6 @@ import { existsSync, writeFileSync } from "fs"; import path from "path"; -import { generateTemplateIdentifier } from "@xo-cash/engine"; +import { generateTemplateIdentifier, parseTemplate } from "@xo-cash/engine"; import type { XOTemplate } from "@xo-cash/types"; import { bold, dim, formatObject } from "../utils.js"; @@ -529,11 +529,11 @@ export const handleTemplateCommand = async ( ); // Set the default locking parameters - await deps.app.engine.setDefaultLockingParameters( - templateFile, + await deps.app.engine.updateFallbackLockingParameters({ + templateIdentifier: generateTemplateIdentifier(parseTemplate(templateFile)), outputIdentifier, roleIdentifier, - ); + }); // Return an empty object return {}; diff --git a/src/services/app.ts b/src/services/app.ts index 030f8f1..fe7358c 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -350,7 +350,8 @@ export class AppService extends EventEmitter { // console.error('Unreserving resources is not currently supported by the engine') for (const [invitationIdentifier, outputs] of byInvitation) { // Remove them directly from state - this.state.archiveInvitation(invitationIdentifier); + // TODO: Make this parallel. CBF doing it now, because it likely breaks things. + await this.state.archiveInvitation(invitationIdentifier); // await this.engine.unreserveResources( // outputs.map((o) => ({ diff --git a/src/services/history.ts b/src/services/history.ts index fbaba57..44c8912 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -1,5 +1,6 @@ import { binToHex, hexToBin, sha256 } from "@bitauth/libauth"; -import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; +import { type Engine } from "@xo-cash/engine"; +import { compileCashAssemblyString } from "@xo-cash/utils"; import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state"; import type { XOInvitation, diff --git a/src/services/invitation.ts b/src/services/invitation.ts index ff60f7c..269361d 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -34,7 +34,7 @@ import type { BlockchainService } from "./electrum.js"; import { EventEmitter } from "../utils/event-emitter.js"; import { decodeExtendedJsonObject } from "../utils/ext-json.js"; -import { compileCashAssemblyString } from "@xo-cash/engine"; +import { compileCashAssemblyString } from "@xo-cash/utils"; import type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js"; import { resolveCommitReferences } from "../utils/resolve-invitation-data.js"; diff --git a/src/utils/blockchain/in-memory-blockchain-provider.ts b/src/utils/blockchain/in-memory-blockchain-provider.ts new file mode 100644 index 0000000..a7ed571 --- /dev/null +++ b/src/utils/blockchain/in-memory-blockchain-provider.ts @@ -0,0 +1,264 @@ +import type { ChainStatus, ElectrumApplicationEvents, TransactionState } from '@electrum-cash/application'; +import type { ScriptHash, ScriptHashListUnspentEntry, ScriptHashListUnspentResponse, ScriptHashStatus, TransactionHash, TransactionHex } from '@electrum-cash/protocol'; + +import type { XOBlockchainProvider } from '@xo-cash/engine'; + +import { binToHex, hash256, hexToBin } from '@bitauth/libauth'; + +import { EventEmitter } from '@xo-cash/utils'; + +/** + * Extremely primitive in-memory blockchain adapter for testing. + * We probably want to move to using the Mem-Cash from Mainnet-Pat + * https://github.com/mainnet-pat/mem-cash + * I haven't looked at how it works yet, though. - Harvey + */ + +type TransactionReceivedEvent = { + transactionHash: string; + transactionState: TransactionState; +}; + +type ScriptHashUpdateEvent = { + scriptHash: string; + status: ScriptHashStatus; +}; + +type ChainStatusEvent = { + chainStatus: ChainStatus; +}; + +type InMemoryBlockchainEvents = { + TransactionReceived: TransactionReceivedEvent; + ScriptHashUpdate: ScriptHashUpdateEvent; + ChainStatus: ChainStatusEvent; +}; + +/** + * In-memory blockchain provider for deterministic tests and local simulations. + */ +export class InMemoryBlockchainProvider implements XOBlockchainProvider { + /** + * Event emitter compatible with electrum event shapes used by the engine. + */ + private readonly events = new EventEmitter(); + + /** + * Flag indicating if the provider is initialized and available. + */ + private initialized = false; + + /** + * Mutable synthetic chain status. + */ + private chainStatus: ChainStatus = { + currentHeight: 0, + verifiedHeight: 0, + verifiedPercent: '0', + }; + + /** + * Script-hash keyed in-memory UTXO view. + */ + private readonly scriptHashUnspentOutputs = new Map(); + + /** + * Transaction hash -> transaction hex storage for inspection in tests. + */ + private readonly transactions = new Map(); + + /** + * Initializes the in-memory provider. + * + * @param _options - Initialization options (unused for in-memory backend) + */ + async initialize(): Promise { + this.initialized = true; + this.emitChainStatus(); + } + + /** + * Stops the in-memory provider and clears all in-memory data. + */ + async stop(): Promise { + this.initialized = false; + this.scriptHashUnspentOutputs.clear(); + this.transactions.clear(); + } + + /** + * Returns whether this provider is initialized. + */ + hasConnectedClient(): boolean { + return this.initialized; + } + + /** + * Returns the current synthetic chain status. + */ + async getChainStatus(): Promise { + this.assertInitialized(); + + return structuredClone(this.chainStatus); + } + + /** + * Stores the transaction in memory and emits a synthetic TransactionReceived event. + * + * @param transactionHex - Raw transaction hex + * @returns Deterministic transaction hash + */ + async broadcastTransaction(transactionHex: TransactionHex): Promise { + this.assertInitialized(); + const transactionBytes = hexToBin(transactionHex); + if (typeof transactionBytes === 'string') { + throw new Error('Cannot broadcast invalid transaction hex in InMemoryBlockchainProvider.'); + } + + const transactionHash = binToHex(hash256(transactionBytes)); + + this.transactions.set(transactionHash, transactionHex); + this.events.emit('TransactionReceived', { + transactionHash, + transactionState: { + received: true, + verified: undefined, + contested: undefined, + finalized: false, + }, + }); + + return transactionHash; + } + + /** + * No-op subscription for in-memory backend. + * + * @param _scriptHash - Script hash to subscribe to + */ + async subscribeToScriptHash(_scriptHash: ScriptHash): Promise { + this.assertInitialized(); + } + + /** + * Reads current in-memory unspent outputs for the provided script hash. + * + * @param scriptHash - Script hash to query + */ + async fetchScriptHashUnspentOutputs(scriptHash: ScriptHash): Promise { + this.assertInitialized(); + const unspentOutputs = this.scriptHashUnspentOutputs.get(scriptHash) ?? []; + + return structuredClone(unspentOutputs); + } + + async fetchScriptHashUnspentTransactionOutputs(scriptHash: ScriptHash, includeLocked: boolean, includeReserved: boolean): Promise { + return [] as ScriptHashListUnspentEntry[]; + } + + async fetchTransaction(transactionHash: TransactionHash): Promise { + this.assertInitialized(); + const transaction = this.transactions.get(transactionHash); + if (transaction === undefined) { + throw new Error(`Transaction ${transactionHash} not found in InMemoryBlockchainProvider.`); + } + return transaction; + } + + /** + * Registers an event listener. + * + * @param eventName - Event name + * @param listener - Event listener + */ + on( + eventName: EventName, + listener: (...args: ElectrumApplicationEvents[EventName]) => void, + ): void { + this.events.on(eventName as 'TransactionReceived' | 'ScriptHashUpdate' | 'ChainStatus', listener as (...args: any[]) => void); + } + + /** + * Removes an event listener. + * + * @param eventName - Event name + * @param listener - Event listener + */ + off( + eventName: EventName, + listener: (...args: any[]) => void, + ): void { + this.events.off(eventName as keyof InMemoryBlockchainEvents, listener); + } + + /** + * Sets synthetic chain status and emits ChainStatus. + * + * @param chainStatus - Updated chain status + */ + setChainStatus(chainStatus: ChainStatus): void { + this.chainStatus = structuredClone(chainStatus); + this.emitChainStatus(); + } + + /** + * Advances chain height and emits ChainStatus. + * + * @param blocks - Number of blocks to advance + */ + advanceBlocks(blocks = 1): void { + this.chainStatus.currentHeight += blocks; + this.chainStatus.verifiedHeight += blocks; + + const ratio = this.chainStatus.currentHeight === 0 ? 0 : (this.chainStatus.verifiedHeight / this.chainStatus.currentHeight) * 100; + this.chainStatus.verifiedPercent = ratio.toFixed(0); + + this.emitChainStatus(); + } + + /** + * Replaces the in-memory unspent outputs for a script hash. + * Emits a ScriptHashUpdate notification. + * + * @param scriptHash - Target script hash + * @param unspentOutputs - New unspent outputs list + * @param status - Optional script hash status marker + */ + setScriptHashUnspentOutputs(scriptHash: ScriptHash, unspentOutputs: ScriptHashListUnspentResponse, status?: ScriptHashStatus): void { + this.scriptHashUnspentOutputs.set(scriptHash, structuredClone(unspentOutputs)); + this.events.emit('ScriptHashUpdate', { + scriptHash, + status: status ?? ('' as ScriptHashStatus), + }); + } + + /** + * Gets a previously broadcast transaction hex if present. + * + * @param transactionHash - Transaction hash + * @returns Transaction hex if present + */ + getBroadcastTransaction(transactionHash: TransactionHash): TransactionHex | undefined { + const transaction = this.transactions.get(transactionHash); + + return transaction === undefined ? undefined : structuredClone(transaction); + } + + /** + * Emits the current chain status. + */ + private emitChainStatus(): void { + this.events.emit('ChainStatus', { chainStatus: structuredClone(this.chainStatus) }); + } + + /** + * Ensures the provider is initialized. + * + * @throws {Error} if provider is not initialized + */ + private assertInitialized(): void { + if (!this.initialized) { + throw new Error('InMemoryBlockchainProvider is not initialized.'); + } + } +} diff --git a/src/utils/sync-server.ts b/src/utils/sync-server.ts index 80a3f93..97e271e 100644 --- a/src/utils/sync-server.ts +++ b/src/utils/sync-server.ts @@ -23,7 +23,7 @@ type InstanceChanged = ResourceInstance & { }; function logSyncServer(message: string): void { - console.error(`[SyncServer] ${message}`); + // console.error(`[SyncServer] ${message}`); } function errorMessage(error: unknown): string { diff --git a/tests/cli/commands/invitation.test.ts b/tests/cli/commands/invitation.test.ts index 833d302..b9e7c06 100644 --- a/tests/cli/commands/invitation.test.ts +++ b/tests/cli/commands/invitation.test.ts @@ -160,7 +160,7 @@ describe("invitation command - error cases", () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-errors-")); paths = createMockPaths(tempDir); }); @@ -201,7 +201,7 @@ describe("invitation command - receive flow", () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-receive-")); paths = createMockPaths(tempDir); }); @@ -276,7 +276,7 @@ describe("invitation command - receive flow", () => { await handleInvitationCommand( createCommandDeps(app, io, paths), ["create", "Wallet (P2PKH)", "receive"], - {}, + { varTransferredSatoshis: "10000", role: "receiver" }, ); expectLogs(spies, [{ out: "All requirements satisfied" }]); @@ -314,7 +314,7 @@ describe("invitation command - request satoshis flow", () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-request-")); paths = createMockPaths(tempDir); }); @@ -410,7 +410,7 @@ describe("invitation command - send flow with resources", () => { engine = mockEngine.engine; state = mockEngine.state; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-send-")); paths = createMockPaths(tempDir); }); @@ -489,7 +489,7 @@ describe("invitation command - send flow with resources", () => { varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", addInput: - "0000000000000000000000000000000000000000000000000000000000000000:0", + "0000000000000000000000000000000000000000000000000000000000000001:0", role: "sender", }, ); @@ -536,7 +536,7 @@ describe("invitation command - multi-step append", () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-append-")); paths = createMockPaths(tempDir); }); @@ -684,7 +684,7 @@ describe("invitation command - list and inspect", () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-list-")); paths = createMockPaths(tempDir); }); @@ -853,7 +853,7 @@ describe("invitation command - sign flow", () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-sign-")); paths = createMockPaths(tempDir); }); @@ -930,7 +930,7 @@ describe("invitation command - sign flow", () => { const result = await handleInvitationCommand( createCommandDeps(app, io, paths), ["create", "Wallet (P2PKH)", "receive"], - { sign: "true" }, + { sign: "true", varTransferredSatoshis: "10000", role: "receiver" }, ); expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/); @@ -940,6 +940,24 @@ describe("invitation command - sign flow", () => { ]); }); + /** + * Tests the --sign flag on create will not auto-signs when requirements are not satisfied. + */ + test("--sign flag auto-signs on create", async () => { + const { io, spies } = createMockIO(); + + const result = await handleInvitationCommand( + createCommandDeps(app, io, paths), + ["create", "Wallet (P2PKH)", "receive"], + { sign: "true" }, + ); + + expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/); + expectLogs(spies, [ + { out: "Remaining requirements" }, + ]); + }); + /** * Tests that signing fails when invitation doesn't exist. */ @@ -969,12 +987,13 @@ describe("invitation command - import flow", () => { let app: AppService; let tempDir: string; let paths: CommandPaths; + let mockEngine: any; beforeEach(async () => { - const mockEngine = await createMockEngine(DEFAULT_SEED); + mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-import-")); paths = createMockPaths(tempDir); }); @@ -1001,7 +1020,7 @@ describe("invitation command - import flow", () => { `inv-${createResult.invitationIdentifier}.json`, ); - const secondApp = await createMockAppService(engine); + const secondApp = await createMockAppService(engine, mockEngine.state); const { io: importIO } = createMockIO(); const importResult = await handleInvitationCommand( @@ -1048,7 +1067,7 @@ describe("invitation command - import flow", () => { `inv-${createResult.invitationIdentifier}.json`, ); - const secondApp = await createMockAppService(engine); + const secondApp = await createMockAppService(engine, mockEngine.state); const { io: importIO } = createMockIO(); await handleInvitationCommand( @@ -1077,7 +1096,7 @@ describe("invitation command - import flow", () => { `inv-${createResult.invitationIdentifier}.json`, ); - const secondApp = await createMockAppService(engine); + const secondApp = await createMockAppService(engine, mockEngine.state); const { io: importIO } = createMockIO(); await handleInvitationCommand( @@ -1106,7 +1125,7 @@ describe("invitation command - auto-inputs flow", () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-autoinputs-")); paths = createMockPaths(tempDir); }); @@ -1182,7 +1201,7 @@ describe("invitation command - broadcast flow", () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-broadcast-")); paths = createMockPaths(tempDir); }); @@ -1247,7 +1266,7 @@ describe("invitation command - full lifecycle", () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-lifecycle-")); paths = createMockPaths(tempDir); }); @@ -1402,7 +1421,7 @@ describe("invitation command - full lifecycle", () => { const result = await handleInvitationCommand( createCommandDeps(app, io, paths), ["create", "Wallet (P2PKH)", "receive"], - { sign: "true" }, + { sign: "true", varTransferredSatoshis: "10000", role: "receiver" }, ); expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/); diff --git a/tests/cli/commands/receive.test.ts b/tests/cli/commands/receive.test.ts index c50cb26..df17118 100644 --- a/tests/cli/commands/receive.test.ts +++ b/tests/cli/commands/receive.test.ts @@ -85,7 +85,7 @@ describe("receive command", () => { engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-receive-tests-")); }); @@ -139,4 +139,4 @@ describe("receive command", () => { } }, ); -}); +}, 1000); diff --git a/tests/cli/commands/resource.test.ts b/tests/cli/commands/resource.test.ts index 7668b84..12e3298 100644 --- a/tests/cli/commands/resource.test.ts +++ b/tests/cli/commands/resource.test.ts @@ -11,7 +11,7 @@ import { reserveResource, } from "../mocks/engine"; import { type Engine } from "@xo-cash/engine"; -import { p2pkhTemplate } from "../mocks/template-p2pkh"; +import { p2pkhTemplate, p2pkhTemplateIdentifier } from "../mocks/template-p2pkh"; import { AppService } from "../../../src/services/app"; import { handleResourceCommand } from "../../../src/cli/commands/resource"; @@ -125,7 +125,7 @@ describe("resource command", () => { engine = mockEngine.engine; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-")); }); @@ -192,7 +192,7 @@ describe("resource command with populated data", () => { engine = mockEngine.engine; state = mockEngine.state; await engine.importTemplate(p2pkhTemplate); - app = await createMockAppService(engine); + app = await createMockAppService(engine, state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-")); }); @@ -283,9 +283,17 @@ describe("resource command with populated data", () => { }); test("unreserve releases a reserved UTXO", async () => { + // Create an invitation + const invitation = await engine.createInvitation({ + templateIdentifier: p2pkhTemplateIdentifier, + actionIdentifier: "receive", + variables: [], + }); + const invitationInstance = await app.createInvitation(invitation); + const resource = await addFakeResource(state, { valueSatoshis: 25000, - reservedBy: "inv-123", + reservedBy: invitationInstance.data.invitationIdentifier, }); const { io, spies } = createMockIO(); @@ -299,8 +307,8 @@ describe("resource command with populated data", () => { ); expectLogs(spies, [ - { out: "Unreserved" }, - { out: "was reserved for inv-123" }, + { out: "Archived invitation" }, + { out: invitationInstance.data.invitationIdentifier }, ]); const resources = await engine.listUnspentOutputsData(); @@ -327,15 +335,24 @@ describe("resource command with populated data", () => { }); test("unreserve-all releases all reserved UTXOs", async () => { + // Create 2 fake invitations + const invitation1 = await engine.createInvitation({ + templateIdentifier: p2pkhTemplateIdentifier, + actionIdentifier: "receive", + variables: [], + }); + const invitation2 = await engine.createInvitation({ + templateIdentifier: p2pkhTemplateIdentifier, + actionIdentifier: "receive", + variables: [], + }); + + const invitationInstance1 = await app.createInvitation(invitation1); + const invitationInstance2 = await app.createInvitation(invitation2); + await addFakeResource(state, { valueSatoshis: 50000 }); - await addFakeResource(state, { - valueSatoshis: 25000, - reservedBy: "inv-123", - }); - await addFakeResource(state, { - valueSatoshis: 10000, - reservedBy: "inv-456", - }); + await addFakeResource(state, { valueSatoshis: 25000, reservedBy: invitationInstance1.data.invitationIdentifier }); + await addFakeResource(state, { valueSatoshis: 10000, reservedBy: invitationInstance2.data.invitationIdentifier }); const { io, spies } = createMockIO(); const result = await handleResourceCommand( diff --git a/tests/cli/commands/template.test.ts b/tests/cli/commands/template.test.ts index 0031636..d08a6b7 100644 --- a/tests/cli/commands/template.test.ts +++ b/tests/cli/commands/template.test.ts @@ -204,9 +204,12 @@ describe("template command", () => { beforeEach(async () => { const mockEngine = await createMockEngine(DEFAULT_SEED); engine = mockEngine.engine; - await engine.importTemplate(p2pkhTemplate); + const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate); + if (templateIdentifier !== p2pkhTemplateIdentifier) { + throw new Error("Template identifier mismatch"); + } - app = await createMockAppService(engine); + app = await createMockAppService(engine, mockEngine.state); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-template-tests-")); }); diff --git a/tests/cli/mocks/engine.ts b/tests/cli/mocks/engine.ts index e3ab036..4066903 100644 --- a/tests/cli/mocks/engine.ts +++ b/tests/cli/mocks/engine.ts @@ -10,7 +10,7 @@ import { UnspentOutputStatus, type UnspentOutputData, } from "@xo-cash/state"; -import { InMemoryBlockchainProvider } from "@xo-cash/engine"; +import { InMemoryBlockchainProvider } from "../../../src/utils/blockchain/in-memory-blockchain-provider.js"; import { convertMnemonicToSeedBytes } from "@xo-cash/crypto"; import { binToHex, sha256 } from "@bitauth/libauth"; @@ -20,6 +20,7 @@ import { MockElectrumService } from "./electrum-service"; import { MockRatesService } from "./rates-service"; import { RatesService } from "../../../src/services/rates"; import { SettingsService } from "../../../src/services/settings"; +import { mkdirSync } from "node:fs"; export const DEFAULT_SEED = "oven crop same above under tower promote decrease vocal pretty require slow"; @@ -68,6 +69,7 @@ export const addFakeResource = async ( options: FakeResourceOptions = {}, ): Promise => { const resource: UnspentOutputData = { + templateIdentifier: options.templateIdentifier ?? "test-template", status: UnspentOutputStatus.CONFIRMED, selectable: true, privacy: false, @@ -131,10 +133,16 @@ export const unreserveResource = async ( * @returns A mock engine instance. */ export const createMockEngine = async (seed: string) => { + const randomId = Math.random().toString(36).substring(2, 15); + const stateDir = `${tmpdir()}/test-data-${randomId}`; + mkdirSync(stateDir, { recursive: true }); + // Create the in-memory storage adapter. const storage = await createStorageAdapter({ - storageType: "inmemory", + storageType: StorageType.INDEXEDDB, accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))), + databasePath: stateDir, + databaseFilename: `xo-wallet-${randomId}.db`, }); // Initialize the storage adapter. @@ -152,13 +160,17 @@ export const createMockEngine = async (seed: string) => { await blockchainMonitor.initializeEventListeners(); // Create the engine instance. - const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider); + const engine = new Engine(seed, state, blockchainProvider, blockchainMonitor); await engine.initializeStateSync(); return { engine, state, blockchainMonitor, blockchainProvider }; }; -export const createMockAppService = async (engine: Engine) => { +export const createMockAppService = async (engine: Engine, state: State) => { + if (!state) { + throw new Error("State is required"); + } + const settings = new SettingsService( `${tmpdir()}/xo-cli-tests-settings.json`, ); @@ -187,6 +199,7 @@ export const createMockAppService = async (engine: Engine) => { mockElectrum, rates, settings, + state, new Uint8Array(32).fill(1), ); }; diff --git a/tests/cli/mocks/template-p2pkh.ts b/tests/cli/mocks/template-p2pkh.ts index ebc4df7..55d1efe 100644 --- a/tests/cli/mocks/template-p2pkh.ts +++ b/tests/cli/mocks/template-p2pkh.ts @@ -1,1496 +1,1447 @@ -import type { XOTemplate } from "@xo-cash/types"; -import { generateTemplateIdentifier, parseTemplate } from "@xo-cash/engine"; +import type { XOTemplate } from '@xo-cash/types'; +import { generateTemplateIdentifier, parseTemplate } from '@xo-cash/engine'; export const p2pkhTemplate: XOTemplate = { - $schema: "https://libauth.org/schemas/wallet-template-v0.schema.json", + $schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json', - // Name for this template. - name: "Wallet (P2PKH)", + // Name for this template. + name: 'Wallet (P2PKH)', - // Description for this template. - description: - "A standard single-factor wallet template that uses Pay-to-Public-Key-Hash (P2PKH) locking scripts.", + // Description for this template. + description: 'A standard single-factor wallet template that uses Pay-to-Public-Key-Hash (P2PKH) locking scripts.', - // Icon for this template. - icon: "wallet", + // Icon for this template. + icon: 'wallet', - // Version number for this template. - version: "1", + // Version number for this template. + version: '1', - // List of VM versions that can be used to run this template. - supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"], + // List of VM versions that can be used to run this template. + supported: [ 'BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05' ], - // Sets optional default values to be used with this template. - defaults: { - // Configures a default intent structure for creating change outputs and locking scripts. - // NOTE: This is used when the engine needs to make change for an output created by - // this template and there is no other policy provided elsewhere that takes precedence. - // NOTE: It is recommended that templates that create outputs with the 'selectable' property - // either provides a change policy here, or a comment that explain that they - // intentionally omit the change policy and why. - change: { - output: "changeOutput", - role: "receiver", - generate: ["ownerKey"], - }, - }, - - // Describe a list of roles that are used in this template. - // NOTE: Template roles are held only for the duration of one specific action/invitation. - // For example, the same entity that acts as 'receiver' when creating/receiving an output - // can later act as 'sender' (or 'owner') when performing a follow-up action. - roles: { - owner: { - name: "Wallet Owner", - description: "The party who can spend from this wallet.", - icon: "owner", - }, - receiver: { - name: "Receiver", - description: "A party that is receiving value.", - icon: "receiver", - }, - sender: { - name: "Sender", - description: "A party that is sending value.", - icon: "sender", - }, - }, - - // Define a list of entrypoints supported by this template. - start: [ - { - action: "receive", - role: "receiver", - generate: ["ownerKey"], - }, - { - action: "requestSatoshis", - role: "receiver", - generate: ["ownerKey"], - }, - { - action: "requestFungibleTokens", - role: "receiver", - generate: ["ownerKey"], - }, - { - action: "requestNonfungibleTokens", - role: "receiver", - generate: ["ownerKey"], - }, - ], - - // Define a list of actions that can be taken by this template. - // NOTE: There is no action to generate an address, but a wallet can create an invitation to a receive action and - // extract the generated lockscript as needed as the engine will track all lockscripts it generates. - actions: { - receive: { - // TODO: Consider rewriting to be generic/role-less. - name: "Receive", - description: - "Receive an unspecified amount of cash and/or tokens from one or more senders.", - icon: "receive", - - roles: { - receiver: { - name: "Receive", - description: - "Receive an unspecified amount of cash and/or tokens from one or more senders.", - icon: "receive", - - requirements: { - secrets: ["ownerKey"], - }, + // Sets optional default values to be used with this template. + defaults: { + // Configures a default intent structure for creating change outputs and locking scripts. + // NOTE: This is used when the engine needs to make change for an output created by + // this template and there is no other policy provided elsewhere that takes precedence. + // NOTE: It is recommended that templates that create outputs with the 'selectable' property + // either provides a change policy here, or a comment that explain that they + // intentionally omit the change policy and why. + change: { + output: 'changeOutput', + role: 'receiver', + generate: [ 'ownerKey' ], }, - sender: { - name: "Send", - description: - "Send an unspecified amount of cash and/or tokens to the provided receiver.", - icon: "send", - - // The sender only need to provide blockchain-level requirements. - // NOTE: This field is not required when empty, but shown here for illustrative purposes. - requirements: {}, - }, - }, - - requirements: { - participants: [ - { - role: "receiver", - slots: { min: 1, max: 1 }, - }, - { - role: "sender", - slots: { min: 1, max: undefined }, - }, - ], - // variables: [ 'requestedSatoshis' ], - }, - - transaction: "receiveTransaction", - }, - requestSatoshis: { - // TODO: Consider rewriting to be generic/role-less. - name: "Request Satoshis", - description: - "Requests a specific amount of Bitcoin Cash from one or more senders.", - icon: "request", - - roles: { - receiver: { - name: "Request Satoshis", - description: - "Requests a specific amount of Bitcoin Cash from one or more senders.", - icon: "request", - - requirements: { - secrets: ["ownerKey"], - variables: ["requestedSatoshis"], - }, - }, - sender: { - name: "Send", - description: - "Send a specific amount of Bitcoin Cash to the provided receiver.", - icon: "send", - - // The sender only need to provide blockchain-level requirements. - // NOTE: This field is not required when empty, but shown here for illustrative purposes. - requirements: {}, - }, - }, - - requirements: { - participants: [ - { - role: "receiver", - slots: { min: 1, max: 1 }, - }, - { - role: "sender", - slots: { min: 1, max: undefined }, - }, - ], - }, - - transaction: "requestSatoshisTransaction", - }, - requestFungibleTokens: { - // TODO: Consider rewriting to be generic/role-less. - name: "Request Fungible Tokens", - description: - "Requests a specific amount of a fungible tokens from one or more senders.", - icon: "request", - - roles: { - receiver: { - name: "Request Fungible Tokens", - description: - "Requests a specific amount of a fungible tokens from one or more senders.", - icon: "request", - - requirements: { - secrets: ["ownerKey"], - variables: ["requestedTokenCategory", "requestedTokenAmount"], - }, - }, - sender: { - name: "Send", - description: - "Send a specific amount of fungible tokens to the provided receiver.", - icon: "send", - - // The sender only need to provide blockchain-level requirements. - // NOTE: This field is not required when empty, but shown here for illustrative purposes. - requirements: {}, - }, - }, - - requirements: { - participants: [ - { - role: "receiver", - slots: { min: 1, max: 1 }, - }, - { - role: "sender", - slots: { min: 1, max: undefined }, - }, - ], - }, - - transaction: "requestFungibleTokensTransaction", - }, - requestNonfungibleTokens: { - // TODO: Consider rewriting to be generic/role-less. - name: "Request a Non-fungible Token", - description: "Requests a non-fungible token from one or more senders.", - icon: "request", - - roles: { - receiver: { - name: "Request a Non-fungible Token", - description: - "Requests a non-fungible token from one or more senders.", - icon: "request", - - requirements: { - secrets: ["ownerKey"], - variables: [ - "requestedTokenCategory", - "requestedTokenCapability", - "requestedTokenCommitment", - ], - }, - }, - sender: { - name: "Send", - description: "Send a non-fungible token to the provided receiver.", - icon: "send", - - // The sender only need to provide blockchain-level requirements. - // NOTE: This field is not required when empty, but shown here for illustrative purposes. - requirements: {}, - }, - }, - - requirements: { - participants: [ - { - role: "receiver", - slots: { min: 1, max: 1 }, - }, - { - role: "sender", - slots: { min: 1, max: undefined }, - }, - ], - }, - - transaction: "requestNonfungibleTokensTransaction", }, - // NOTE: Sending value can be done without explicit template support. - // NOTE: This feature is explicitly defined in this template to demonstrate how versatile templates can be, and - // to ensure the feature is discoverable by the user from outputs that hold value. - sendSatoshis: { - name: "Send Satoshis", - description: - "Sends a specific amount of Bitcoin Cash to a given recipient.", - icon: "send", - - roles: { - sender: { - requirements: { - variables: ["transferredSatoshis", "recipientLockingscript"], - secrets: ["ownerKey"], - }, - }, - }, - - requirements: { - participants: [ - { - role: "sender", - slots: { min: 1, max: 1 }, - }, - ], - }, - - // Sending is only available for outputs that have sufficient satoshis on them. - // NOTE: Dust is enforced here according to standardness rules. - conditions: ["$(OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHAN)"], - - transaction: "transferSatoshisTransaction", - }, - sendFungibleTokens: { - name: "Send Fungible Tokens", - description: - "Send a specific amount of a fungible token to a given recipient.", - icon: "send", - - roles: { - sender: { - requirements: { - variables: [ - "transferredTokenCategory", - "transferredTokenAmount", - "recipientLockingscript", - ], - secrets: ["ownerKey"], - }, - }, - }, - - requirements: { - participants: [ - { - role: "sender", - slots: { min: 1, max: 1 }, - }, - ], - }, - - // Sending is only available for outputs that have fungible tokens on them. - conditions: ["$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)"], - - transaction: "transferFungibleTokensTransaction", - }, - sendNonfungibleTokens: { - name: "Send a Non-fungible Token", - description: "Send a non-fungible token to a given recipient.", - icon: "send", - - roles: { - sender: { - requirements: { - variables: [ - "transferredTokenCategory", - "transferredTokenCapability", - "transferredTokenCommitment", - "recipientLockingscript", - ], - secrets: ["ownerKey"], - }, - }, - }, - - requirements: { - participants: [ - { - role: "sender", - slots: { min: 1, max: 1 }, - }, - ], - }, - - // Sending is only available for outputs that have a non-fungible token on them. - conditions: [ - "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)", - ], - - transaction: "transferNonfungibleTokensTransaction", - }, - - // NOTE: Burning tokens can be done without explicit template support. - // NOTE: This feature is explicitly defined in this template to demonstrate how versatile templates can be, and - // to ensure the feature is discoverable by the user from outputs that hold tokens. - burnFungibleTokens: { - name: "Delete Fungible Tokens", - description: - "Permanently and irreversibly deletes one or more fungible tokens.", - icon: "burn", - - roles: { + // Describe a list of roles that are used in this template. + // NOTE: Template roles are held only for the duration of one specific action/invitation. + // For example, the same entity that acts as 'receiver' when creating/receiving an output + // can later act as 'sender' (or 'owner') when performing a follow-up action. + roles: { owner: { - requirements: { - variables: ["burnedTokenCategory", "burnedTokenAmount"], - secrets: ["ownerKey"], - }, + name: 'Wallet Owner', + description: 'The party who can spend from this wallet.', + icon: 'owner', }, - }, - - requirements: { - participants: [ - { - role: "owner", - slots: { min: 1, max: 1 }, - }, - ], - }, - - // Burning is only available for outputs that have fungible tokens on them. - conditions: ["$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)"], - - transaction: "burnFungibleTokensTransaction", - }, - burnNonfungibleTokens: { - name: "Delete a Non-fungible Token", - description: - "Permanently and irreversibly deletes one non-fungible token.", - icon: "burn", - - roles: { - owner: { - requirements: { - variables: [ - "burnedTokenCategory", - "burnedTokenCapability", - "burnedTokenCommitment", - ], - secrets: ["ownerKey"], - }, - }, - }, - - requirements: { - participants: [ - { - role: "owner", - slots: { min: 1, max: 1 }, - }, - ], - }, - - // Burning is only available for outputs that have non-fungible tokens on them. - conditions: [ - "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)", - ], - - transaction: "burnNonfungibleTokenTransaction", - }, - - sign: { - name: "Sign Message", - description: - "Signs a provided message using the Bitcoin message signing protocol.", - icon: "sign", - - roles: { - owner: { - requirements: { - variables: ["messageToSign"], - secrets: ["ownerKey"], - }, - }, - }, - - requirements: { - participants: [ - { - role: "owner", - slots: { min: 1, max: 1 }, - }, - ], - }, - - data: "messageSignature", - }, - verify: { - name: "Verify Message Signature", - description: - "Verifies a provided message signature according to the Bitcoin message signing protocol.", - icon: "verify", - - roles: { - owner: { - requirements: { - variables: ["messageSignature", "messageToVerify"], - secrets: ["ownerKey"], - }, - }, - }, - - requirements: { - participants: [ - { - role: "owner", - slots: { min: 1, max: 1 }, - }, - ], - }, - - data: "messageSignatureValidity", - }, - }, - - // Define a set of data that can be used in this template. - data: { - messageSignature: { - // Evaluate CashASM expression to get the signature needed. - // NOTE: Pushes the prefix and message, then concatenates them together to form the data to sign. - // NOTE: In libauth today, it seems that this is done by defining the signature as a variable, and tying it to the key and message, - // and so this is different - // TODO: Check with Jason and see if there is any reason why this cannot be done like this. - value: - "$( OP_CAT )", - type: "bytes", - hint: "signature", - }, - messageSignatureValidity: { - // Evaluate the validity of the message with the owners public key. - value: - "$( OP_CAT OP_CHECKDATASIG)", - type: "integer", - hint: "script_boolean", - }, - }, - - // Define a set of transactions that can be used in this template. - transactions: { - receiveTransaction: { - name: "Transfer Completed", - description: "Transferred an unspecified amount of cash and/or tokens.", - icon: "request", - - roles: { receiver: { - name: "Received", - description: "Received an unspecified amount of cash and/or tokens.", - icon: "receive", + name: 'Receiver', + description: 'A party that is receiving value.', + icon: 'receiver', }, sender: { - name: "Sent", - description: "Sent an unspecified amount of cash and/or tokens.", - icon: "send", + name: 'Sender', + description: 'A party that is sending value.', + icon: 'sender', }, - }, + }, - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to - // match the output and thus generate an invitation to participate in this action. - // When the invitation is shared, the other parties can add as many inputs and change outputs as needed. - inputs: [], - outputs: [ + // Define a list of entrypoints supported by this template. + start: [ { - output: "receiveOutput", - outputIndex: undefined, + action: 'receive', + role: 'receiver', + generate: [ 'ownerKey' ], }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - requestSatoshisTransaction: { - name: "Satoshis Transferred", - description: "Transferred $() satoshis.", - icon: "request", - - roles: { - receiver: { - name: "Received", - description: "Received $() satoshis.", - icon: "receive", - }, - sender: { - name: "Sent", - description: "Sent $() satoshis.", - icon: "send", - }, - }, - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to - // match the output and thus generate an invitation to participate in this action. - // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. - inputs: [], - outputs: [ { - output: "requestSatoshisOutput", - outputIndex: undefined, + action: 'requestSatoshis', + role: 'receiver', + generate: [ 'ownerKey' ], }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - requestFungibleTokensTransaction: { - name: "Fungible Tokens Transferred", - description: - "Transferred $( OP_DIV).$( OP_MOD) $() tokens.", - icon: "request", - - roles: { - receiver: { - name: "Received", - description: - "Received $( OP_DIV).$( OP_MOD) $() tokens.", - icon: "receive", - }, - sender: { - name: "Sent", - description: - "Sent $( OP_DIV).$( OP_MOD) $() tokens.", - icon: "send", - }, - }, - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to - // match the output and thus generate an invitation to participate in this action. - // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. - inputs: [], - outputs: [ { - output: "requestFungibleTokensOutput", - outputIndex: undefined, + action: 'requestFungibleTokens', + role: 'receiver', + generate: [ 'ownerKey' ], }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - requestNonfungibleTokensTransaction: { - name: "Non-Fungible Token Transferred", - description: - 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: "request", - - roles: { - receiver: { - name: "Received", - description: - 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: "receive", - }, - sender: { - name: "Sent", - description: - 'Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: "send", - }, - }, - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to - // match the output and thus generate an invitation to participate in this action. - // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. - inputs: [], - outputs: [ { - output: "requestNonfungibleTokensOutput", - outputIndex: undefined, + action: 'requestNonfungibleTokens', + role: 'receiver', + generate: [ 'ownerKey' ], }, - ], + ], - // Standard transaction without a locktime. - version: 2, - locktime: 0, + // Define a list of actions that can be taken by this template. + // NOTE: There is no action to generate an address, but a wallet can create an invitation to a receive action and + // extract the generated lockscript as needed as the engine will track all lockscripts it generates. + actions: { + receive: { + // TODO: Consider rewriting to be generic/role-less. + name: 'Receive', + description: 'Receive an unspecified amount of cash and/or tokens from one or more senders.', + icon: 'receive', - // ... - composable: true, - }, + roles: { + receiver: { + name: 'Receive', + description: 'Receive an unspecified amount of cash and/or tokens from one or more senders.', + icon: 'receive', - transferSatoshisTransaction: { - name: "Satoshis Transferred", - description: - "$() satoshis were transferred to a recipient.", - icon: "send", + requirements: { + secrets: [ 'ownerKey' ], + }, + }, + sender: { + name: 'Send', + description: 'Send an unspecified amount of cash and/or tokens to the provided receiver.', + icon: 'send', - roles: { - receiver: { - name: "Received", - description: "Received $() satoshis.", - icon: "receive", - }, - sender: { - name: "Sent", - description: "Sent $() satoshis.", - icon: "send", - }, - }, - - // Enforce the inputs and outputs required by the transaction. - // NOTE: The input is provided from the action since it is only available on outputs with satoshis. - inputs: [], - outputs: [ - { - output: "transferSatoshisOutput", - outputIndex: undefined, - }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - transferFungibleTokensTransaction: { - name: "Fungible Tokens Transferred", - description: - "$( OP_DIV).$( OP_MOD) $() tokens were transferred to a recipient.", - icon: "send", - - roles: { - receiver: { - name: "Received", - description: - "Received $( OP_DIV).$( OP_MOD) $() tokens.", - icon: "receive", - }, - sender: { - name: "Sent", - description: - "Sent $( OP_DIV).$( OP_MOD) $() tokens.", - icon: "send", - }, - }, - - // Enforce the inputs and outputs required by the transaction. - // NOTE: The input is provided from the action since it is only available on outputs with fungible tokens. - inputs: [], - outputs: [ - { - output: "transferFungibleTokensOutput", - outputIndex: undefined, - }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - transferNonfungibleTokensTransaction: { - name: "Non-fungible Token Transferred", - description: - 'One non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token was transferred to a recipient, with $() commitment.', - icon: "send", - - roles: { - receiver: { - name: "Received", - description: - 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: "receive", - }, - sender: { - name: "Sent", - description: - 'Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: "send", - }, - }, - - // Enforce the inputs and outputs required by the transaction. - // NOTE: The input is provided from the action since it is only available on outputs with a non-fungible token. - inputs: [], - outputs: [ - { - output: "transferNonfungibleTokenOutput", - outputIndex: undefined, - }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - - burnFungibleTokensTransaction: { - name: "Deleted fungible tokens", - description: - "Permanently and irreversibly deleted $( OP_DIV).$( OP_MOD) $() tokens.", - icon: "burn", - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no defined outputs as any non-burned value is automatically returned as change. - inputs: [ - { - input: "burnFungibleTokensInput", - inputIndex: undefined, - }, - ], - - outputs: [], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - burnNonfungibleTokenTransaction: { - name: "Deleted non fungible token", - description: - 'Permanently and irreversibly deleted a non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: "burn", - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no defined outputs as any non-burned value is automatically returned as change. - inputs: [ - { - input: "burnNonfungibleTokenInput", - inputIndex: undefined, - }, - ], - outputs: [], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - }, - - // Define a set of outputs that can be used within transactions in this template. - outputs: { - changeOutput: { - name: "Change", - description: "Funds returned as change.", - icon: "receive", - - // Defines how the requested funds should be locked. - lockingScript: "receivingLockingScript", - }, - receiveOutput: { - name: "Recipient output", - description: - "Transferred an unspecified amount of cash and/or tokens to a recipient.", - icon: "receive", - - roles: { - receiver: { - name: "Received", - description: "Received an unspecified amount of cash and/or tokens.", - }, - sender: { - name: "Sent", - description: "Sent an unspecified amount of cash and/or tokens.", - }, - }, - - // Defines how the requested funds should be locked. - lockingScript: "receivingLockingScript", - }, - requestSatoshisOutput: { - name: "Satoshis", - description: "$() satoshis.", - icon: "request", - - roles: { - receiver: { - name: "Satoshis Received", - description: "Received $() satoshis.", - }, - sender: { - name: "Satoshis Sent", - description: "Sent $() satoshis.", - }, - }, - - // Defines how the requested funds should be locked. - lockingScript: "receivingLockingScript", - - // Require the specified number of satoshis and no tokens. - valueSatoshis: "$()", - token: null, - }, - requestFungibleTokensOutput: { - name: "Fungible $() Tokens", - description: - "$( OP_DIV).$( OP_MOD) $() tokens.", - icon: "request", - - roles: { - receiver: { - name: "Fungible $() Tokens Received", - description: - "Received $( OP_DIV).$( OP_MOD) $() tokens.", - }, - sender: { - name: "Fungible $() Tokens Sent", - description: - "Sent $( OP_DIV).$( OP_MOD) $() tokens.", - }, - }, - - // Defines how the requested funds should be locked. - lockingScript: "receivingLockingScript", - - // Require a flat 1000 satoshis to ensure the fungible tokens remains transferrable. - valueSatoshis: "1000", - - // Require only the specified amount and type of fungible tokens. - // NOTE: This can be composed with a request for a non-fungible token, but will always result in two separate outputs. - token: { - category: "$()", - amount: "$()", - nft: null, - }, - }, - requestNonfungibleTokensOutput: { - name: "Non-fungible $() Token", - description: - 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.', - icon: "request", - - roles: { - receiver: { - name: "Non-fungible $() Token Received", - description: - 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', - }, - sender: { - name: "Non-fungible $() Token Sent", - description: - 'Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', - }, - }, - - // Defines how the requested funds should be locked. - lockingScript: "receivingLockingScript", - - // Require a flat 1000 satoshis to ensure the non-fungible token remains transferrable. - valueSatoshis: "1000", - - // Require only an NFT with specified category, capability and commitment. - // NOTE: This can be composed with a request for fungible token amounts, but will always result in two separate outputs. - token: { - category: "$()", - amount: null, - nft: { - capability: "$()", - commitment: "$()", - }, - }, - }, - - transferSatoshisOutput: { - name: "Recipient output", - description: - "Transferred $() satoshis to a recipient.", - icon: "send", - - roles: { - receiver: { - name: "Received", - description: "Received $() satoshis.", - }, - sender: { - name: "Sent", - description: "Sent $() satoshis.", - }, - }, - - // Use the recipients lockscript. - lockingScript: "sendingLockingscript", - - // Set the amount of satoshis to transfer. - valueSatoshis: "$()", - }, - transferFungibleTokensOutput: { - name: "Recipient output", - description: - "Transferred $( OP_DIV).$( OP_MOD) $() tokens to a recipient.", - icon: "send", - - roles: { - receiver: { - name: "Received", - description: - "Received $( OP_DIV).$( OP_MOD) $() tokens.", - }, - sender: { - name: "Sent", - description: - "Sent $( OP_DIV).$( OP_MOD) $() tokens.", - }, - }, - - // Use the recipients lockscript. - lockingScript: "sendingLockingscript", - - // Set the amount of fungible tokens to transfer. - token: { - category: "$()", - amount: "$()", - }, - }, - transferNonfungibleTokenOutput: { - name: "Recipient output", - description: - 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.', - icon: "send", - - roles: { - receiver: { - name: "Received", - description: - 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', - }, - sender: { - name: "Sent", - description: - 'Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', - }, - }, - - // Use the recipients lockscript. - lockingScript: "sendingLockingscript", - - // Set the non-fungible token to transfer. - token: { - category: "$()", - nft: { - capability: "$()", - commitment: "$()", - }, - }, - }, - }, - - inputs: { - burnFungibleTokensInput: { - name: "Deleted fungible tokens", - description: - "Permanently and irreversibly deleted $() $().", - icon: "burn", - - // Define which unlocking script unlocks this input. - unlockingScript: "unlockP2PKH", - - // Require a fungible token of the requested token category, with an amount larger than or equal to the requested amount to burn. - token: { - category: "$()", - amount: - "$( OP_GREATERTHANOREQUAL OP_IF OP_ENDIF)", - }, - - // Ignore the burned token amount when determining change for this output. - // NOTE: The engine must check that this does not exceed the input value. - omitChangeAmounts: { - fungibleTokens: "${}", - }, - }, - burnNonfungibleTokenInput: { - name: "Deleted non-fungible token", - description: - 'Permanently and irreversibly burned one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) token of category $(), with a $() commitment.', - icon: "burn", - - // Define which unlocking script unlocks this input. - unlockingScript: "unlockP2PKH", - - // Require a non-fungible token of the specified category, capability and commitment to burn. - token: { - category: "$()", - nft: { - capability: "$()", - commitment: "$()", - }, - }, - - // Ignore the burned token when determining change for this output. - // NOTE: The engine must check that this does not exceed the input value. - omitChangeAmounts: { - nonfungibleTokens: 1, - }, - }, - }, - - // Define locking scripts used by this template. - // NOTE: Template supported wallets should automatically track all generated lockscripts for on-chain events. - lockingScripts: { - sendingLockingscript: { - // TODO: This currently describes outputs locked to this script, but all actions creating such outputs already have descriptions. - // This can either be dropped, or maybe more importantly, should be disambiguated so that lockscripts can provide separate - // descriptions for their script and the outputs that are locked to them. Leaving as a TODO for now and will address later. - name: "Sent", - description: "Funds sent to an external recipient", - icon: "address", - - // ... - lockingType: "p2pkh", - lockingBytecode: "lockToRecipient", - - // Indicate that the sent output does not belong to the initiating user. - // NOTE: These values default to false/empty, but added here for additional clarity. - actions: [], - state: { variables: [], secrets: [] }, - balance: {}, - selectable: false, - }, - receivingLockingScript: { - // NOTE: Outputs to this lockscript by external actors defaults to this description when detected on-chain. - name: "Received", - description: "Funds received without wallet coordination.", - icon: "address", - - // Defines how spending future received funds should be locked. - lockingType: "p2pkh", - lockingBytecode: "lockP2PKH", - - // Define a default unlocking script to be used when no action provided script is present. - unlockingBytecode: "unlockP2PKH", - - // Participants without a role or observers cannot take any further actions. - // NOTE: This is not required but shown here for illustrative purposes. - actions: [], - - roles: { - receiver: { - // The only state that is required to be persisted when receiving funds is the owners private key. - // NOTE: This is defined as a secret to not leak when creating invitations for others to participate in request or send actions. - state: { - variables: [], - secrets: ["ownerKey"], - }, - - // List actions that can be taken with the ownerKey for each address/lockscript. - actions: [ - { - action: "sign", - role: "owner", - secrets: [{ ownerKey: null }], + requirements: { + variables: [ 'transferredSatoshis' ], + }, + }, }, - { - action: "verify", - role: "owner", - secrets: [{ ownerKey: null }], - }, - { - action: "sendSatoshis", - role: "sender", - secrets: [{ ownerKey: null }], - }, - { - action: "sendFungibleTokens", - role: "sender", - secrets: [{ ownerKey: null }], - }, - { - action: "sendNonfungibleTokens", - role: "sender", - secrets: [{ ownerKey: null }], - }, - { - action: "burnFungibleTokens", - role: "sender", - secrets: [{ ownerKey: null }], - }, - { - action: "burnNonfungibleTokens", - role: "sender", - secrets: [{ ownerKey: null }], - }, - ], - // Indicates how much of received funds should be part of a wallets total balance. - // NOTE: Evaluates in the context of the source transaction, with the current outputs technical data available under this.output.* - // NOTE: A CashASM expression of '1' means include 100% of this asset in the balance. - // NOTE: Since we know that the owner controls all assets, we short-cut the evaluations by setting 1 directly. - balance: { - satoshis: true, - fungibleTokens: true, - nonfungibleTokens: true, - }, + requirements: { + participants: [ + { + role: 'receiver', + slots: { min: 1, max: 1 }, + }, + { + role: 'sender', + slots: { min: 1, max: undefined }, + }, + ], + }, - // Indicate when received funds should be considered in automatic coin selection to meet input requirements of other transactions. - // NOTE: Evaluates in the context of the source transaction, with the current outputs technical data available under this.output.* - // NOTE: This evaluates to a boolean. If non-true, the output should not be considered for automatic coin selection. - // NOTE: Since we know that the secret key exist for the owner, this template short-cuts the evaluation by setting true directly. - selectable: true, + transaction: 'receiveTransaction', }, - }, - }, - }, + requestSatoshis: { + // TODO: Consider rewriting to be generic/role-less. + name: 'Request Satoshis', + description: 'Requests a specific amount of Bitcoin Cash from one or more senders.', + icon: 'request', - // Define a set of scripts that can be used in this template. - scripts: { - lockP2PKH: - "OP_DUP OP_HASH160 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG", - unlockP2PKH: - " ", - lockToRecipient: "", - }, + roles: { + receiver: { + name: 'Request Satoshis', + description: 'Requests a specific amount of Bitcoin Cash from one or more senders.', + icon: 'request', - // TODO: Add icons - constants: { - dustLimit: { - name: "Dust Limit", - description: - "Standard required minimum satoshis for Pay to Public Key Hash outputs.", - type: "integer", - value: 546, - }, + requirements: { + secrets: [ 'ownerKey' ], + variables: [ 'requestedSatoshis' ], + }, + }, + sender: { + name: 'Send', + description: 'Send a specific amount of Bitcoin Cash to the provided receiver.', + icon: 'send', - // Define a message prefix for use in arbitrary message signing. - messagePrefix: { - name: "Message Prefix", - description: - "Standard message prefix used in the bitcoin signed message protocol.", - type: "bytes", - - // Value is enforced to the bitcoin signing magic string: - // "\x18Bitcoin Signed Message:\n" - value: "0x18426974636f696e205369676e6564204d6573736167653a0a", - }, - }, - - // TODO: Add icons - variables: { - // Describe the secret private key. - ownerKey: { - name: "Owners Private Key", - description: - "The private key used to authorize spending of received funds.", - type: "bytes", - hint: "private_key", - }, - - messageToSign: { - name: "Message", - description: "The text message to sign.", - type: "string", - }, - messageToVerify: { - name: "Message", - description: "The text message to verify.", - type: "string", - }, - messageSignature: { - name: "Message Signature", - description: "The signature for the message.", - type: "bytes", - hint: "signature", - }, - - // Describe the parameters used when requesting value. - requestedSatoshis: { - name: "Requested Amount", - description: "The Bitcoin Cash amount requested", - type: "integer", - hint: "satoshis", - }, - requestedTokenCategory: { - name: "Requested Token Category", - description: "The token category requested", - type: "bytes", - hint: "token_category", - }, - requestedTokenAmount: { - name: "Requested Token Amount", - description: "The fungible token amount requested", - type: "integer", - hint: "token_amount", - }, - requestedTokenCapability: { - name: "Requested Token Capability", - description: "The non-fungible token capability requested", - type: "bytes", - hint: "token_capability", - }, - requestedTokenCommitment: { - name: "Requested Token Commitment", - description: "The non-fungible token commitment requested", - type: "bytes", - hint: "token_commitment", - }, - transferredTokenCategory: { - name: "Sending Token Category", - description: "The token category of the token(s) to send", - type: "bytes", - hint: "token_category", - }, - transferredTokenAmount: { - name: "Sending Token Amount", - description: "The fungible token amount to send", - type: "integer", - hint: "token_amount", - }, - transferredTokenCapability: { - name: "Sending Token Capability", - description: "The token capability for the non-fungible token to send", - type: "bytes", - hint: "token_capability", - }, - transferredTokenCommitment: { - name: "Sending Token Commitment", - description: "The token commitment for the non-fungible token to send", - type: "bytes", - hint: "token_commitment", - }, - burnedTokenCategory: { - name: "Deleted Token Category", - description: "The token category of the token(s) to delete", - type: "bytes", - hint: "token_category", - }, - burnedTokenAmount: { - name: "Deleted Token Amount", - description: "The fungible token amount to delete", - type: "integer", - hint: "token_amount", - }, - burnedTokenCapability: { - name: "Deleted Token Capability", - description: "The token capability for the non-fungible token to delete", - type: "bytes", - hint: "token_capability", - }, - burnedTokenCommitment: { - name: "Deleted Token Commitment", - description: "The token commitment for the non-fungible token to delete", - type: "bytes", - hint: "token_commitment", - }, - }, - - // Define a list of re-usable icons that can be used as part of metadata. - // NOTE: the actual icons are not embedded in the template but only referenced by hashes here, - // and can be distributed either as an asset pack or looked up through something like IPFS. - icons: [ - { - name: "wallet", - hash: "0000000000000000000000", - }, - { - name: "owner", - hash: "0000000000000000000000", - }, - { - name: "sender", - hash: "0000000000000000000000", - }, - { - name: "address", - hash: "0000000000000000000000", - }, - { - name: "receive", - hash: "0000000000000000000000", - }, - { - name: "request", - hash: "0000000000000000000000", - }, - { - name: "send", - hash: "0000000000000000000000", - }, - { - name: "burn", - hash: "0000000000000000000000", - }, - { - name: "sign", - hash: "0000000000000000000000", - }, - { - name: "verify", - hash: "0000000000000000000000", - }, - ], - - scenarios: [ - { - name: "requesting satoshis", - description: "happy-path evaluation for requesting satoshis.", - - // The action being run in the scenario. - action: "requestSatoshis", - - // List of roles taken in this scenario, and the resources they provided. - roles: [ - { - role: "receiver", - values: { - generated: { - ownerKey: "KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8", + // The sender only need to provide blockchain-level requirements. + // NOTE: This field is not required when empty, but shown here for illustrative purposes. + requirements: {}, + }, }, - variables: { - requestedSatoshis: 2000, + + requirements: { + participants: [ + { + role: 'receiver', + slots: { min: 1, max: 1 }, + }, + { + role: 'sender', + slots: { min: 1, max: undefined }, + }, + ], }, - secrets: { - // This scenario does not carry any secrets. + + transaction: 'requestSatoshisTransaction', + }, + requestFungibleTokens: { + // TODO: Consider rewriting to be generic/role-less. + name: 'Request Fungible Tokens', + description: 'Requests a specific amount of a fungible tokens from one or more senders.', + icon: 'request', + + roles: { + receiver: { + name: 'Request Fungible Tokens', + description: 'Requests a specific amount of a fungible tokens from one or more senders.', + icon: 'request', + + requirements: { + secrets: [ 'ownerKey' ], + variables: [ 'requestedTokenCategory', 'requestedTokenAmount' ], + }, + }, + sender: { + name: 'Send', + description: 'Send a specific amount of fungible tokens to the provided receiver.', + icon: 'send', + + // The sender only need to provide blockchain-level requirements. + // NOTE: This field is not required when empty, but shown here for illustrative purposes. + requirements: {}, + }, }, + + requirements: { + participants: [ + { + role: 'receiver', + slots: { min: 1, max: 1 }, + }, + { + role: 'sender', + slots: { min: 1, max: undefined }, + }, + ], + }, + + transaction: 'requestFungibleTokensTransaction', + }, + requestNonfungibleTokens: { + // TODO: Consider rewriting to be generic/role-less. + name: 'Request a Non-fungible Token', + description: 'Requests a non-fungible token from one or more senders.', + icon: 'request', + + roles: { + receiver: { + name: 'Request a Non-fungible Token', + description: 'Requests a non-fungible token from one or more senders.', + icon: 'request', + + requirements: { + secrets: [ 'ownerKey' ], + variables: [ 'requestedTokenCategory', 'requestedTokenCapability', 'requestedTokenCommitment' ], + }, + }, + sender: { + name: 'Send', + description: 'Send a non-fungible token to the provided receiver.', + icon: 'send', + + // The sender only need to provide blockchain-level requirements. + // NOTE: This field is not required when empty, but shown here for illustrative purposes. + requirements: {}, + }, + }, + + requirements: { + participants: [ + { + role: 'receiver', + slots: { min: 1, max: 1 }, + }, + { + role: 'sender', + slots: { min: 1, max: undefined }, + }, + ], + }, + + transaction: 'requestNonfungibleTokensTransaction', + }, + + // NOTE: Sending value can be done without explicit template support. + // NOTE: This feature is explicitly defined in this template to demonstrate how versatile templates can be, and + // to ensure the feature is discoverable by the user from outputs that hold value. + sendSatoshis: { + name: 'Send Satoshis', + description: 'Sends a specific amount of Bitcoin Cash to a given recipient.', + icon: 'send', + + roles: { + sender: { + requirements: { + variables: [ 'transferredSatoshis', 'recipientLockingscript' ], + secrets: [ 'ownerKey' ], + }, + }, + }, + + requirements: { + participants: [ + { + role: 'sender', + slots: { min: 1, max: 1 }, + }, + ], + }, + + // Sending is only available for outputs that have sufficient satoshis on them. + // NOTE: Dust is enforced here according to standardness rules. + conditions: [ '$(OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHAN)' ], + + transaction: 'transferSatoshisTransaction', + }, + sendFungibleTokens: { + name: 'Send Fungible Tokens', + description: 'Send a specific amount of a fungible token to a given recipient.', + icon: 'send', + + roles: { + sender: { + requirements: { + variables: [ 'transferredTokenCategory', 'transferredTokenAmount', 'recipientLockingscript' ], + secrets: [ 'ownerKey' ], + }, + }, + }, + + requirements: { + participants: [ + { + role: 'sender', + slots: { min: 1, max: 1 }, + }, + ], + }, + + // Sending is only available for outputs that have fungible tokens on them. + conditions: [ '$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)' ], + + transaction: 'transferFungibleTokensTransaction', + }, + sendNonfungibleTokens: { + name: 'Send a Non-fungible Token', + description: 'Send a non-fungible token to a given recipient.', + icon: 'send', + + roles: { + sender: { + requirements: { + variables: [ 'transferredTokenCategory', 'transferredTokenCapability', 'transferredTokenCommitment', 'recipientLockingscript' ], + secrets: [ 'ownerKey' ], + }, + }, + }, + + requirements: { + participants: [ + { + role: 'sender', + slots: { min: 1, max: 1 }, + }, + ], + }, + + // Sending is only available for outputs that have a non-fungible token on them. + conditions: [ '$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)' ], + + transaction: 'transferNonfungibleTokensTransaction', + }, + + // NOTE: Burning tokens can be done without explicit template support. + // NOTE: This feature is explicitly defined in this template to demonstrate how versatile templates can be, and + // to ensure the feature is discoverable by the user from outputs that hold tokens. + burnFungibleTokens: { + name: 'Delete Fungible Tokens', + description: 'Permanently and irreversibly deletes one or more fungible tokens.', + icon: 'burn', + + roles: { + owner: { + requirements: { + variables: [ 'burnedTokenCategory', 'burnedTokenAmount' ], + secrets: [ 'ownerKey' ], + }, + }, + }, + + requirements: { + participants: [ + { + role: 'owner', + slots: { min: 1, max: 1 }, + }, + ], + }, + + // Burning is only available for outputs that have fungible tokens on them. + conditions: [ '$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)' ], + + transaction: 'burnFungibleTokensTransaction', + }, + burnNonfungibleTokens: { + name: 'Delete a Non-fungible Token', + description: 'Permanently and irreversibly deletes one non-fungible token.', + icon: 'burn', + + roles: { + owner: { + requirements: { + variables: [ 'burnedTokenCategory', 'burnedTokenCapability', 'burnedTokenCommitment' ], + secrets: [ 'ownerKey' ], + }, + }, + }, + + requirements: { + participants: [ + { + role: 'owner', + slots: { min: 1, max: 1 }, + }, + ], + }, + + // Burning is only available for outputs that have non-fungible tokens on them. + conditions: [ '$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)' ], + + transaction: 'burnNonfungibleTokenTransaction', + }, + + sign: { + name: 'Sign Message', + description: 'Signs a provided message using the Bitcoin message signing protocol.', + icon: 'sign', + + roles: { + owner: { + requirements: { + variables: [ 'messageToSign' ], + secrets: [ 'ownerKey' ], + }, + }, + }, + + requirements: { + participants: [ + { + role: 'owner', + slots: { min: 1, max: 1 }, + }, + ], + }, + + data: 'messageSignature', + }, + verify: { + name: 'Verify Message Signature', + description: 'Verifies a provided message signature according to the Bitcoin message signing protocol.', + icon: 'verify', + + roles: { + owner: { + requirements: { + variables: [ 'messageSignature', 'messageToVerify' ], + secrets: [ 'ownerKey' ], + }, + }, + }, + + requirements: { + participants: [ + { + role: 'owner', + slots: { min: 1, max: 1 }, + }, + ], + }, + + data: 'messageSignatureValidity', + }, + }, + + // Define a set of data that can be used in this template. + data: { + messageSignature: { + // Evaluate CashASM expression to get the signature needed. + // NOTE: Pushes the prefix and message, then concatenates them together to form the data to sign. + // NOTE: In libauth today, it seems that this is done by defining the signature as a variable, and tying it to the key and message, + // and so this is different + // TODO: Check with Jason and see if there is any reason why this cannot be done like this. + value: '$( OP_CAT )', + type: 'bytes', + hint: 'signature', + }, + messageSignatureValidity: { + // Evaluate the validity of the message with the owners public key. + value: '$( OP_CAT OP_CHECKDATASIG)', + type: 'integer', + }, + }, + + // Define a set of transactions that can be used in this template. + transactions: { + receiveTransaction: { + name: 'Transfer Completed', + description: 'Transferred an unspecified amount of cash and/or tokens.', + icon: 'request', + + roles: { + receiver: { + name: 'Received', + description: 'Received an unspecified amount of cash and/or tokens.', + icon: 'receive', + }, + sender: { + name: 'Sent', + description: 'Sent an unspecified amount of cash and/or tokens.', + icon: 'send', + }, + }, + + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to + // match the output and thus generate an invitation to participate in this action. + // When the invitation is shared, the other parties can add as many inputs and change outputs as needed. inputs: [], outputs: [ - { - lockingBytecode: - "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac", - valueSatoshis: 2000, - }, + { + output: 'receiveOutput', + outputIndex: undefined, + }, ], - }, + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + requestSatoshisTransaction: { + name: 'Satoshis Transferred', + description: 'Transferred $() satoshis.', + icon: 'request', + + roles: { + receiver: { + name: 'Received', + description: 'Received $() satoshis.', + icon: 'receive', + }, + sender: { + name: 'Sent', + description: 'Sent $() satoshis.', + icon: 'send', + }, + }, + + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to + // match the output and thus generate an invitation to participate in this action. + // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. + inputs: [], + outputs: [ + { + output: 'requestSatoshisOutput', + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + requestFungibleTokensTransaction: { + name: 'Fungible Tokens Transferred', + description: + 'Transferred $( OP_DIV).$( OP_MOD) $() tokens.', + icon: 'request', + + roles: { + receiver: { + name: 'Received', + description: + 'Received $( OP_DIV).$( OP_MOD) $() tokens.', + icon: 'receive', + }, + sender: { + name: 'Sent', + description: + 'Sent $( OP_DIV).$( OP_MOD) $() tokens.', + icon: 'send', + }, + }, + + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to + // match the output and thus generate an invitation to participate in this action. + // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. + inputs: [], + outputs: [ + { + output: 'requestFungibleTokensOutput', + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + requestNonfungibleTokensTransaction: { + name: 'Non-Fungible Token Transferred', + description: + 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: 'request', + + roles: { + receiver: { + name: 'Received', + description: + 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: 'receive', + }, + sender: { + name: 'Sent', + description: + 'Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: 'send', + }, + }, + + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to + // match the output and thus generate an invitation to participate in this action. + // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. + inputs: [], + outputs: [ + { + output: 'requestNonfungibleTokensOutput', + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + + transferSatoshisTransaction: { + name: 'Satoshis Transferred', + description: '$() satoshis were transferred to a recipient.', + icon: 'send', + + roles: { + receiver: { + name: 'Received', + description: 'Received $() satoshis.', + icon: 'receive', + }, + sender: { + name: 'Sent', + description: 'Sent $() satoshis.', + icon: 'send', + }, + }, + + // Enforce the inputs and outputs required by the transaction. + // NOTE: The input is provided from the action since it is only available on outputs with satoshis. + inputs: [], + outputs: [ + { + output: 'transferSatoshisOutput', + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + transferFungibleTokensTransaction: { + name: 'Fungible Tokens Transferred', + description: + '$( OP_DIV).$( OP_MOD) $() tokens were transferred to a recipient.', + icon: 'send', + + roles: { + receiver: { + name: 'Received', + description: + 'Received $( OP_DIV).$( OP_MOD) $() tokens.', + icon: 'receive', + }, + sender: { + name: 'Sent', + description: + 'Sent $( OP_DIV).$( OP_MOD) $() tokens.', + icon: 'send', + }, + }, + + // Enforce the inputs and outputs required by the transaction. + // NOTE: The input is provided from the action since it is only available on outputs with fungible tokens. + inputs: [], + outputs: [ + { + output: 'transferFungibleTokensOutput', + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + transferNonfungibleTokensTransaction: { + name: 'Non-fungible Token Transferred', + description: + 'One non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token was transferred to a recipient, with $() commitment.', + icon: 'send', + + roles: { + receiver: { + name: 'Received', + description: + 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: 'receive', + }, + sender: { + name: 'Sent', + description: + 'Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: 'send', + }, + }, + + // Enforce the inputs and outputs required by the transaction. + // NOTE: The input is provided from the action since it is only available on outputs with a non-fungible token. + inputs: [], + outputs: [ + { + output: 'transferNonfungibleTokenOutput', + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + + burnFungibleTokensTransaction: { + name: 'Deleted fungible tokens', + description: + 'Permanently and irreversibly deleted $( OP_DIV).$( OP_MOD) $() tokens.', + icon: 'burn', + + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no defined outputs as any non-burned value is automatically returned as change. + inputs: [ + { + input: 'burnFungibleTokensInput', + inputIndex: undefined, + }, + ], + + outputs: [], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + burnNonfungibleTokenTransaction: { + name: 'Deleted non fungible token', + description: + 'Permanently and irreversibly deleted a non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: 'burn', + + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no defined outputs as any non-burned value is automatically returned as change. + inputs: [ + { + input: 'burnNonfungibleTokenInput', + inputIndex: undefined, + }, + ], + outputs: [], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + }, + + // Define a set of outputs that can be used within transactions in this template. + outputs: { + changeOutput: { + name: 'Change', + description: 'Funds returned as change.', + icon: 'receive', + + // Defines how the requested funds should be locked. + lockingScript: 'receivingLockingScript', + }, + receiveOutput: { + name: 'Recipient output', + description: 'Transferred an unspecified amount of cash and/or tokens to a recipient.', + icon: 'receive', + + roles: { + receiver: { + name: 'Received', + description: 'Received an unspecified amount of cash and/or tokens.', + }, + sender: { + name: 'Sent', + description: 'Sent an unspecified amount of cash and/or tokens.', + }, + }, + + // Defines how the requested funds should be locked. + lockingScript: 'receivingLockingScript', + + // Set the amount of cash and/or tokens to transfer. + valueSatoshis: '$()', + token: null, + }, + requestSatoshisOutput: { + name: 'Satoshis', + description: '$() satoshis.', + icon: 'request', + + roles: { + receiver: { + name: 'Satoshis Received', + description: 'Received $() satoshis.', + }, + sender: { + name: 'Satoshis Sent', + description: 'Sent $() satoshis.', + }, + }, + + // Defines how the requested funds should be locked. + lockingScript: 'receivingLockingScript', + + // Require the specified number of satoshis and no tokens. + valueSatoshis: '$()', + token: null, + }, + requestFungibleTokensOutput: { + name: 'Fungible $() Tokens', + description: + '$( OP_DIV).$( OP_MOD) $() tokens.', + icon: 'request', + + roles: { + receiver: { + name: 'Fungible $() Tokens Received', + description: + 'Received $( OP_DIV).$( OP_MOD) $() tokens.', + }, + sender: { + name: 'Fungible $() Tokens Sent', + description: + 'Sent $( OP_DIV).$( OP_MOD) $() tokens.', + }, + }, + + // Defines how the requested funds should be locked. + lockingScript: 'receivingLockingScript', + + // Require a flat 1000 satoshis to ensure the fungible tokens remains transferrable. + valueSatoshis: '1000', + + // Require only the specified amount and type of fungible tokens. + // NOTE: This can be composed with a request for a non-fungible token, but will always result in two separate outputs. + token: { + category: '$()', + amount: '$()', + nft: null, + }, + }, + requestNonfungibleTokensOutput: { + name: 'Non-fungible $() Token', + description: + 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.', + icon: 'request', + + roles: { + receiver: { + name: 'Non-fungible $() Token Received', + description: + 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', + }, + sender: { + name: 'Non-fungible $() Token Sent', + description: + 'Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', + }, + }, + + // Defines how the requested funds should be locked. + lockingScript: 'receivingLockingScript', + + // Require a flat 1000 satoshis to ensure the non-fungible token remains transferrable. + valueSatoshis: '1000', + + // Require only an NFT with specified category, capability and commitment. + // NOTE: This can be composed with a request for fungible token amounts, but will always result in two separate outputs. + token: { + category: '$()', + amount: null, + nft: { + capability: '$()', + commitment: '$()', + }, + }, + }, + + transferSatoshisOutput: { + name: 'Recipient output', + description: 'Transferred $() satoshis to a recipient.', + icon: 'send', + + roles: { + receiver: { + name: 'Received', + description: 'Received $() satoshis.', + }, + sender: { + name: 'Sent', + description: 'Sent $() satoshis.', + }, + }, + + // Use the recipients lockscript. + lockingScript: 'sendingLockingscript', + + // Set the amount of satoshis to transfer. + valueSatoshis: '$()', + }, + transferFungibleTokensOutput: { + name: 'Recipient output', + description: + 'Transferred $( OP_DIV).$( OP_MOD) $() tokens to a recipient.', + icon: 'send', + + roles: { + receiver: { + name: 'Received', + description: + 'Received $( OP_DIV).$( OP_MOD) $() tokens.', + }, + sender: { + name: 'Sent', + description: + 'Sent $( OP_DIV).$( OP_MOD) $() tokens.', + }, + }, + + // Use the recipients lockscript. + lockingScript: 'sendingLockingscript', + + // Set the amount of fungible tokens to transfer. + token: { + category: '$()', + amount: '$()', + }, + }, + transferNonfungibleTokenOutput: { + name: 'Recipient output', + description: + 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.', + icon: 'send', + + roles: { + receiver: { + name: 'Received', + description: + 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', + }, + sender: { + name: 'Sent', + description: + 'Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', + }, + }, + + // Use the recipients lockscript. + lockingScript: 'sendingLockingscript', + + // Set the non-fungible token to transfer. + token: { + category: '$()', + nft: { + capability: '$()', + commitment: '$()', + }, + }, + }, + }, + + inputs: { + burnFungibleTokensInput: { + name: 'Deleted fungible tokens', + description: 'Permanently and irreversibly deleted $() $().', + icon: 'burn', + + // Define which unlocking script unlocks this input. + unlockingScript: 'unlockP2PKH', + + // Require a fungible token of the requested token category, with an amount larger than or equal to the requested amount to burn. + token: { + category: '$()', + amount: '$( OP_GREATERTHANOREQUAL OP_IF OP_ENDIF)', + }, + + // Ignore the burned token amount when determining change for this output. + // NOTE: The engine must check that this does not exceed the input value. + omitChangeAmounts: { + fungibleTokens: '${}', + }, + }, + burnNonfungibleTokenInput: { + name: 'Deleted non-fungible token', + description: + 'Permanently and irreversibly burned one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) token of category $(), with a $() commitment.', + icon: 'burn', + + // Define which unlocking script unlocks this input. + unlockingScript: 'unlockP2PKH', + + // Require a non-fungible token of the specified category, capability and commitment to burn. + token: { + category: '$()', + nft: { + capability: '$()', + commitment: '$()', + }, + }, + + // Ignore the burned token when determining change for this output. + // NOTE: The engine must check that this does not exceed the input value. + omitChangeAmounts: { + nonfungibleTokens: 1, + }, + }, + }, + + // Define locking scripts used by this template. + // NOTE: Template supported wallets should automatically track all generated lockscripts for on-chain events. + lockingScripts: { + sendingLockingscript: { + // TODO: This currently describes outputs locked to this script, but all actions creating such outputs already have descriptions. + // This can either be dropped, or maybe more importantly, should be disambiguated so that lockscripts can provide separate + // descriptions for their script and the outputs that are locked to them. Leaving as a TODO for now and will address later. + name: 'Sent', + description: 'Funds sent to an external recipient', + icon: 'address', + + // ... + lockingType: 'p2pkh', + lockingBytecode: 'lockToRecipient', + + // Indicate that the sent output does not belong to the initiating user. + // NOTE: These values default to false/empty, but added here for additional clarity. + actions: [], + state: { variables: [], secrets: [] }, + balance: {}, + selectable: false, + }, + receivingLockingScript: { + // NOTE: Outputs to this lockscript by external actors defaults to this description when detected on-chain. + name: 'Received', + description: 'Funds received without wallet coordination.', + icon: 'address', + + // Defines how spending future received funds should be locked. + lockingType: 'p2pkh', + lockingBytecode: 'lockP2PKH', + + // Define a default unlocking script to be used when no action provided script is present. + unlockingBytecode: 'unlockP2PKH', + + // Participants without a role or observers cannot take any further actions. + // NOTE: This is not required but shown here for illustrative purposes. + actions: [], + + roles: { + receiver: { + // The only state that is required to be persisted when receiving funds is the owners private key. + // NOTE: This is defined as a secret to not leak when creating invitations for others to participate in request or send actions. + state: { + variables: [], + secrets: [ 'ownerKey' ], + }, + + // List actions that can be taken with the ownerKey for each address/lockscript. + actions: [ + { + action: 'sign', + role: 'owner', + secrets: [{ ownerKey: null }], + }, + { + action: 'verify', + role: 'owner', + secrets: [{ ownerKey: null }], + }, + { + action: 'sendSatoshis', + role: 'sender', + secrets: [{ ownerKey: null }], + }, + { + action: 'sendFungibleTokens', + role: 'sender', + secrets: [{ ownerKey: null }], + }, + { + action: 'sendNonfungibleTokens', + role: 'sender', + secrets: [{ ownerKey: null }], + }, + { + action: 'burnFungibleTokens', + role: 'sender', + secrets: [{ ownerKey: null }], + }, + { + action: 'burnNonfungibleTokens', + role: 'sender', + secrets: [{ ownerKey: null }], + }, + ], + + // Indicates how much of received funds should be part of a wallets total balance. + // NOTE: Evaluates in the context of the source transaction, with the current outputs technical data available under this.output.* + // NOTE: A CashASM expression of '1' means include 100% of this asset in the balance. + // NOTE: Since we know that the owner controls all assets, we short-cut the evaluations by setting 1 directly. + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + + // Indicate when received funds should be considered in automatic coin selection to meet input requirements of other transactions. + // NOTE: Evaluates in the context of the source transaction, with the current outputs technical data available under this.output.* + // NOTE: This evaluates to a boolean. If non-true, the output should not be considered for automatic coin selection. + // NOTE: Since we know that the secret key exist for the owner, this template short-cuts the evaluation by setting true directly. + selectable: true, + }, + }, + }, + }, + + // Define a set of scripts that can be used in this template. + scripts: { + lockP2PKH: 'OP_DUP OP_HASH160 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG', + unlockP2PKH: ' ', + lockToRecipient: '', + }, + + // TODO: Add icons + constants: { + dustLimit: { + name: 'Dust Limit', + description: 'Standard required minimum satoshis for Pay to Public Key Hash outputs.', + type: 'integer', + value: 546, + }, + + // Define a message prefix for use in arbitrary message signing. + messagePrefix: { + name: 'Message Prefix', + description: 'Standard message prefix used in the bitcoin signed message protocol.', + type: 'bytes', + + // Value is enforced to the bitcoin signing magic string: + // "\x18Bitcoin Signed Message:\n" + value: '0x18426974636f696e205369676e6564204d6573736167653a0a', + }, + }, + + // TODO: Add icons + variables: { + // Describe the secret private key. + ownerKey: { + name: 'Owners Private Key', + description: 'The private key used to authorize spending of received funds.', + type: 'bytes', + hint: 'private_key', + }, + + messageToSign: { + name: 'Message', + description: 'The text message to sign.', + type: 'string', + }, + messageToVerify: { + name: 'Message', + description: 'The text message to verify.', + type: 'string', + }, + messageSignature: { + name: 'Message Signature', + description: 'The signature for the message.', + type: 'bytes', + hint: 'signature', + }, + + // Describe the parameters used when requesting value. + requestedSatoshis: { + name: 'Requested Amount', + description: 'The Bitcoin Cash amount requested', + type: 'integer', + hint: 'satoshis', + }, + transferredSatoshis: { + name: 'Transferred Amount', + description: 'The Bitcoin Cash amount transferred', + type: 'integer', + hint: 'satoshis', + }, + requestedTokenCategory: { + name: 'Requested Token Category', + description: 'The token category requested', + type: 'bytes', + hint: 'token_category', + }, + requestedTokenAmount: { + name: 'Requested Token Amount', + description: 'The fungible token amount requested', + type: 'integer', + hint: 'token_amount', + }, + requestedTokenCapability: { + name: 'Requested Token Capability', + description: 'The non-fungible token capability requested', + type: 'bytes', + hint: 'token_capability', + }, + requestedTokenCommitment: { + name: 'Requested Token Commitment', + description: 'The non-fungible token commitment requested', + type: 'bytes', + hint: 'token_commitment', + }, + transferredTokenCategory: { + name: 'Sending Token Category', + description: 'The token category of the token(s) to send', + type: 'bytes', + hint: 'token_category', + }, + transferredTokenAmount: { + name: 'Sending Token Amount', + description: 'The fungible token amount to send', + type: 'integer', + hint: 'token_amount', + }, + transferredTokenCapability: { + name: 'Sending Token Capability', + description: 'The token capability for the non-fungible token to send', + type: 'bytes', + hint: 'token_capability', + }, + transferredTokenCommitment: { + name: 'Sending Token Commitment', + description: 'The token commitment for the non-fungible token to send', + type: 'bytes', + hint: 'token_commitment', + }, + burnedTokenCategory: { + name: 'Deleted Token Category', + description: 'The token category of the token(s) to delete', + type: 'bytes', + hint: 'token_category', + }, + burnedTokenAmount: { + name: 'Deleted Token Amount', + description: 'The fungible token amount to delete', + type: 'integer', + hint: 'token_amount', + }, + burnedTokenCapability: { + name: 'Deleted Token Capability', + description: 'The token capability for the non-fungible token to delete', + type: 'bytes', + hint: 'token_capability', + }, + burnedTokenCommitment: { + name: 'Deleted Token Commitment', + description: 'The token commitment for the non-fungible token to delete', + type: 'bytes', + hint: 'token_commitment', + }, + }, + + // Define a list of re-usable icons that can be used as part of metadata. + // NOTE: the actual icons are not embedded in the template but only referenced by hashes here, + // and can be distributed either as an asset pack or looked up through something like IPFS. + icons: [ + { + name: 'wallet', + hash: '0000000000000000000000', }, { - role: "sender", - values: { - // The sender provides no raw data to the action. - generated: {}, - variables: {}, - secrets: {}, + name: 'owner', + hash: '0000000000000000000000', + }, + { + name: 'sender', + hash: '0000000000000000000000', + }, + { + name: 'address', + hash: '0000000000000000000000', + }, + { + name: 'receive', + hash: '0000000000000000000000', + }, + { + name: 'request', + hash: '0000000000000000000000', + }, + { + name: 'send', + hash: '0000000000000000000000', + }, + { + name: 'burn', + hash: '0000000000000000000000', + }, + { + name: 'sign', + hash: '0000000000000000000000', + }, + { + name: 'verify', + hash: '0000000000000000000000', + }, + ], - // The sender does provide input and change. - inputs: [ - { - outpointTransactionHash: - "4ef28553a31a266719e66ba97fee3aeecd6d1788f7ff6ab12f8ebceda49660c0", - outpointIndex: 0, - sequenceNumber: 0, - unlockingBytecode: - "41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696", - }, + scenarios: [ + { + name: 'requesting satoshis', + description: 'happy-path evaluation for requesting satoshis.', + + // The action being run in the scenario. + action: 'requestSatoshis', + + // List of roles taken in this scenario, and the resources they provided. + roles: [ + { + role: 'receiver', + values: { + generated: { + ownerKey: 'KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8', + }, + variables: { + requestedSatoshis: 2000, + }, + secrets: { + // This scenario does not carry any secrets. + }, + inputs: [], + outputs: [ + { + lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac', + valueSatoshis: 2000, + }, + ], + }, + }, + { + role: 'sender', + values: { + // The sender provides no raw data to the action. + generated: {}, + variables: {}, + secrets: {}, + + // The sender does provide input and change. + inputs: [ + { + outpointTransactionHash: '4ef28553a31a266719e66ba97fee3aeecd6d1788f7ff6ab12f8ebceda49660c0', + outpointIndex: 0, + sequenceNumber: 0, + unlockingBytecode: + '41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696', + }, + ], + outputs: [ + { + lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac', + valueSatoshis: 2000, + }, + ], + }, + }, ], - outputs: [ - { - lockingBytecode: - "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac", - valueSatoshis: 2000, - }, - ], - }, - }, - ], - // List of resources provided outside the context of a role. - values: { - generated: { - // This scenario does not have any non-role generated values. - }, - variables: { - // This scenario does not have any non-role variables. - }, - secrets: { - // This scenario does not have any non-role secrets. - }, - }, + // List of resources provided outside the context of a role. + values: { + generated: { + // This scenario does not have any non-role generated values. + }, + variables: { + // This scenario does not have any non-role variables. + }, + secrets: { + // This scenario does not have any non-role secrets. + }, + }, - // Outcomes provides the set of resulting values created from the action. - outcome: { - roles: { - receiver: { - name: "Request", - description: - "Requested a specific amount of satoshis from one or more senders.", - icon: "request", - }, - sender: { - name: "Send", - description: - "Sent a specific amount of satoshis to the provided receiver.", - icon: "send", - }, - }, + // Outcomes provides the set of resulting values created from the action. + outcome: { + roles: { + receiver: { + name: 'Request', + description: 'Requested a specific amount of satoshis from one or more senders.', + icon: 'request', + }, + sender: { + name: 'Send', + description: 'Sent a specific amount of satoshis to the provided receiver.', + icon: 'send', + }, + }, - transactions: [ - { - transaction: - "0200000001c06096a4edbc8e2fb16afff788176dcdee3aee7fa96be61967261aa35385f24e000000006441226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696000000000267530300000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188acd0070000000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188ac00000000", - value: "", - }, - ], - }, - }, - ], + transactions: [ + { + transaction: + '0200000001c06096a4edbc8e2fb16afff788176dcdee3aee7fa96be61967261aa35385f24e000000006441226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696000000000267530300000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188acd0070000000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188ac00000000', + value: '', + }, + ], + }, + }, + ], }; -export const p2pkhTemplateIdentifier = generateTemplateIdentifier( - parseTemplate(p2pkhTemplate), -); +export const p2pkhTemplateIdentifier = generateTemplateIdentifier(parseTemplate(p2pkhTemplate)); diff --git a/tests/utils/load-template-from-file.test.ts b/tests/utils/load-template-from-file.test.ts index 69756ae..ba6397f 100644 --- a/tests/utils/load-template-from-file.test.ts +++ b/tests/utils/load-template-from-file.test.ts @@ -37,7 +37,7 @@ describe("loadTemplateFromFile", () => { test("loads TypeScript templates via child process", async () => { const tsTemplatePath = path.resolve( process.cwd(), - "../templates/source/p2pkh.ts", + "./tests/cli/mocks/template-p2pkh.ts", ); expect(existsSync(tsTemplatePath)).toBe(true); diff --git a/tests/utils/sync-server.test.ts b/tests/utils/sync-server.test.ts index c89e9a5..c394da9 100644 --- a/tests/utils/sync-server.test.ts +++ b/tests/utils/sync-server.test.ts @@ -1,5 +1,5 @@ import { binToHex } from "@bitauth/libauth"; -import { serializeInvitation } from "@xo-cash/engine"; +import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; import type { XOInvitation, XOInvitationCommit } from "@xo-cash/types"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -135,7 +135,7 @@ describe("SyncServer v2 adapter", () => { invitationIdentifier, new Uint8Array(32).fill(1), ); - const messages: Array<{ event?: string; data: string }> = []; + const messages: Array<{ event?: string; data: XOInvitation }> = []; sync.on("message", (message) => messages.push(message)); client.onMessage?.( @@ -149,6 +149,6 @@ describe("SyncServer v2 adapter", () => { expect(messages).toHaveLength(1); expect(messages[0]?.event).toBe("invitation-updated"); - expect(messages[0]?.data).toBe(serializeInvitation(updated)); + expect(messages[0]?.data).toEqual(deserializeInvitation(serializeInvitation(updated))); }); });