Skip to content
Merged
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ git mark verify
git mark info
```

## Chains

Pick one with `--chain` at `git mark init`. It's recorded in `blocktrails.json` and used for every later mark.

| Chain | Network | Addresses | Explorer API |
|-------|---------|-----------|--------------|
| `tbtc4` (default) | Bitcoin testnet4 | `tb1p…` | mempool.space/testnet4 |
| `tbtc3` | Bitcoin testnet3 | `tb1p…` | mempool.space/testnet |
| `signet` | Bitcoin signet | `tb1p…` | mempool.space/signet |
| `btc` | Bitcoin | `bc1p…` | mempool.space |
| `xbt` | BLAKE2b fork of Bitcoin, from block 961,640 | `bc1p…` | mempool.kilombino.com |
| `txbt4` | BLAKE2b fork of testnet4, from block 150,308 | `tb1p…` | mempool.guide/testnet4 |

On `xbt` and `txbt4`, marks are signed with `SIGHASH_UNIFIED` ([spec](https://github.com/bitcoin-blake/blaketest/blob/gh-pages/unified-sighash.md)): a 65-byte signature ending `0x21` that Knots accepts on the BLAKE2b chains and SHA256d nodes reject. Coins from before the fork exist on both chains, so without this a mark could be replayed onto the original chain. `lib/unified-sighash.js` implements it and passes Knots's 166 test vectors (`test/unified_sighash.json`).

## How It Works

Each `git mark` creates a real Bitcoin transaction. The address is derived from your key + the commit hash using BIP-341 taproot key chaining:
Expand Down
11 changes: 11 additions & 0 deletions URI.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ Gitmark was designed to be prototyped on the bitmark chain. But other chains wi

Supported chains, bitcoin, litecoin, liquid network

The `git mark` CLI uses these chain codes in its TXO URIs (`txo:<chain>:<txid>:<vout>`):

| Code | Chain |
|------|-------|
| `btc` | Bitcoin |
| `tbtc4` | Bitcoin testnet4 |
| `tbtc3` | Bitcoin testnet3 |
| `signet` | Bitcoin signet |
| `xbt` | Bitcoin BLAKE2b, the BLAKE2b proof-of-work fork of Bitcoin |
| `txbt4` | Bitcoin BLAKE2b testnet4 |

## Git tags

The : character is not available in a git tag, so if used in a tag a possible workaround may be needed
39 changes: 30 additions & 9 deletions bin/git-mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
import { unifiedSighash, SIGHASH_ALL, SIGHASH_UNIFIED, SCRIPT_TYPE_TAPROOT } from '../lib/unified-sighash.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const PKG = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
Expand All @@ -29,11 +30,15 @@ const TRAIL_FILE = 'blocktrails.json';
const PRIVATE_FILE = '.git/blocktrails.json';
const DEFAULT_CHAIN = 'tbtc4';

// unified: sign with SIGHASH_UNIFIED, so the transaction cannot be replayed onto the SHA256d chain
// that the BLAKE2b forks share history with.
const CHAINS = {
tbtc3: { explorer: 'https://mempool.space/testnet/api', name: 'Bitcoin Testnet3' },
tbtc4: { explorer: 'https://mempool.space/testnet4/api', name: 'Bitcoin Testnet4' },
btc: { explorer: 'https://mempool.space/api', name: 'Bitcoin' },
signet: { explorer: 'https://mempool.space/signet/api', name: 'Bitcoin Signet' },
tbtc3: { explorer: 'https://mempool.space/testnet/api', name: 'Bitcoin Testnet3', hrp: 'tb' },
tbtc4: { explorer: 'https://mempool.space/testnet4/api', name: 'Bitcoin Testnet4', hrp: 'tb' },
btc: { explorer: 'https://mempool.space/api', name: 'Bitcoin', hrp: 'bc' },
signet: { explorer: 'https://mempool.space/signet/api', name: 'Bitcoin Signet', hrp: 'tb' },
xbt: { explorer: 'https://mempool.kilombino.com/api', name: 'Bitcoin BLAKE2b', hrp: 'bc', unified: true },
txbt4: { explorer: 'https://mempool.guide/testnet4/api', name: 'Bitcoin BLAKE2b Testnet4', hrp: 'tb', unified: true },
};

// --- Blocktrails key chaining (BIP-341) ---
Expand Down Expand Up @@ -105,7 +110,7 @@ function pubkeyToAddress(pubkeyHex, states, chain) {
const pubBytes = hexToBytes(pubkeyHex);
const derived = states.length > 0 ? deriveChainedPubkey(pubBytes, states) : pubBytes;
const xOnly = derived.slice(1);
const hrp = chain === 'btc' ? 'bc' : 'tb';
const hrp = CHAINS[chain]?.hrp ?? 'tb';
return bech32mEncode(hrp, 1, xOnly);
}

Expand All @@ -125,7 +130,7 @@ function writeU64LE(n) { const b = new Uint8Array(8); let v = BigInt(n); for (le
function concatBytes(...arrays) { const r = new Uint8Array(arrays.reduce((s, a) => s + a.length, 0)); let o = 0; for (const a of arrays) { r.set(a, o); o += a.length; } return r; }
function reverseTxid(txid) { return hexToBytes(txid).reverse(); }

function buildTransaction(input, outputs, privkeyBytes) {
function buildTransaction(input, outputs, privkeyBytes, { unified = false } = {}) {
const inputs = [input];
const internalXOnly = new Uint8Array(secp256k1.getPublicKey(privkeyBytes, true)).slice(1);
const untweakedHex = '5120' + bytesToHex(internalXOnly);
Expand All @@ -152,7 +157,21 @@ function buildTransaction(input, outputs, privkeyBytes) {
const shaOutputs = sha256(concatBytes(...serOutputs));

const sigs = [];
for (let i = 0; i < inputs.length; i++) {
if (unified) {
// SIGHASH_ALL | SIGHASH_UNIFIED, carried as a 65th byte on the signature
const hashType = SIGHASH_ALL | SIGHASH_UNIFIED;
const tx = {
version, locktime,
inputs: inputs.map(i => ({ txid: reverseTxid(i.txid), vout: i.vout, sequence })),
outputs: outputs.map(o => ({ value: o.amount, script: o.scriptPubKey })),
};
const spent = inputs.map(i => ({ value: i.amount, script: i.scriptPubKey }));
for (let i = 0; i < inputs.length; i++) {
const sighash = unifiedSighash(tx, i, hashType, SCRIPT_TYPE_TAPROOT, spent);
sigs.push(concatBytes(schnorr.sign(sighash, signingKey), new Uint8Array([hashType])));
}
}
for (let i = 0; !unified && i < inputs.length; i++) {
const sigMsg = concatBytes(
new Uint8Array([0x00, 0x00]),
writeU32LE(version), writeU32LE(locktime),
Expand Down Expand Up @@ -349,7 +368,8 @@ async function cmdInit(args) {
const rawTx = buildTransaction(
{ txid: txo.txid, vout: txo.vout, amount: txo.amount, scriptPubKey: hexToBytes(prevOut.scriptpubkey) },
[{ amount: outputAmount, scriptPubKey: baseScript }],
voucherKey
voucherKey,
{ unified: !!CHAINS[chain].unified }
);
const newTxid = await broadcastTx(rawTx, explorer);

Expand Down Expand Up @@ -420,7 +440,8 @@ async function cmdMark(args) {
const rawTx = buildTransaction(
{ txid: priv.txid, vout: priv.vout, amount: priv.amount, scriptPubKey: hexToBytes(prevOut.scriptpubkey) },
[{ amount: outputAmount, scriptPubKey: nextScript }],
signingKey
signingKey,
{ unified: !!CHAINS[chain].unified }
);
const newTxid = await broadcastTx(rawTx, explorer);

Expand Down
195 changes: 195 additions & 0 deletions lib/unified-sighash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Unified opt-in signature hash (Bitcoin Knots PR #357), as shipped in
// v29.4.1.knots20260508. Spec: doc/unified-sighash.md in that tag, mirrored at
// https://github.com/bitcoin-blake/blaketest/blob/gh-pages/unified-sighash.md.
// Vectors: test/unified_sighash.json (166 rows).
//
// One message for every script type. A signature opts in by setting
// SIGHASH_UNIFIED (0x20) in its hash type byte, so SIGHASH_ALL becomes 0x21.
// A node that does not implement the fork computes the legacy message for the
// same byte and the signature fails there, which is what makes an opted-in
// transaction unreplayable onto the SHA256d chain.

import { sha256 } from '@noble/hashes/sha256';

export const SIGHASH_ALL = 0x01;
export const SIGHASH_NONE = 0x02;
export const SIGHASH_SINGLE = 0x03;
export const SIGHASH_UNIFIED = 0x20;
export const SIGHASH_ANYONECANPAY = 0x80;

export const SCRIPT_TYPE_BASE = 0; // bare or P2SH
export const SCRIPT_TYPE_WITNESS_V0 = 1;
export const SCRIPT_TYPE_TAPROOT = 2; // key path
export const SCRIPT_TYPE_TAPSCRIPT = 3;

const TAPLEAF_VERSION = 0xc0;

// ---- serialization helpers -------------------------------------------------

export function compactSize(n) {
if (n < 0xfd) return Uint8Array.of(n);
if (n <= 0xffff) return Uint8Array.of(0xfd, n & 0xff, n >> 8);
if (n <= 0xffffffff) return Uint8Array.of(0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff);
throw new Error('compactSize: value too large');
}

export function u32le(n) {
return Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff);
}

export function i64le(v) {
const out = new Uint8Array(8);
let t = BigInt.asUintN(64, BigInt(v));
for (let i = 0; i < 8; i++) { out[i] = Number(t & 0xffn); t >>= 8n; }
return out;
}

export function concat(...parts) {
const len = parts.reduce((a, p) => a + p.length, 0);
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}

export function taggedHash(tag, msg) {
const t = sha256(new TextEncoder().encode(tag));
return sha256(concat(t, t, msg));
}

function serializeOutput(out) {
return concat(i64le(out.value), compactSize(out.script.length), out.script);
}

function serializeOutpoint(inp) {
return concat(inp.txid, u32le(inp.vout));
}

// ---- transaction parsing ---------------------------------------------------

// Parses a raw transaction (legacy or segwit serialization) into
// { version, inputs: [{ txid, vout, scriptSig, sequence, witness }],
// outputs: [{ value, script }], locktime, segwit }.
// txid is kept in wire (little-endian) byte order.
export function parseTransaction(bytes) {
let o = 0;
const readCS = () => {
const first = bytes[o++];
if (first < 0xfd) return first;
let size = first === 0xfd ? 2 : first === 0xfe ? 4 : 8;
let v = 0n;
for (let i = 0; i < size; i++) v |= BigInt(bytes[o + i]) << BigInt(8 * i);
o += size;
return Number(v);
};
const take = (n) => { const s = bytes.subarray(o, o + n); if (s.length !== n) throw new Error('truncated'); o += n; return s; };
const readU32 = () => { const b = take(4); return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0; };
const readI64 = () => { const b = take(8); let v = 0n; for (let i = 7; i >= 0; i--) v = (v << 8n) | BigInt(b[i]); return BigInt.asIntN(64, v); };

const version = readU32();
let segwit = false;
if (bytes[o] === 0x00 && bytes[o + 1] === 0x01) { segwit = true; o += 2; }
const nIn = readCS();
const inputs = [];
for (let i = 0; i < nIn; i++) {
const txid = take(32);
const vout = readU32();
const scriptSig = take(readCS());
const sequence = readU32();
inputs.push({ txid, vout, scriptSig, sequence, witness: [] });
}
const nOut = readCS();
const outputs = [];
for (let i = 0; i < nOut; i++) {
const value = readI64();
const script = take(readCS());
outputs.push({ value, script });
}
if (segwit) {
for (let i = 0; i < nIn; i++) {
const n = readCS();
for (let j = 0; j < n; j++) inputs[i].witness.push(take(readCS()));
}
}
const locktime = readU32();
if (o !== bytes.length) throw new Error('trailing bytes after transaction');
return { version, inputs, outputs, locktime, segwit };
}

// ---- the message -----------------------------------------------------------

export function tapleafHash(script, version = TAPLEAF_VERSION) {
return taggedHash('TapLeaf', concat(Uint8Array.of(version), compactSize(script.length), script));
}

// tx: parsed transaction (see parseTransaction), or any object of that shape.
// spentOutputs: [{ value, script }] one per input, in input order.
// opts.scriptCode: required for script types 0 and 1 (Uint8Array, no length prefix).
// opts.annex: Uint8Array or undefined, taproot only.
// opts.tapleafHash / opts.leafScript: script type 3 only (one of them).
// opts.codeseparatorPos: script type 3 only, default 0xffffffff.
export function unifiedSighash(tx, inIdx, hashType, scriptType, spentOutputs, opts = {}) {
if (!(hashType & SIGHASH_UNIFIED)) throw new Error('hash type does not opt in to SIGHASH_UNIFIED');
if (inIdx >= tx.inputs.length) throw new Error('input index out of range');
if (spentOutputs.length !== tx.inputs.length) throw new Error('need one spent output per input');
const taproot = scriptType === SCRIPT_TYPE_TAPROOT || scriptType === SCRIPT_TYPE_TAPSCRIPT;
const outputType = hashType & 0x1f;
if (taproot) {
if (hashType & ~(0x1f | SIGHASH_ANYONECANPAY | SIGHASH_UNIFIED)) throw new Error('undefined hash type for taproot');
if (outputType !== SIGHASH_ALL && outputType !== SIGHASH_NONE && outputType !== SIGHASH_SINGLE) throw new Error('undefined hash type for taproot');
}
const anyonecanpay = !!(hashType & SIGHASH_ANYONECANPAY);

const parts = [];
parts.push(Uint8Array.of(0)); // epoch
parts.push(Uint8Array.of(hashType & 0xff)); // hash type, one byte
parts.push(u32le(tx.version));
parts.push(u32le(tx.locktime), Uint8Array.of(0)); // locktime, five bytes

if (!anyonecanpay) {
parts.push(sha256(concat(...tx.inputs.map(serializeOutpoint))));
parts.push(sha256(concat(...spentOutputs.map(s => i64le(s.value)))));
parts.push(sha256(concat(...spentOutputs.map(s => concat(compactSize(s.script.length), s.script)))));
parts.push(sha256(concat(...tx.inputs.map(i => u32le(i.sequence)))));
}
if (outputType !== SIGHASH_NONE && outputType !== SIGHASH_SINGLE) {
parts.push(sha256(concat(...tx.outputs.map(serializeOutput))));
}

parts.push(Uint8Array.of(scriptType));

if (anyonecanpay) {
parts.push(serializeOutpoint(tx.inputs[inIdx]));
parts.push(serializeOutput(spentOutputs[inIdx]));
parts.push(u32le(tx.inputs[inIdx].sequence));
} else {
parts.push(u32le(inIdx));
}

if (!taproot) {
if (!opts.scriptCode) throw new Error('scriptCode required for script types 0 and 1');
parts.push(compactSize(opts.scriptCode.length), opts.scriptCode);
} else {
if (opts.annex) {
parts.push(Uint8Array.of(1));
parts.push(sha256(concat(compactSize(opts.annex.length), opts.annex)));
} else {
parts.push(Uint8Array.of(0));
}
}

if (outputType === SIGHASH_SINGLE) {
if (inIdx >= tx.outputs.length) throw new Error('SIGHASH_SINGLE with no matching output');
parts.push(sha256(serializeOutput(tx.outputs[inIdx])));
}

if (scriptType === SCRIPT_TYPE_TAPSCRIPT) {
const leaf = opts.tapleafHash || (opts.leafScript && tapleafHash(opts.leafScript));
if (!leaf) throw new Error('tapleaf hash required for tapscript');
parts.push(leaf);
parts.push(Uint8Array.of(0)); // key version
parts.push(u32le(opts.codeseparatorPos ?? 0xffffffff));
}

return taggedHash('UnifiedSighash', concat(...parts));
}
Loading
Loading