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
2 changes: 1 addition & 1 deletion packages/tx-manifest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"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.",
"description": "Reads the request a site sends to perform one action of a txManifest protocol, and establishes what the wallet knows about that action before anyone approves it: the covenants it touches, rebuilt and compared against what the chain says, or a refusal naming what could not be established. Wallet-agnostic: it holds no keys, reaches no network and remembers nothing between calls.",
"type": "module",
"types": "./src/index.ts",
"exports": {
Expand Down
10 changes: 10 additions & 0 deletions packages/tx-manifest/src/__fixtures__/p2pk.simf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* PAY TO PUBLIC KEY
*
* The coins move if the person with the given public key signs the transaction.
*
* https://docs.ivylang.org/bitcoin/language/ExampleContracts.html#lockwithpublickey
*/
fn main() {
jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE)
}
22 changes: 22 additions & 0 deletions packages/tx-manifest/src/chain/chainRead.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Reading what the chain says sits at an outpoint.
*
* This is a port rather than a reader: what is here is the shape of the answer and the shape
* of the asking, and the wallet supplies the thing that actually asks. The package holds no
* endpoint and opens no connection of its own — that is what keeps the same request answered
* the same way twice, and what lets every check below be exercised without a network.
*
* It exists at all because no component a wallet ships can answer the question. A wallet's
* UTXO snapshot only ever contains outputs the wallet owns, and a covenant output belongs to
* a contract rather than to anyone. What is read is public chain data: no key, no descriptor,
* no wallet state.
*/

export type OutPoint = { txid: string; vout: number };

export type TxOutAtOutPoint = {
/** The output's scriptPubKey in hex — the locking condition itself. */
scriptPubKeyHex: string;
};

export type ReadTxOut = (outpoint: OutPoint) => Promise<TxOutAtOutPoint>;
102 changes: 102 additions & 0 deletions packages/tx-manifest/src/covenants/compileParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { ParsedLiquidProcessCtParams } from "../request/request";

/**
* A contract's compile-time parameters, in SimplicityHL's own argument JSON shape.
*
* Kept as the compiler's format rather than a shape of our own so the value that reaches
* compilation is the value the compiler documents, with nothing translating in between.
*/
export type ContractArguments = Record<string, { type: string; value: string }>;

export type ResolveCompileParamsResult =
| { arguments: ContractArguments; ok: true }
| { ok: false; reason: string };

/**
* The manifest's declared parameter types, mapped to the compiler's.
*
* Deliberately a closed list: a type nobody has mapped is refused rather than passed
* through, because a wrong type here produces a valid-looking wrong address rather than
* an error. The corpus's remaining types — the integer widths, `bytes32`,
* `liquid.asset_id` and `address` — arrive with the slices that need them.
*/
const PARAM_TYPES: Record<string, string> = {
pubkey: "Pubkey",
};

/**
* Resolves the compile-time parameters a contract is built with, from the manifest's
* wiring and the parameters the request filled.
*
* The wiring lives in `compile_params`, a map of the contract's parameter name to a
* reference — `{"PUB_KEY": "params.pubkey"}`. Note the collision the format carries:
* `compile_params` is both this wiring map and a deprecated namespace prefix for
* references. This reads the wiring; the namespace is a later slice's problem.
*
* Scope: resolves `params.` references only. Instance references, computed values and
* formulas belong to the slices that own them, and are refused here rather than silently
* mishandled.
*
* Everything it cannot resolve refuses rather than resolving to something plausible. That
* strictness is the point — these values participate in the address, so a wrong one produces
* a well-formed address for the wrong contract instead of an error.
*/
export function resolveCompileParams(
request: ParsedLiquidProcessCtParams,
wiring: Record<string, unknown>,
declaredTypes: Record<string, string>,
): ResolveCompileParamsResult {
const resolved: ContractArguments = {};

for (const [name, reference] of Object.entries(wiring)) {
if (typeof reference !== "string") {
return { ok: false, reason: `Compile parameter ${name} is not a reference.` };
}

const paramName = referencedParam(reference);

if (!paramName) {
return {
ok: false,
reason: `Compile parameter ${name} references ${reference}, which this runtime does not resolve yet.`,
};
}

const value = request.params[paramName];

if (typeof value !== "string") {
return {
ok: false,
reason: `Compile parameter ${name} needs parameter ${paramName}, which the request did not supply as a value.`,
};
}

const declaredType = declaredTypes[paramName];
const compilerType = declaredType ? PARAM_TYPES[declaredType] : undefined;

if (!compilerType) {
return {
ok: false,
reason: `Parameter ${paramName} is declared as ${declaredType ?? "an unstated type"}, which this runtime does not encode yet.`,
};
}

resolved[name] = { type: compilerType, value: withHexPrefix(value) };
}

return { arguments: resolved, ok: true };
}

