Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"sortTailwindcss": true,
"ignorePatterns": [
"lwk/**",
"**/__fixtures__/**",
"AGENTS.md",
"CLAUDE.md",
"README.md",
Expand Down
15 changes: 15 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
"validate:i18n": "i18n-check -l apps/extension/src/localization/resources -s en -f i18next",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"check": "bun run typecheck && bun run lint && bun run format:check"
"test": "bun test",
"test:watch": "bun test --watch",
"typecheck:packages": "tsc -p packages/tsconfig.json --noEmit",
"check": "bun run typecheck && bun run typecheck:packages && bun run lint && bun run format:check && bun run test"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
Expand Down Expand Up @@ -93,6 +96,7 @@
"@stylistic/eslint-plugin-jsx": "^3.0.1",
"@stylistic/eslint-plugin-ts": "^3.0.1",
"@tailwindcss/postcss": "^4.3.0",
"@types/bun": "^1.3.14",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
Expand All @@ -101,6 +105,7 @@
"@typescript-eslint/parser": "^8.23.0",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"bun-types": "^1.3.14",
"chalk": "^5.6.2",
"cross-env": "^10.1.0",
"dotenv": "^17.4.2",
Expand Down
5 changes: 5 additions & 0 deletions packages/bun-test-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="bun-types" />

// Makes `bun:test` resolvable to `tsc`, which the test files import from. `@types/bun`
// re-exports `bun-types` and is supposed to be picked up automatically, but is not under
// this project's configuration.
8 changes: 8 additions & 0 deletions packages/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "../node_modules/.cache/tsbuildinfo.packages.json",
"paths": {}
},
"include": ["."]
}
15 changes: 15 additions & 0 deletions packages/tx-manifest/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@humid/tx-manifest",
"version": "1.0.0",
"private": true,
"description": "Reads the request a site sends to perform one action of a txManifest protocol, and works out which parts of that request the chosen action requires. Wallet-agnostic: it holds no keys, reaches no network and remembers nothing between calls.",
"type": "module",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./fixtures/*": "./src/__fixtures__/*"
},
"dependencies": {
"zod": "^4.0.0"
}
}
133 changes: 133 additions & 0 deletions packages/tx-manifest/src/__fixtures__/p2pk.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
{
"manifest_version": "0.1.0",
"attestation_version": "1",
"protocol": "p2pk-simplicity",
"description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.",
"chain": "liquid",
"utxo_types": {
"p2pk_output": {
"description": "A Liquid UTXO locked to PUBKEY via the compiled p2pk.simf program.",
"script": {
"type": "simplicity",
"source": "./p2pk.simf"
},
"asset": "lbtc",
"confidential": false
}
},
"actions": {
"Pay": {
"description": "Lock funds into a p2pk output that only PUBKEY's owner can spend.",
"params": {
"pubkey": {
"type": "pubkey",
"description": "The x-only public key that will be able to spend this output (the recipient)."
},
"amount_sat": {
"type": "u64",
"description": "Amount in satoshis to lock in the output."
}
},
"inputs": [
{
"id": "funding_input",
"description": "Wallet UTXO providing the funds.",
"utxo_source": "wallet",
"asset": "lbtc",
"amount_sat": {
"min_amount": "params.amount_sat"
}
}
],
"outputs": [
{
"id": "p2pk_out",
"description": "The funded p2pk output, locked to PUBKEY.",
"destination": {
"utxo_type": "p2pk_output",
"compile_params": {
"PUB_KEY": "params.pubkey"
}
},
"amount_sat": "params.amount_sat",
"asset": "lbtc"
},
{
"id": "change_out",
"description": "Change returned to the funding wallet.",
"destination": "change",
"asset": "lbtc",
"optional": true
}
],
"validations": [
{
"id": "amount_nonzero",
"rule": {
"type": "arithmetic",
"expr": "params.amount_sat > 0"
},
"error": {
"code": "INVALID_AMOUNT",
"message": "Amount must be greater than zero"
}
}
]
},
"Receive": {
"description": "Spend a p2pk output back into your wallet. Requires a BIP340 signature from the pubkey the output was locked to.",
"params": {
"pubkey": {
"type": "pubkey",
"description": "The x-only public key the output was locked to in Pay. Must be one of your own wallet's keys so the wallet can sign the spend."
}
},
"inputs": [
{
"id": "p2pk_in",
"description": "The p2pk covenant UTXO to spend, located via the state file by its utxo_type.",
"utxo_source": {
"utxo_type": "p2pk_output",
"compile_params": {
"PUB_KEY": "params.pubkey"
}
},
"witnesses": {
"SIGNATURE": {
"type": "Signature",
"sig_type": "sig_hash_all",
"source": {
"type": "wallet",
"key": "params.pubkey"
},
"description": "BIP340 Schnorr signature over the whole transaction, from the recipient key."
}
}
},
{
"id": "fee_input",
"description": "Wallet L-BTC UTXO to pay the network fee.",
"utxo_source": "wallet",
"asset": "lbtc",
"optional": true
}
],
"outputs": [
{
"id": "received_out",
"description": "The reclaimed funds, sent to your wallet.",
"destination": "wallet",
"asset": "lbtc",
"amount_sat": "p2pk_in.amount_sat"
},
{
"id": "fee_change",
"description": "L-BTC change from the fee input.",
"destination": "change",
"asset": "lbtc",
"optional": true
}
]
}
}
}
19 changes: 19 additions & 0 deletions packages/tx-manifest/src/document/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* The three shape tests every part of the runtime needs on a document it did not write.
*
* A manifest arrives as parsed JSON and nothing about its interior is guaranteed, so
* reading it is a sequence of "is this the shape I think it is" questions. These were
* redefined in four files before the runtime core existed; they live here once.
*/

export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function asRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}

export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
17 changes: 17 additions & 0 deletions packages/tx-manifest/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Reads the request a site sends to perform one action of a txManifest protocol.
*
* What is here holds no keys, opens no network connection of its own and remembers nothing
* between calls: a wallet supplies the chain reads and the signing, and the same request twice
* is answered the same way. That is what makes it a package rather than part of one wallet.
*
* This surface is what a wallet needs and nothing else. A module absent from here is private
* even though its directory is not hidden — the way to make one public is to add it,
* deliberately, when something outside actually needs it.
*/

// What the site sent, checked into a shape the rest can rely on. What a particular action
// then needs from that request is worked out inside the package rather than answered here:
// a caller holding the answer has nothing to do with it until there is something to build.
export type { ParsedLiquidProcessCtParams } from "./request/request";
export { parseLiquidProcessCtParams } from "./request/validation";
58 changes: 58 additions & 0 deletions packages/tx-manifest/src/request/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* The request a site sends to perform one action of a txManifest protocol.
*
* Six parts, per the accepted request contract: the manifest, the sources of the
* contracts it references, the chosen action and its filled parameters, and the two
* mutable protocol files the site holds — the instance file (this deployment's field
* values) and the state file (its live covenant UTXO set).
*
* The last two are optional at the wire level because not every manifest needs them —
* a protocol with no covenant parameters has no instance file, and an action that
* creates rather than spends has nothing to read from state. Whether a *specific*
* action can proceed without them is a different question, answered by inspecting what
* that action actually references rather than by the shape of the request.
*
* The fee is deliberately absent. The wallet establishes the fee and the fee rate; a
* fee-like value arriving here would be ignored rather than honoured.
*/
export type ParsedLiquidProcessCtParams = {
/** The chosen action's name, as it appears in the manifest. */
action: string;
/** Whether to broadcast the finished transaction or return it unsent. */
broadcast: boolean;
/**
* Source text of every contract the manifest references, keyed by the path the
* manifest uses. Sources are not published with a manifest; they arrive here.
*/
contractSources: Record<string, string>;
/** This deployment's field values, when the protocol has any. */
instance?: Record<string, unknown>;
/** The txManifest document itself. */
manifest: Record<string, unknown>;
/** The filled parameters of the chosen action. */
params: Record<string, unknown>;
/** The deployment's live covenant UTXO set, when the action reads one. */
state?: Record<string, unknown>;
};

/** One request part, named the way a refusal message names it. */
export type RequestPart = "contractSources" | "instance" | "params" | "state";

/**
* What a specific action needs from the request, and what of that is absent.
*
* `missing` is what makes a refusal answerable: it names the part and, where the part
* is a map, the exact keys that were referenced and not supplied.
*/
export type ActionRequirements = {
missing: MissingPart[];
required: RequestPart[];
};

export type MissingPart = {
/** Which keys were referenced and not supplied, when the part is a map. */
keys?: string[];
part: RequestPart;
/** Why the action needs it, in the manifest's own terms. */
reason: string;
};
Loading