/**
* The action parameter a reference points at, or undefined when it points elsewhere.
*
* Accepts the `$`-prefixed spelling alongside the bare one: the corpus carries both, and
* `lending` uses one where `lending_v2` uses the other.
*/
function referencedParam(reference: string): string | undefined {
return /^\$?params\.(?<name>[A-Za-z0-9_]+)$/.exec(reference)?.groups?.name;
}

function withHexPrefix(value: string): string {
return value.startsWith("0x") ? value : `0x${value}`;
}
140 changes: 140 additions & 0 deletions packages/tx-manifest/src/covenants/covenant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { asRecord } from "../document/json";
import type { ParsedLiquidProcessCtParams } from "../request/request";
import { resolveCompileParams } from "./compileParams";

/**
* What one compile yields: where the covenant is, in both forms a wallet needs.
*
* Both come from the same compiled contract rather than from two calls, because they are two
* spellings of one fact. Deriving them separately is how an output comes to be paid to a
* bech32 string — what pays a covenant is a scriptPubKey, and an address is not hex.
*/
export type CompiledCovenant = {
/** What a person is shown. */
address: string;
/** What an output actually pays to, and what the chain is compared against. */
scriptPubKeyHex: string;
};

/**
* Compiles a covenant and reports where it lands.
*
* The caller supplies the compile step, so this can be exercised without a wasm module and so
* the module's lifecycle stays where it belongs — with the wallet, not with this package.
*/
export type CompileCovenant = (input: {
argumentsJson: string;
network: string;
source: string;
}) => Promise<CompiledCovenant> | CompiledCovenant;

export type CovenantDerivation = {
/** The address the wallet derived by rebuilding the contract itself. */
address: string;
/** The same covenant as an output pays it, from the same compile. */
scriptPubKeyHex: string;
/** The manifest's name for the kind of UTXO this is. */
utxoType: string;
};

export type DeriveCovenantResult =
| { derivation: CovenantDerivation; ok: true }
| { ok: false; reason: string };

/**
* Derives one covenant UTXO type, from the contract source the request supplied and the
* parameters the manifest wires into it.
*
* This is the wallet establishing a fact for itself. Nothing the site says about where the
* funds are is consulted; the site's contribution is the source text and the parameter
* values, and both change what is derived rather than what it is checked against.
*/
export async function deriveCovenantAddress(
request: ParsedLiquidProcessCtParams,
input: {
compile: CompileCovenant;
declaredTypes: Record<string, string>;
network: string;
utxoType: string;
wiring: Record<string, unknown>;
},
): Promise<DeriveCovenantResult> {
const declared = asRecord(asRecord(request.manifest.utxo_types)?.[input.utxoType]);

if (!declared) {
return { ok: false, reason: `The manifest declares no utxo type named "${input.utxoType}".` };
}

const sourcePath = asRecord(declared.script)?.source;

if (typeof sourcePath !== "string") {
return { ok: false, reason: `Utxo type "${input.utxoType}" names no contract source.` };
}

const source = request.contractSources[sourcePath];

if (source === undefined) {
return { ok: false, reason: `The source of ${sourcePath} was not supplied.` };
}

const params = resolveCompileParams(request, input.wiring, input.declaredTypes);

if (!params.ok) {
return params;
}

try {
const compiled = await input.compile({
argumentsJson: JSON.stringify(params.arguments),
network: input.network,
source,
});

return {
derivation: {
address: compiled.address,
scriptPubKeyHex: compiled.scriptPubKeyHex,
utxoType: input.utxoType,
},
ok: true,
};
} catch (error) {
return {
ok: false,
reason: `The contract at ${sourcePath} did not compile: ${String(error)}`,
};
}
}

/**
* Whether a covenant UTXO is what the manifest claims: does the script the wallet derived
* match the one the funds are actually locked by?
*
* `onChainScriptPubKeyHex` must come from the chain, never from the request. Comparing two
* values the same site supplied would pass for any pair it chose to make consistent. The
* state file carries an outpoint and no script precisely because the script has to be read
* rather than told.
*
* The comparison is over the script rather than the address it is written as. The script is
* the locking condition itself; an address is one rendering of it, and rendering is where a
* difference can hide — the same script has a different address on a different network, and
* two spellings of one address are not equal as strings.
*
* A mismatch is a refusal. There is no shape of this function that returns a warning.
*/
export function covenantMatchesChain(
derivation: CovenantDerivation,
onChainScriptPubKeyHex: string,
): { matched: true } | { matched: false; reason: string } {
if (derivation.scriptPubKeyHex.toLowerCase() === onChainScriptPubKeyHex.toLowerCase()) {
return { matched: true };
}

return {
matched: false,
reason:
`The ${derivation.utxoType} contract rebuilds to ${derivation.address}, ` +
"but the funds are locked by a different contract. " +
"This is not the contract the site described.",
};
}
26 changes: 26 additions & 0 deletions packages/tx-manifest/src/covenants/declaredTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { asRecord } from "../document/json";

/**
* The declared types a covenant's compile parameters are encoded against.
*
* Read from the action's own parameter declarations rather than inferred from the values the
* request filled them with. A value's shape is not evidence of what it was declared as, and a
* runtime that guessed from it would read a covenant hash of sixty-four zeros as a number.
*
* A declaration this cannot read leaves the name with no type, which refuses. That is the
* direction to fail in: the alternative is a value encoded at a width nobody stated, and the
* width is part of the address.
*/
export function declaredParamTypes(action: Record<string, unknown>): Record<string, string> {
const types: Record<string, string> = {};

for (const [name, declared] of Object.entries(asRecord(action.params) ?? {})) {
const type = asRecord(declared)?.type;

if (typeof type === "string") {
types[name] = type;
}
}

return types;
}
62 changes: 62 additions & 0 deletions packages/tx-manifest/src/document/sites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { asArray, asRecord } from "./json";

/**
* One place in an action where a covenant appears, and which side it is on.
*
* Inputs spend a covenant, outputs create one, and the distinction decides whether there
* is anything on chain to compare a derived covenant against. Enumerating both from one
* place is what stops "where are the covenants" being answered differently by whichever
* function happens to be asking.
*/
export type CovenantSite = {
role: "created" | "spent";
utxoType: string;
/** The compile parameters wired in at this site, unresolved. */
wiring: Record<string, unknown>;
};

export function covenantSites(action: Record<string, unknown>): CovenantSite[] {
const sites: CovenantSite[] = [];

for (const entry of asArray(action.inputs)) {
const site = covenantReference(asRecord(entry)?.utxo_source);

if (site) {
sites.push({ ...site, role: "spent" });
}
}

for (const entry of asArray(action.outputs)) {
const site = covenantReference(asRecord(entry)?.destination);

if (site) {
sites.push({ ...site, role: "created" });
}
}

return sites;
}

/** The utxo types this action reaches, in the order it names them. */
export function namedUtxoTypes(action: Record<string, unknown>): string[] {
return [...new Set(covenantSites(action).map((site) => site.utxoType))];
}

/**
* The covenant one input source or output destination names, if it names one.
*
* The keywords — `wallet`, `change` — are written where the object would be, so anything
* that is not an object naming a `utxo_type` is not a covenant site.
*/
function covenantReference(
value: unknown,
): { utxoType: string; wiring: Record<string, unknown> } | undefined {
const record = asRecord(value);
const utxoType = record?.utxo_type;

if (typeof utxoType !== "string") {
return undefined;
}

return { utxoType, wiring: asRecord(record?.compile_params) ?? {} };
}
Loading