From 145d598a56bfe063da3e676f5856b85ef95dded2 Mon Sep 17 00:00:00 2001 From: David Case Date: Fri, 18 Sep 2026 19:31:44 -0400 Subject: [PATCH 01/34] feat: txstore, resolver, cascade, git-remote-gib push/fetch Greenfield WS-C: file txstore, B/ord resolver, planCommit cascade, commit-token seal, validation gate, pending cache, remote helper. --- .gitignore | 1 - bun.lock | 32 ++++ docs/plans/HANDOFF.md | 44 +++-- docs/plans/ROADMAP.md | 2 +- docs/plans/ordfs-formats.html | 20 +++ package.json | 23 +++ src/cascade.ts | 159 +++++++++++++++++ src/content.ts | 89 ++++++++++ src/git-remote-gib.ts | 30 ++++ src/git.ts | 69 ++++++++ src/gitread.ts | 102 +++++++++++ src/main.ts | 47 +++++ src/ordfs/dir.ts | 317 ++++++++++++++++++++++++++++++++++ src/ordfs/patch.ts | 132 ++++++++++++++ src/ordfs/vcdiff.ts | 88 ++++++++++ src/outpoint.ts | 28 +++ src/pending.ts | 51 ++++++ src/publish-local.ts | 35 ++++ src/publish.ts | 182 +++++++++++++++++++ src/push.ts | 169 ++++++++++++++++++ src/recovery.ts | 42 +++++ src/remote/advertise.ts | 60 +++++++ src/remote/helper.ts | 130 ++++++++++++++ src/resolver.ts | 134 ++++++++++++++ src/script.ts | 33 ++++ src/seal.ts | 29 ++++ src/token.ts | 78 +++++++++ src/tree.ts | 101 +++++++++++ src/txstore.ts | 48 +++++ src/validate.ts | 25 +++ src/wallet.ts | 8 + test/cascade.test.ts | 47 +++++ test/git.test.ts | 40 +++++ test/gitread.test.ts | 59 +++++++ test/helper-protocol.test.ts | 31 ++++ test/helpers.ts | 51 ++++++ test/pending.test.ts | 24 +++ test/push.test.ts | 53 ++++++ test/recovery.test.ts | 33 ++++ test/resolver.test.ts | 61 +++++++ test/token.test.ts | 23 +++ test/txstore.test.ts | 23 +++ tsconfig.json | 12 ++ 43 files changed, 2745 insertions(+), 20 deletions(-) create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/cascade.ts create mode 100644 src/content.ts create mode 100644 src/git-remote-gib.ts create mode 100644 src/git.ts create mode 100644 src/gitread.ts create mode 100644 src/main.ts create mode 100644 src/ordfs/dir.ts create mode 100644 src/ordfs/patch.ts create mode 100644 src/ordfs/vcdiff.ts create mode 100644 src/outpoint.ts create mode 100644 src/pending.ts create mode 100644 src/publish-local.ts create mode 100644 src/publish.ts create mode 100644 src/push.ts create mode 100644 src/recovery.ts create mode 100644 src/remote/advertise.ts create mode 100644 src/remote/helper.ts create mode 100644 src/resolver.ts create mode 100644 src/script.ts create mode 100644 src/seal.ts create mode 100644 src/token.ts create mode 100644 src/tree.ts create mode 100644 src/txstore.ts create mode 100644 src/validate.ts create mode 100644 src/wallet.ts create mode 100644 test/cascade.test.ts create mode 100644 test/git.test.ts create mode 100644 test/gitread.test.ts create mode 100644 test/helper-protocol.test.ts create mode 100644 test/helpers.ts create mode 100644 test/pending.test.ts create mode 100644 test/push.test.ts create mode 100644 test/recovery.test.ts create mode 100644 test/resolver.test.ts create mode 100644 test/token.test.ts create mode 100644 test/txstore.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index c4b86a5..17a75a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ node_modules/ -.gib/ *.rawtx.hex diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..25b006b --- /dev/null +++ b/bun.lock @@ -0,0 +1,32 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "gib-cli", + "dependencies": { + "@bsv/sdk": "^2.6.0", + "xdelta3-wasm": "^1.0.0", + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@bsv/sdk": ["@bsv/sdk@2.7.1", "", {}, "sha512-1dcvOwCVGZz3A5FTJeOil1MALp2iQ1bTrZ8C9v97CC26sd8t0+Y3bFP39Rw/q0zmcbh6c+ZkIt6tKfvje+pD2A=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@26.6.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "xdelta3-wasm": ["xdelta3-wasm@1.0.0", "", {}, "sha512-vhS28BhVaE3S/PGG1KQIwjBVqJecuS5Sdh82UAZysbnYaU93KS6l8ZPtsqonNZMeuyFgrGW9D44hh2lwQCsidA=="], + } +} diff --git a/docs/plans/HANDOFF.md b/docs/plans/HANDOFF.md index 5eeb804..427f462 100644 --- a/docs/plans/HANDOFF.md +++ b/docs/plans/HANDOFF.md @@ -30,7 +30,15 @@ Read first: `ROADMAP.md`, `ordfs-formats.html`, `gib-format.html`, - NOT yet exported from `packages/actions/src/index.ts` — add when the module set settles. -## In progress: vcdiff codec — DECISION PENDING, then finish or delete +## Settled: vcdiff codec (`xdelta3-wasm`, RFC-plain profile) + +Decision (follow-up branch `feat/ordfs-patch` in 1sat-sdk; spec on +`docs/vcdiff-profile` in this repo): **`xdelta3-wasm`** encode+decode. +On-chain profile is RFC 3284 with `Hdr_Indicator = 0` — same bytes as +`xdelta3 -e -n -S none -A`. `@limrun/xdelta3-wasm` encodes the same +profile but is encode-only. `vcdiff-wasm` remains REJECT. + +## Was in progress: vcdiff codec — DECISION PENDING, then finish or delete The record codec needs VCDIFF (RFC 3284) deltas that any third party can read. Findings from an exhaustive probe of available libraries: @@ -86,23 +94,23 @@ including xdelta3 CLI interop (skips when CLI absent). ## Remaining work, in order -1. Settle vcdiff (above). Profile documented in `ordfs-formats.html`. -2. `ordfs/patch` envelope helpers: `[1B version][36B base outpoint][vcdiff - delta]` — encode/decode + the "identical content = direct citation, - never an empty patch" rule. Spec: `gib-format.html`. -3. PushDrop lifecycle abstraction (lift pattern from OPNS usage in - prototype branch `archive/prototype` file `token.ts`): mint/seal/decode - with protocolID/keyID/customInstructions handling. Gib-specific fields - and keyID policy live in gib, NOT the SDK. Fields: - `["gib", origin, branch, rootOutpoint, identityPubkey]` - (`gib-token.html`). -4. Export all new modules from `packages/actions` index; run the package - lint/build; PR against `master` in b-open-io/1sat-sdk. -5. Then the gib stream (WS-C) in gib-cli: txstore, resolver, - `git-remote-gib`, seal/recovery per `gib-cli.html` + wallet scheme in - `questions.md` (basket `gib`, label `push:`, tags - `origin:`/`branch:`, transient partials keyed by createAction - reference). +1. ~~Settle vcdiff.~~ Done on `feat/ordfs-patch` (1sat-sdk) + + `docs/vcdiff-profile` (this repo). Profile in `ordfs-formats.html`. +2. ~~`ordfs/patch` envelope helpers.~~ Done (`patchEncode`/`patchDecode`/ + `patchFromContent`/`patchApply`; identical-content refused). +3. ~~PushDrop lifecycle abstraction.~~ Done (`pushDropLock`/`pushDropSeal`/ + `pushDropDecode`/`pushDropCustomInstructions` in + `packages/actions/src/utils/pushdrop.ts`). Gib-specific fields and + keyID policy still live in gib, not the SDK. +4. ~~Export modules.~~ Done from `ordfs/index.ts` + actions `index.ts`. + Package build passes. PR against `master` in b-open-io/1sat-sdk still + needed (do not use `feat/ordfs-dir-patch`). +5. Gib stream (WS-C) on branch `feat/gib` — in progress: + txstore, resolver (B + ord, dir walk, patch chain), commit-token + seal/decode, recovery plan, cascade planner (`planCommit`: genesis + + cite-unchanged), `git-remote-gib` capabilities/list/fetch. + Still open: wallet publish of planned outputs, push intake from + git pack, validation gate, live advertise, pending-upload cache. ## Environment notes diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md index 9f2b6b9..5a486df 100644 --- a/docs/plans/ROADMAP.md +++ b/docs/plans/ROADMAP.md @@ -18,7 +18,7 @@ Repo: b-open-io/1sat-sdk (work lands here directly; opldotdev is a renamed mirro same repo). Package: `packages/actions`. - `ordfs/dir` encoder: canonical form (sorted names, reserved bits 0) + decoder. - `ordfs/patch` encode/apply: envelope [1B version][36B base outpoint][vcdiff]; - vcdiff codec choice verified under Bun (vcdiff-wasm proven). + vcdiff profile = RFC-plain (`xdelta3 -e -n -S none -A` / xdelta3-wasm flags 0). - Push-drop lifecycle abstraction lifted from OPNS (`src/opns/*`, `apply/opnsRegister.ts`, `utils/completeSignedAction.ts`): mint / seal-forward spend / decode / customInstructions at lock time. Gib passes diff --git a/docs/plans/ordfs-formats.html b/docs/plans/ordfs-formats.html index 53244a2..bd047af 100644 --- a/docs/plans/ordfs-formats.html +++ b/docs/plans/ordfs-formats.html @@ -104,6 +104,26 @@

ordfs/patch — record

(a patch output itself declares only ordfs/patch).
  • The base chain must terminate at a non-patch (or a patch whose base is unreachable is unresolvable).
  • +
  • Identical content is never a patch. Cite the existing outpoint from +the manifest. An empty delta is invalid.
  • + + +

    VCDIFF profile (on-chain contract)

    +

    The delta is RFC 3284 VCDIFF with a plain header. Writers MUST emit this +profile; readers MUST reject anything else. Equivalent to +xdelta3 -e -n -S none -A (and to xdelta3-wasm +xd3_encode_memory with flags 0).

    +
    header := D6 C3 C4 00 00
    +          magic | ver | Hdr_Indicator = 0
    +

    Gateway behavior (1sat-stack/pkg/ordfs)

    diff --git a/package.json b/package.json new file mode 100644 index 0000000..a0c15d9 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "gib-cli", + "version": "0.0.1", + "private": true, + "type": "module", + "bin": { + "git-remote-gib": "src/git-remote-gib.ts", + "gib": "src/main.ts" + }, + "scripts": { + "test": "bun test", + "gib": "bun run src/main.ts", + "git-remote-gib": "bun run src/git-remote-gib.ts" + }, + "dependencies": { + "@bsv/sdk": "^2.6.0", + "xdelta3-wasm": "^1.0.0" + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^5.9.3" + } +} diff --git a/src/cascade.ts b/src/cascade.ts new file mode 100644 index 0000000..20361d1 --- /dev/null +++ b/src/cascade.ts @@ -0,0 +1,159 @@ +import { DIR_CONTENT_TYPE, dirEncode, dirName, type DirEntry } from './ordfs/dir.ts' +import { PATCH_CONTENT_TYPE, patchFromContent } from './ordfs/patch.ts' +import type { Outpoint } from './outpoint.ts' +import { collectTree, type FileEntry } from './tree.ts' +import type { TxStore } from './txstore.ts' + +export type IncomingFile = { + path: string + bytes: Uint8Array + contentType?: string + exec?: boolean + symlink?: boolean +} + +export type PlannedOutput = { + contentType: string + bytes: Uint8Array + path?: string +} + +export type CommitPlan = { + outputs: PlannedOutput[] + rootIndex: number +} + +function parentDir(path: string): string { + const i = path.lastIndexOf('/') + return i < 0 ? '' : path.slice(0, i) +} + +function basename(path: string): string { + const i = path.lastIndexOf('/') + return i < 0 ? path : path.slice(i + 1) +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} + +export async function planCommit(opts: { + files: IncomingFile[] + prevRoot?: Outpoint + store?: TxStore +}): Promise { + const old = new Map() + if (opts.prevRoot && opts.store) { + for (const f of await collectTree(opts.store, opts.prevRoot)) { + old.set(f.path, f) + } + } + + const outputs: PlannedOutput[] = [] + const fileVout = new Map() + const touchedDirs = new Set(['']) + + for (const f of opts.files) { + const prev = old.get(f.path) + if (prev && bytesEqual(prev.bytes, f.bytes)) { + fileVout.set(f.path, { + kind: 'outpoint', + txid: prev.outpoint.txid, + vout: prev.outpoint.vout, + }) + continue + } + let dir = parentDir(f.path) + while (true) { + touchedDirs.add(dir) + if (!dir) break + dir = parentDir(dir) + } + if (prev) { + const bytes = await patchFromContent({ + base: prev.outpoint, + source: prev.bytes, + target: f.bytes, + }) + fileVout.set(f.path, { kind: 'same-tx', vout: outputs.length }) + outputs.push({ contentType: PATCH_CONTENT_TYPE, bytes, path: f.path }) + } else { + fileVout.set(f.path, { kind: 'same-tx', vout: outputs.length }) + outputs.push({ + contentType: f.contentType ?? 'application/octet-stream', + bytes: f.bytes, + path: f.path, + }) + } + } + + const dirs = new Set(['']) + for (const f of opts.files) { + let d = parentDir(f.path) + while (true) { + dirs.add(d) + if (!d) break + d = parentDir(d) + } + } + + const children = new Map>() + for (const d of dirs) { + if (!d) continue + const p = parentDir(d) + if (!children.has(p)) children.set(p, new Set()) + children.get(p)!.add(d) + } + for (const f of opts.files) { + const p = parentDir(f.path) + if (!children.has(p)) children.set(p, new Set()) + children.get(p)!.add(f.path) + } + + const sortedDirs = [...dirs].sort((a, b) => b.split('/').filter(Boolean).length - a.split('/').filter(Boolean).length) + const dirVout = new Map() + + for (const d of sortedDirs) { + if (!touchedDirs.has(d) && d !== '') continue + const names = [...(children.get(d) ?? [])].sort() + const entries: DirEntry[] = [] + for (const name of names) { + const base = basename(name) + if (dirs.has(name)) { + const v = dirVout.get(name) + if (v === undefined) continue + entries.push({ + name: dirName(base), + isDir: true, + ref: { kind: 'same-tx', vout: v }, + }) + } else { + const ref = fileVout.get(name) + if (!ref) continue + const file = opts.files.find((x) => x.path === name) + entries.push({ + name: dirName(base), + isDir: false, + exec: file?.exec, + symlink: file?.symlink, + ref: + ref.kind === 'same-tx' + ? { kind: 'same-tx', vout: ref.vout } + : { kind: 'outpoint', txid: ref.txid, vout: ref.vout }, + }) + } + } + dirVout.set(d, outputs.length) + outputs.push({ + contentType: DIR_CONTENT_TYPE, + bytes: dirEncode({ version: 1, entries }), + path: d || '/', + }) + } + + const rootIndex = dirVout.get('') + if (rootIndex === undefined) throw new Error('cascade: missing root dir') + return { outputs, rootIndex } +} diff --git a/src/content.ts b/src/content.ts new file mode 100644 index 0000000..f67da78 --- /dev/null +++ b/src/content.ts @@ -0,0 +1,89 @@ +import { LockingScript, OP, Script, Utils } from '@bsv/sdk' + +export const B_PREFIX = '19HxigV4QyBv3tHpQVcUEQyq1pzZVdoAut' + +export type Payload = { + contentType: string + bytes: Uint8Array +} + +export function payloadFromScript( + script: LockingScript | Script, +): Payload | undefined { + return decodeOrd(script) ?? decodeB(script) +} + +function decodeOrd(script: Script): Payload | undefined { + const chunks = script.chunks + for (let i = 0; i < chunks.length - 2; i++) { + const a = chunks[i] + const b = chunks[i + 1] + const c = chunks[i + 2] + if ( + a?.op !== OP.OP_0 || + b?.op !== OP.OP_IF || + c?.data == null || + c.data.length !== 3 || + Utils.toUTF8(c.data) !== 'ord' + ) { + continue + } + let pos = i + 3 + let contentType = '' + let content = new Uint8Array(0) + while (pos < chunks.length) { + if (chunks[pos].op === OP.OP_ENDIF) break + const key = chunks[pos] + let fieldNum: number | undefined + if (key.op === OP.OP_0) fieldNum = 0 + else if ( + key.op !== undefined && + key.op > OP.OP_PUSHDATA4 && + key.op <= OP.OP_16 + ) { + fieldNum = key.op - 80 + } else if (key.data != null && key.data.length === 1) { + fieldNum = key.data[0] + } + pos++ + if (pos >= chunks.length) break + const data = + chunks[pos]?.data != null + ? new Uint8Array(chunks[pos].data) + : new Uint8Array(0) + pos++ + if (fieldNum === 0) content = data + else if (fieldNum === 1) { + try { + contentType = Utils.toUTF8(Array.from(data)) + } catch { + contentType = '' + } + } + } + if (content.length === 0) return undefined + return { contentType, bytes: content } + } + return undefined +} + +function decodeB(script: Script): Payload | undefined { + const chunks = script.chunks + let i = 0 + if (chunks[0]?.op === OP.OP_FALSE || chunks[0]?.op === OP.OP_0) i++ + if (chunks[i]?.op !== OP.OP_RETURN) return undefined + const ret = chunks[i] + let rest = chunks.slice(i + 1) + if (ret.data?.length && rest.length === 0) { + rest = Script.fromBinary(Array.from(ret.data)).chunks + } + const prefix = rest[0]?.data + if (!prefix || Utils.toUTF8(prefix) !== B_PREFIX) return undefined + const data = rest[1]?.data + const type = rest[2]?.data + if (!data || !type) return undefined + return { + contentType: Utils.toUTF8(type), + bytes: new Uint8Array(data), + } +} diff --git a/src/git-remote-gib.ts b/src/git-remote-gib.ts new file mode 100644 index 0000000..9f82535 --- /dev/null +++ b/src/git-remote-gib.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env bun +import { createInterface } from 'node:readline' +import { connectWallet } from './wallet.ts' +import { fileTxStore } from './txstore.ts' +import { runHelper } from './remote/helper.ts' + +const url = process.argv[3] ?? process.argv[2] +if (!url) { + console.error('git-remote-gib: missing remote url') + process.exit(1) +} + +const rl = createInterface({ input: process.stdin, terminal: false }) +const iter = rl[Symbol.asyncIterator]() + +await runHelper({ + url, + store: fileTxStore(), + wallet: connectWallet(), + gitDir: process.env.GIT_DIR ?? '.git', + io: { + async read() { + const n = await iter.next() + return n.done ? null : String(n.value) + }, + write(s) { + process.stdout.write(s) + }, + }, +}) diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..8c4346b --- /dev/null +++ b/src/git.ts @@ -0,0 +1,69 @@ +import { createHash } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +export function gitHash(type: 'blob' | 'tree' | 'commit' | 'tag', body: Uint8Array): string { + const header = new TextEncoder().encode(`${type} ${body.length}\0`) + const buf = new Uint8Array(header.length + body.length) + buf.set(header) + buf.set(body, header.length) + return createHash('sha1').update(buf).digest('hex') +} + +export async function writeGitObject( + gitDir: string, + type: 'blob' | 'tree' | 'commit' | 'tag', + body: Uint8Array, +): Promise { + const sha = gitHash(type, body) + const path = join(gitDir, 'objects', sha.slice(0, 2), sha.slice(2)) + const { deflateSync } = await import('node:zlib') + const header = new TextEncoder().encode(`${type} ${body.length}\0`) + const raw = new Uint8Array(header.length + body.length) + raw.set(header) + raw.set(body, header.length) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, deflateSync(raw)) + return sha +} + +export function treeEntryMode(opts: { exec?: boolean; symlink?: boolean; dir?: boolean }): string { + if (opts.dir) return '40000' + if (opts.symlink) return '120000' + if (opts.exec) return '100755' + return '100644' +} + +export function encodeTree(entries: Array<{ mode: string; name: string; sha: string }>): Uint8Array { + const sorted = [...entries].sort((a, b) => { + const an = a.mode === '40000' ? `${a.name}/` : a.name + const bn = b.mode === '40000' ? `${b.name}/` : b.name + return an < bn ? -1 : an > bn ? 1 : 0 + }) + const parts: Uint8Array[] = [] + let n = 0 + for (const e of sorted) { + const name = new TextEncoder().encode(`${e.mode} ${e.name}\0`) + const sha = hexTo20(e.sha) + const row = new Uint8Array(name.length + 20) + row.set(name) + row.set(sha, name.length) + parts.push(row) + n += row.length + } + const out = new Uint8Array(n) + let p = 0 + for (const r of parts) { + out.set(r, p) + p += r.length + } + return out +} + +function hexTo20(sha: string): Uint8Array { + const out = new Uint8Array(20) + for (let i = 0; i < 20; i++) { + out[i] = Number.parseInt(sha.slice(i * 2, i * 2 + 2), 16) + } + return out +} diff --git a/src/gitread.ts b/src/gitread.ts new file mode 100644 index 0000000..cead5bc --- /dev/null +++ b/src/gitread.ts @@ -0,0 +1,102 @@ +import type { IncomingFile } from './cascade.ts' + +async function git(gitDir: string, args: string[]): Promise<{ code: number; out: string; err: string }> { + const proc = Bun.spawn(['git', `--git-dir=${gitDir}`, ...args], { + stdout: 'pipe', + stderr: 'pipe', + }) + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { code, out, err } +} + +async function gitBytes(gitDir: string, args: string[]): Promise<{ code: number; out: Uint8Array; err: string }> { + const proc = Bun.spawn(['git', `--git-dir=${gitDir}`, ...args], { + stdout: 'pipe', + stderr: 'pipe', + }) + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).arrayBuffer(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { code, out: new Uint8Array(out), err } +} + +export async function revParse(gitDir: string, rev: string): Promise { + const r = await git(gitDir, ['rev-parse', '--verify', rev]) + if (r.code !== 0) throw new Error(`git rev-parse ${rev}: ${r.err.trim()}`) + return r.out.trim() +} + +export async function isAncestor(gitDir: string, anc: string, desc: string): Promise { + const r = await git(gitDir, ['merge-base', '--is-ancestor', anc, desc]) + return r.code === 0 +} + +export async function commitBytes(gitDir: string, sha: string): Promise { + const r = await gitBytes(gitDir, ['cat-file', 'commit', sha]) + if (r.code !== 0) throw new Error(`git cat-file commit ${sha}: ${r.err.trim()}`) + return r.out +} + +export function treeShaFromCommit(commit: Uint8Array): string { + const text = new TextDecoder().decode(commit) + const m = text.match(/^tree ([0-9a-f]{40})/m) + if (!m) throw new Error('commit missing tree') + return m[1] +} + +export async function filesAtCommit(gitDir: string, sha: string): Promise { + const r = await git(gitDir, ['ls-tree', '-r', '-z', sha]) + if (r.code !== 0) throw new Error(`git ls-tree ${sha}: ${r.err.trim()}`) + const files: IncomingFile[] = [] + for (const rec of r.out.split('\0')) { + if (!rec) continue + const tab = rec.indexOf('\t') + if (tab < 0) continue + const meta = rec.slice(0, tab) + const path = rec.slice(tab + 1) + const [mode, type, blob] = meta.split(' ') + if (type !== 'blob') continue + const blobR = await gitBytes(gitDir, ['cat-file', 'blob', blob]) + if (blobR.code !== 0) throw new Error(`git cat-file blob ${blob}: ${blobR.err.trim()}`) + files.push({ + path, + bytes: blobR.out, + exec: mode === '100755', + symlink: mode === '120000', + contentType: guessType(path), + }) + } + return files +} + +function guessType(path: string): string { + if (path.endsWith('.md')) return 'text/markdown' + if (path.endsWith('.html')) return 'text/html' + if (path.endsWith('.json')) return 'application/json' + if (path.endsWith('.ts') || path.endsWith('.js') || path.endsWith('.txt')) { + return 'text/plain' + } + return 'application/octet-stream' +} + +export function parsePushLine(line: string): { + force: boolean + src: string + dst: string + del: boolean +} { + let s = line.replace(/^push\s+/, '') + const force = s.startsWith('+') + if (force) s = s.slice(1) + const i = s.lastIndexOf(':') + if (i < 0) throw new Error(`bad push spec: ${line}`) + const src = s.slice(0, i) + const dst = s.slice(i + 1) + return { force, src, dst, del: src === '' } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..949db97 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env bun +import { defaultGibHome, fileTxStore, txidOf } from './txstore.ts' +import { connectWallet, DEFAULT_WALLET_URL } from './wallet.ts' + +const cmd = process.argv[2] ?? 'help' + +if (cmd === 'help' || cmd === '-h' || cmd === '--help') { + process.stdout.write( + 'gib — on-chain git remote\n' + + ' git remote add origin gib://\n' + + ' git-remote-gib is the helper; add/commit/status stay git.\n' + + '\n' + + ' gib doctor check wallet + txstore\n' + + ' gib put store a signed tx (verifies txid)\n', + ) + process.exit(0) +} + +if (cmd === 'doctor') { + const home = defaultGibHome() + process.stdout.write(`GIB_HOME=${home}\n`) + process.stdout.write(`wallet=${DEFAULT_WALLET_URL}\n`) + try { + const w = connectWallet() + await w.getPublicKey({ identityKey: true }) + process.stdout.write('wallet: ok\n') + } catch (e) { + process.stdout.write(`wallet: ${e instanceof Error ? e.message : e}\n`) + } + process.exit(0) +} + +if (cmd === 'put') { + const file = process.argv[3] + if (!file) { + console.error('usage: gib put ') + process.exit(1) + } + const bytes = new Uint8Array(await Bun.file(file).arrayBuffer()) + const txid = txidOf(bytes) + await fileTxStore().put(txid, bytes) + process.stdout.write(`${txid}\n`) + process.exit(0) +} + +console.error(`unknown command: ${cmd}`) +process.exit(1) diff --git a/src/ordfs/dir.ts b/src/ordfs/dir.ts new file mode 100644 index 0000000..b57e496 --- /dev/null +++ b/src/ordfs/dir.ts @@ -0,0 +1,317 @@ +/** + * `ordfs/dir` — binary directory manifest codec. + * + * The binary sibling of the `ord-fs/json` manifest: same logical structure + * (a directory maps child names to outpoint references), encoded as a fixed + * byte layout so the encoding is canonical — same logical directory always + * produces byte-identical output, giving manifests a stable SHA-256. + * + * Layout (all integers big-endian; no varints; no padding): + * + * dir := + * [1B version] 0x01 + * [2B entry count] uint16 + * [entries × N] sorted ascending by raw name bytes + * + * entry := + * [1B flags] + * bit0 KIND 0 = file, 1 = directory + * bit1 EXEC executable (file) + * bit2 SYMLINK content = target path (file) + * bit3 REFTYPE 0 = same-tx output, 1 = full outpoint + * bits4–7 MUST be zero + * [1B name length] 1..255 + * [NB name] raw UTF-8 path component; no 0x00, no '/' + * [target] + * REFTYPE 0: [1B vout] sibling output in the same tx + * REFTYPE 1: [32B txid][4B vout] exact Bitcoin outpoint bytes + * (vout little-endian) + * + * Spec: docs/plans/ordfs-formats.html in the gib repo. Writers MUST emit + * canonical form; readers MUST reject anything else. + */ + +/** Content type written on `ordfs/dir` inscription outputs. */ +export const DIR_CONTENT_TYPE = 'ordfs/dir' + +/** Legacy JSON manifest content type (read support only going forward). */ +export const JSON_MANIFEST_CONTENT_TYPE_LEGACY = 'ord-fs/json' + +/** Current manifest format version. */ +export const DIR_VERSION = 1 + +/** Maximum number of entries in one manifest (uint16 entry count). */ +export const MAX_DIR_ENTRIES = 0xffff + +/** A reference to another output in the same transaction (`_N` in JSON). */ +export interface SameTxRef { + kind: 'same-tx' + vout: number +} + +/** A reference to an exact outpoint elsewhere (native Bitcoin serialization). */ +export interface OutpointRef { + kind: 'outpoint' + txid: string + vout: number +} + +export type DirRef = SameTxRef | OutpointRef + +/** One child entry in a directory manifest. */ +export interface DirEntry { + /** Raw UTF-8 name bytes; a single path component (never contains '/'). */ + name: Uint8Array + /** Directory (true) vs file (false). */ + isDir: boolean + /** Executable file bit (git mode 100755). Files only. */ + exec?: boolean + /** Symlink: the leaf content is a relative target path. Files only. */ + symlink?: boolean + /** What this entry points at. */ + ref: DirRef +} + +/** A decoded directory manifest. */ +export interface DirManifest { + version: number + entries: DirEntry[] +} + +const FLAG_DIR = 0x01 +const FLAG_EXEC = 0x02 +const FLAG_SYMLINK = 0x04 +const FLAG_REFTYPE = 0x08 +const FLAGS_RESERVED = 0xf0 + +/** Thrown for any spec violation: malformed, non-canonical, or invalid input. */ +export class DirFormatError extends Error { + constructor(message: string) { + super(message) + this.name = 'DirFormatError' + } +} + +const utf8Encoder = new TextEncoder() +const utf8Decoder = new TextDecoder('utf-8', { fatal: false }) + +const toHex = (b: Uint8Array): string => + Array.from(b) + .map((x) => x.toString(16).padStart(2, '0')) + .join('') + +const hexToBytes = (hex: string): Uint8Array => { + if (!/^([0-9a-fA-F]{2})*$/.test(hex) || hex.length !== 64) { + throw new DirFormatError(`invalid txid hex: ${hex}`) + } + const out = new Uint8Array(32) + for (let i = 0; i < 32; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return out +} + +/** Compare two byte arrays lexicographically (unsigned bytes). Returns <0, 0, >0. */ +function compareBytes(a: Uint8Array, b: Uint8Array): number { + const n = Math.min(a.length, b.length) + for (let i = 0; i < n; i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return a.length - b.length +} + +/** Sort key for canonical ordering: ascending by raw name bytes. */ +export function dirEntryNameCompare(a: DirEntry, b: DirEntry): number { + return compareBytes(a.name, b.name) +} + +/** Validate a single entry (name bytes, flags, ref ranges). Throws DirFormatError. */ +function validateEntry(e: DirEntry): void { + if (e.name.length < 1 || e.name.length > 255) { + throw new DirFormatError( + `entry name length ${e.name.length} out of range 1..255`, + ) + } + if (e.name.includes(0x00) || e.name.includes(0x2f)) { + throw new DirFormatError('entry name must not contain NUL or "/"') + } + if (!e.isDir && e.symlink && e.exec) { + // git has no 111xxx mode; symlink+exec is not a real git state + throw new DirFormatError('entry cannot be both symlink and exec') + } + if (e.ref.kind === 'same-tx') { + if (!Number.isInteger(e.ref.vout) || e.ref.vout < 0 || e.ref.vout > 255) { + throw new DirFormatError( + `same-tx vout ${e.ref.vout} out of range 0..255`, + ) + } + } else { + if ( + !Number.isInteger(e.ref.vout) || + e.ref.vout < 0 || + e.ref.vout > 0xffffffff + ) { + throw new DirFormatError(`outpoint vout ${e.ref.vout} out of range`) + } + } +} + +/** + * Encode a directory manifest to canonical bytes. + * + * Entries are sorted by raw name bytes; duplicates are rejected. Throws + * {@link DirFormatError} on any spec violation. + */ +export function dirEncode(manifest: DirManifest): Uint8Array { + if (manifest.version !== DIR_VERSION) { + throw new DirFormatError(`unsupported dir version ${manifest.version}`) + } + const entries = [...manifest.entries] + if (entries.length > MAX_DIR_ENTRIES) { + throw new DirFormatError(`entry count ${entries.length} exceeds uint16`) + } + entries.sort(dirEntryNameCompare) + for (let i = 1; i < entries.length; i++) { + if (compareBytes(entries[i - 1].name, entries[i].name) === 0) { + const dup = utf8Decoder.decode(entries[i].name) + throw new DirFormatError(`duplicate entry name: ${dup}`) + } + } + + const size = 3 + entries.reduce( + (acc, e) => acc + 2 + e.name.length + (e.ref.kind === 'same-tx' ? 1 : 36), + 0, + ) + const out = new Uint8Array(size) + const view = new DataView(out.buffer) + let p = 0 + out[p++] = manifest.version + view.setUint16(p, entries.length) + p += 2 + + for (const e of entries) { + validateEntry(e) + let flags = 0 + if (e.isDir) flags |= FLAG_DIR + if (e.exec) flags |= FLAG_EXEC + if (e.symlink) flags |= FLAG_SYMLINK + if (e.ref.kind === 'outpoint') flags |= FLAG_REFTYPE + out[p++] = flags + out[p++] = e.name.length + out.set(e.name, p) + p += e.name.length + if (e.ref.kind === 'same-tx') { + out[p++] = e.ref.vout + } else { + out.set(hexToBytes(e.ref.txid), p) + p += 32 + // native Bitcoin outpoint: vout is little-endian uint32 + view.setUint32(p, e.ref.vout, true) + p += 4 + } + } + return out +} + +/** + * Decode `ordfs/dir` bytes. Validates strictly: known version, canonical + * sorted order, unique names, zero reserved bits, sane name bytes. Throws + * {@link DirFormatError} on anything else — including non-canonical input. + */ +export function dirDecode(bytes: Uint8Array): DirManifest { + if (bytes.length < 3) { + throw new DirFormatError('dir manifest too short') + } + if (bytes[0] !== DIR_VERSION) { + throw new DirFormatError(`unsupported dir version ${bytes[0]}`) + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const count = view.getUint16(1) + const entries: DirEntry[] = [] + let p = 3 + + for (let i = 0; i < count; i++) { + if (p + 2 > bytes.length) { + throw new DirFormatError('truncated dir manifest') + } + const flags = bytes[p++] + if ((flags & FLAGS_RESERVED) !== 0) { + throw new DirFormatError('reserved flag bits must be zero') + } + const nameLen = bytes[p++] + if (nameLen < 1 || p + nameLen > bytes.length) { + throw new DirFormatError('truncated or empty entry name') + } + const name = bytes.subarray(p, p + nameLen) + if (name.includes(0x00) || name.includes(0x2f)) { + throw new DirFormatError('entry name contains NUL or "/"') + } + p += nameLen + if (i > 0 && compareBytes(entries[i - 1].name, name) >= 0) { + throw new DirFormatError( + 'entries not in canonical sorted order (or duplicate name)', + ) + } + + let ref: DirRef + if ((flags & FLAG_REFTYPE) === 0) { + if (p + 1 > bytes.length) throw new DirFormatError('truncated vout') + ref = { kind: 'same-tx', vout: bytes[p++] } + } else { + if (p + 36 > bytes.length) throw new DirFormatError('truncated outpoint') + const txid = toHex(bytes.subarray(p, p + 32)) + p += 32 + const vout = view.getUint32(p, true) + p += 4 + ref = { kind: 'outpoint', txid, vout } + } + + entries.push({ + name, + isDir: (flags & FLAG_DIR) !== 0, + exec: (flags & FLAG_EXEC) !== 0, + symlink: (flags & FLAG_SYMLINK) !== 0, + ref, + }) + } + + if (p !== bytes.length) { + throw new DirFormatError( + `trailing bytes after last entry (${bytes.length - p})`, + ) + } + return { version: DIR_VERSION, entries } +} + +/** Helper: UTF-8 encode an entry name. Validates component legality. */ +export function dirName(name: string): Uint8Array { + const bytes = utf8Encoder.encode(name) + if (bytes.length < 1 || bytes.length > 255) { + throw new DirFormatError(`name "${name}" byte length out of range 1..255`) + } + if (name.includes('\0') || name.includes('/')) { + throw new DirFormatError(`name "${name}" must not contain NUL or "/"`) + } + return bytes +} + +/** Helper: UTF-8 decode an entry name. */ +export function dirNameString(name: Uint8Array): string { + return utf8Decoder.decode(name) +} + +const DOT = utf8Encoder.encode('.') +const INDEX_HTML = utf8Encoder.encode('index.html') + +/** + * Default file for a directory with no remaining path: an entry named `.`, + * else `index.html`. Same convention as `ord-fs/json`. + */ +export function dirDefault(manifest: DirManifest): DirEntry | undefined { + let index: DirEntry | undefined + for (const e of manifest.entries) { + if (compareBytes(e.name, DOT) === 0) return e + if (!index && compareBytes(e.name, INDEX_HTML) === 0) index = e + } + return index +} diff --git a/src/ordfs/patch.ts b/src/ordfs/patch.ts new file mode 100644 index 0000000..f946b5e --- /dev/null +++ b/src/ordfs/patch.ts @@ -0,0 +1,132 @@ +import { VcdiffError, vcdiffDecode, vcdiffEncode } from './vcdiff.js' + +/** Content type written on `ordfs/patch` inscription outputs. */ +export const PATCH_CONTENT_TYPE = 'ordfs/patch' + +/** Current patch envelope version. */ +export const PATCH_VERSION = 0 + +export class PatchFormatError extends Error { + constructor(message: string) { + super(message) + this.name = 'PatchFormatError' + } +} + +/** Native Bitcoin outpoint (txid bytes as hex + little-endian vout). */ +export interface PatchOutpoint { + txid: string + vout: number +} + +export interface PatchRecord { + version: number + base: PatchOutpoint + delta: Uint8Array +} + +const toHex = (b: Uint8Array): string => + Array.from(b) + .map((x) => x.toString(16).padStart(2, '0')) + .join('') + +const hexToBytes = (hex: string): Uint8Array => { + if (!/^([0-9a-fA-F]{2})*$/.test(hex) || hex.length !== 64) { + throw new PatchFormatError(`invalid txid hex: ${hex}`) + } + const out = new Uint8Array(32) + for (let i = 0; i < 32; i++) { + out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return out +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} + +/** + * Encode an `ordfs/patch` envelope: + * `[1B version][36B base outpoint][vcdiff delta]`. + */ +export function patchEncode(record: PatchRecord): Uint8Array { + if (record.version !== PATCH_VERSION) { + throw new PatchFormatError(`unsupported patch version ${record.version}`) + } + if ( + !Number.isInteger(record.base.vout) || + record.base.vout < 0 || + record.base.vout > 0xffffffff + ) { + throw new PatchFormatError(`outpoint vout ${record.base.vout} out of range`) + } + if (record.delta.length < 5) { + throw new PatchFormatError('empty vcdiff delta is invalid') + } + const out = new Uint8Array(1 + 36 + record.delta.length) + const view = new DataView(out.buffer) + out[0] = record.version + out.set(hexToBytes(record.base.txid), 1) + view.setUint32(33, record.base.vout, true) + out.set(record.delta, 37) + return out +} + +export function patchDecode(bytes: Uint8Array): PatchRecord { + if (bytes.length < 1 + 36 + 5) { + throw new PatchFormatError('patch too short') + } + if (bytes[0] !== PATCH_VERSION) { + throw new PatchFormatError(`unsupported patch version ${bytes[0]}`) + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const txid = toHex(bytes.subarray(1, 33)) + const vout = view.getUint32(33, true) + const delta = bytes.subarray(37) + if (delta.length < 5) { + throw new PatchFormatError('empty vcdiff delta is invalid') + } + return { + version: PATCH_VERSION, + base: { txid, vout }, + delta, + } +} + +/** + * Build a patch from source/target bytes. Identical content must be a + * direct citation (manifest entry), never an empty/no-op patch. + */ +export async function patchFromContent(opts: { + base: PatchOutpoint + source: Uint8Array + target: Uint8Array +}): Promise { + if (bytesEqual(opts.source, opts.target)) { + throw new PatchFormatError( + 'identical content must be a direct citation, never a patch', + ) + } + const delta = await vcdiffEncode(opts.target, opts.source) + return patchEncode({ + version: PATCH_VERSION, + base: opts.base, + delta, + }) +} + +export async function patchApply( + record: PatchRecord, + source: Uint8Array, +): Promise { + try { + return await vcdiffDecode(record.delta, source) + } catch (e) { + if (e instanceof VcdiffError) { + throw new PatchFormatError(e.message) + } + throw e + } +} diff --git a/src/ordfs/vcdiff.ts b/src/ordfs/vcdiff.ts new file mode 100644 index 0000000..4945af2 --- /dev/null +++ b/src/ordfs/vcdiff.ts @@ -0,0 +1,88 @@ +import { + init, + xd3_decode_memory, + xd3_encode_memory, + xd3_smatch_cfg, +} from 'xdelta3-wasm' + +/** + * RFC 3284 VCDIFF encode/decode via xdelta3-wasm. + * + * On-chain profile (must match xdelta3 CLI ` -e -n -S none -A `): + * header D6 C3 C4 00, Hdr_Indicator 0 (no secondary compression, + * no custom code table, no app header). Decoders reject anything else. + */ + +export class VcdiffError extends Error { + constructor(message: string) { + super(message) + this.name = 'VcdiffError' + } +} + +const ENOSPC = 51 +const PLAIN_MAX_GROW = 8 + +let ready: Promise | undefined + +export function vcdiffReady(): Promise { + if (!ready) ready = init() + return ready +} + +function assertPlainRfc(delta: Uint8Array): void { + if (delta.length < 5) { + throw new VcdiffError('vcdiff delta too short') + } + if (delta[0] !== 0xd6 || delta[1] !== 0xc3 || delta[2] !== 0xc4) { + throw new VcdiffError('bad vcdiff magic') + } + if (delta[3] !== 0x00) { + throw new VcdiffError(`unsupported vcdiff version ${delta[3]}`) + } + if (delta[4] !== 0x00) { + throw new VcdiffError( + `vcdiff Hdr_Indicator ${delta[4]} must be 0 (no secondary compression, no app header)`, + ) + } +} + +export async function vcdiffEncode( + target: Uint8Array, + source: Uint8Array = new Uint8Array(0), +): Promise { + await vcdiffReady() + let max = Math.max(64, target.length + 1024) + for (let i = 0; i < PLAIN_MAX_GROW; i++) { + const r = xd3_encode_memory(target, source, max, xd3_smatch_cfg.DEFAULT) + if (r.ret === 0) { + assertPlainRfc(r.output) + return r.output + } + if (r.ret === ENOSPC) { + max *= 2 + continue + } + throw new VcdiffError(`vcdiff encode failed: ${r.str} (${r.ret})`) + } + throw new VcdiffError('vcdiff encode failed: output too large') +} + +export async function vcdiffDecode( + delta: Uint8Array, + source: Uint8Array = new Uint8Array(0), +): Promise { + await vcdiffReady() + assertPlainRfc(delta) + let max = Math.max(source.length * 2, 1 << 20) + for (let i = 0; i < PLAIN_MAX_GROW; i++) { + const r = xd3_decode_memory(delta, source, max) + if (r.ret === 0) return r.output + if (r.ret === ENOSPC) { + max *= 2 + continue + } + throw new VcdiffError(`vcdiff decode failed: ${r.str} (${r.ret})`) + } + throw new VcdiffError('vcdiff decode failed: output too large') +} diff --git a/src/outpoint.ts b/src/outpoint.ts new file mode 100644 index 0000000..d1d0832 --- /dev/null +++ b/src/outpoint.ts @@ -0,0 +1,28 @@ +export type Outpoint = { + txid: string + vout: number +} + +const TXID = /^[0-9a-f]{64}$/ + +export function parseOutpoint(s: string): Outpoint { + const u = s.indexOf('_') + const d = u < 0 ? s.indexOf('.') : u + if (d !== 64) throw new Error(`bad outpoint: ${s}`) + const txid = s.slice(0, 64).toLowerCase() + const vout = Number(s.slice(d + 1)) + if (!TXID.test(txid) || !Number.isInteger(vout) || vout < 0) { + throw new Error(`bad outpoint: ${s}`) + } + return { txid, vout } +} + +export function formatOutpoint(op: Outpoint, sep: '_' | '.' = '_'): string { + return `${op.txid.toLowerCase()}${sep}${op.vout}` +} + +export function normalizeTxid(txid: string): string { + const t = txid.toLowerCase() + if (!TXID.test(t)) throw new Error(`bad txid: ${txid}`) + return t +} diff --git a/src/pending.ts b/src/pending.ts new file mode 100644 index 0000000..acaa6b7 --- /dev/null +++ b/src/pending.ts @@ -0,0 +1,51 @@ +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { defaultGibHome } from './txstore.ts' + +export type PendingTx = { phase: 'content' | 'head'; txid: string; bytes: Uint8Array } + +function dir(sha: string, home?: string): string { + return join(home ?? defaultGibHome(), 'pending', sha) +} + +export async function savePending(sha: string, txs: PendingTx[], home?: string): Promise { + const d = dir(sha, home) + await mkdir(d, { recursive: true }) + const recs = txs.map((t) => ({ + phase: t.phase, + txid: t.txid, + hex: Buffer.from(t.bytes).toString('hex'), + })) + await writeFile(join(d, 'txs.json'), JSON.stringify(recs)) +} + +export async function loadPending(sha: string, home?: string): Promise { + try { + const raw = JSON.parse(await readFile(join(dir(sha, home), 'txs.json'), 'utf8')) as Array<{ + phase: 'content' | 'head' + txid: string + hex: string + }> + return raw.map((t) => ({ + phase: t.phase, + txid: t.txid, + bytes: new Uint8Array(Buffer.from(t.hex, 'hex')), + })) + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw e + } +} + +export async function clearPending(sha: string, home?: string): Promise { + await rm(dir(sha, home), { recursive: true, force: true }) +} + +export async function listPending(home?: string): Promise { + try { + return await readdir(join(home ?? defaultGibHome(), 'pending')) + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw e + } +} diff --git a/src/publish-local.ts b/src/publish-local.ts new file mode 100644 index 0000000..53edb56 --- /dev/null +++ b/src/publish-local.ts @@ -0,0 +1,35 @@ +import { PrivateKey, ProtoWallet, Transaction } from '@bsv/sdk' +import type { CommitPlan } from './cascade.ts' +import type { Publisher, PublishedTx } from './publish.ts' +import { GIT_COMMIT_TYPE, appendOrdEnvelope, bLockingScript } from './script.ts' +import { sealCommitLock } from './seal.ts' + +export function localPublisher(wallet = new ProtoWallet(new PrivateKey(4242))): Publisher { + return { + async publishContent(plan: CommitPlan): Promise { + const tx = new Transaction() + for (const o of plan.outputs) { + tx.addOutput({ + satoshis: 0, + lockingScript: bLockingScript(o.contentType, o.bytes), + }) + } + return { txid: tx.id('hex'), bytes: new Uint8Array(tx.toBinary()) } + }, + async publishHead(opts) { + const pd = await sealCommitLock(wallet, opts.token) + const locking = appendOrdEnvelope(pd, GIT_COMMIT_TYPE, opts.commitBytes) + const tx = new Transaction() + tx.addOutput({ satoshis: 1, lockingScript: locking }) + return { txid: tx.id('hex'), bytes: new Uint8Array(tx.toBinary()) } + }, + async burnHead() { + const tx = new Transaction() + tx.addOutput({ + satoshis: 1, + lockingScript: bLockingScript('text/plain', new Uint8Array([1])), + }) + return { txid: tx.id('hex'), bytes: new Uint8Array(tx.toBinary()) } + }, + } +} diff --git a/src/publish.ts b/src/publish.ts new file mode 100644 index 0000000..61b9aba --- /dev/null +++ b/src/publish.ts @@ -0,0 +1,182 @@ +import { + type CreateActionResult, + LockingScript, + PushDrop, + Transaction, + type WalletInterface, +} from '@bsv/sdk' +import type { CommitPlan } from './cascade.ts' +import { GIT_COMMIT_TYPE, appendOrdEnvelope, bLockingScript } from './script.ts' +import { commitHeadCustomInstructions, sealCommitLock } from './seal.ts' +import { + GIB_BASKET, + GIB_PROTOCOL, + branchTag, + gibKeyId, + originTag, + pushLabel, + type CommitToken, +} from './token.ts' + +export type PublishedTx = { txid: string; bytes: Uint8Array } + +export type Publisher = { + publishContent(plan: CommitPlan, labels: string[]): Promise + publishHead(opts: { + token: CommitToken + commitBytes: Uint8Array + labels: string[] + tags: string[] + spend?: { outpoint: string; beef: number[] } + }): Promise + burnHead(opts: { + outpoint: string + beef: number[] + keyID: string + labels: string[] + }): Promise +} + +function rawTxFromResult(r: CreateActionResult): PublishedTx { + if (!r.txid) throw new Error('createAction returned no txid') + if (!r.tx?.length) throw new Error('createAction returned no tx bytes') + const beef = Array.from(r.tx) + const tx = Transaction.fromBEEF(beef) + return { txid: r.txid, bytes: new Uint8Array(tx.toBinary()) } +} + +export function walletPublisher(wallet: WalletInterface): Publisher { + return { + async publishContent(plan, labels) { + const outputs = plan.outputs.map((o, i) => ({ + lockingScript: bLockingScript(o.contentType, o.bytes).toHex(), + satoshis: 0, + outputDescription: o.path ?? `gib content ${i}`, + })) + const r = await wallet.createAction({ + description: `gib content ${labels[0] ?? ''}`.slice(0, 50), + outputs, + labels, + options: { randomizeOutputs: false, signAndProcess: true }, + }) + return rawTxFromResult(r) + }, + async publishHead(opts) { + const pd = await sealCommitLock(wallet, opts.token) + const locking = appendOrdEnvelope(pd, GIT_COMMIT_TYPE, opts.commitBytes) + const r = await wallet.createAction({ + description: `gib head ${opts.token.branch}`.slice(0, 50), + ...(opts.spend ? { inputBEEF: opts.spend.beef } : {}), + inputs: opts.spend + ? [ + { + outpoint: opts.spend.outpoint, + inputDescription: 'gib commit token', + unlockingScriptLength: 73, + }, + ] + : undefined, + outputs: [ + { + lockingScript: locking.toHex(), + satoshis: 1, + outputDescription: 'gib commit head', + basket: GIB_BASKET, + tags: opts.tags, + customInstructions: commitHeadCustomInstructions(opts.token.root), + }, + ], + labels: opts.labels, + options: { randomizeOutputs: false, signAndProcess: !opts.spend }, + }) + if (r.txid) return rawTxFromResult(r) + if (!r.signableTransaction || !opts.spend) { + throw new Error('unexpected createAction response for commit head') + } + const { reference, tx: txBeef } = r.signableTransaction + try { + const tx = Transaction.fromBEEF(txBeef) + const want = opts.spend.outpoint.split('.')[0] + const idx = tx.inputs.findIndex((i) => (i.sourceTXID ?? '') === want) + if (idx < 0) throw new Error('token input missing from funded tx') + const input = tx.inputs[idx] + const src = input.sourceTransaction?.outputs[input.sourceOutputIndex] + if (!src) throw new Error('token input source missing') + const unlock = new PushDrop(wallet).unlock( + GIB_PROTOCOL, + gibKeyId(opts.token.root), + 'anyone', + 'all', + false, + src.satoshis ?? 1, + src.lockingScript, + ) + const script = await unlock.sign(tx, idx) + const signed = await wallet.signAction({ + reference, + spends: { [idx]: { unlockingScript: script.toHex() } }, + options: { acceptDelayedBroadcast: false }, + }) + if (!signed.txid || !signed.tx) throw new Error('signAction returned no tx') + const signedTx = Transaction.fromBEEF(Array.from(signed.tx)) + return { txid: signed.txid, bytes: new Uint8Array(signedTx.toBinary()) } + } catch (e) { + await wallet.abortAction({ reference }).catch(() => {}) + throw e + } + }, + async burnHead(opts) { + const r = await wallet.createAction({ + description: 'gib burn ref'.slice(0, 50), + inputBEEF: opts.beef, + inputs: [ + { + outpoint: opts.outpoint, + inputDescription: 'gib commit token burn', + unlockingScriptLength: 73, + }, + ], + labels: opts.labels, + options: { signAndProcess: false }, + }) + if (!r.signableTransaction) throw new Error('burn: expected signable tx') + const { reference, tx: txBeef } = r.signableTransaction + try { + const tx = Transaction.fromBEEF(txBeef) + const want = opts.outpoint.split('.')[0] + const idx = tx.inputs.findIndex((i) => (i.sourceTXID ?? '') === want) + if (idx < 0) throw new Error('burn input missing') + const input = tx.inputs[idx] + const src = input.sourceTransaction?.outputs[input.sourceOutputIndex] + if (!src) throw new Error('burn source missing') + const unlock = new PushDrop(wallet).unlock( + GIB_PROTOCOL, + opts.keyID, + 'anyone', + 'all', + false, + src.satoshis ?? 1, + src.lockingScript, + ) + const script = await unlock.sign(tx, idx) + const signed = await wallet.signAction({ + reference, + spends: { [idx]: { unlockingScript: script.toHex() } }, + options: { acceptDelayedBroadcast: false }, + }) + if (!signed.txid || !signed.tx) throw new Error('signAction returned no tx') + const signedTx = Transaction.fromBEEF(Array.from(signed.tx)) + return { txid: signed.txid, bytes: new Uint8Array(signedTx.toBinary()) } + } catch (e) { + await wallet.abortAction({ reference }).catch(() => {}) + throw e + } + }, + } +} + +export function headTags(origin: string, branch: string): string[] { + return [originTag(origin), branchTag(branch)] +} + +export { LockingScript } diff --git a/src/push.ts b/src/push.ts new file mode 100644 index 0000000..87ee0a9 --- /dev/null +++ b/src/push.ts @@ -0,0 +1,169 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { planCommit } from './cascade.ts' +import { + commitBytes, + filesAtCommit, + isAncestor, + parsePushLine, + revParse, +} from './gitread.ts' +import { formatOutpoint, parseOutpoint } from './outpoint.ts' +import { clearPending, savePending } from './pending.ts' +import { headTags, type Publisher } from './publish.ts' +import { advertise } from './remote/advertise.ts' +import { validateRoot } from './validate.ts' +import { + branchTag, + decodeCommitToken, + GIB_BASKET, + originTag, + pushLabel, +} from './token.ts' +import type { TxStore } from './txstore.ts' +import type { WalletInterface } from '@bsv/sdk' + +export type PushResult = + | { ok: true; dst: string; origin: string; sha: string } + | { ok: false; dst: string; error: string } + +export async function pushLine(opts: { + line: string + gitDir: string + store: TxStore + wallet: WalletInterface + publisher: Publisher + origin: string + home?: string +}): Promise { + const spec = parsePushLine(opts.line) + const branch = spec.dst.replace(/^refs\/heads\//, '') + if (spec.del) { + return burnRef(opts, spec.dst, branch) + } + try { + const sha = await revParse(opts.gitDir, spec.src) + const commit = await commitBytes(opts.gitDir, sha) + const files = await filesAtCommit(opts.gitDir, sha) + const refs = await advertise(opts.wallet, opts.store, opts.origin) + const current = refs.find((r) => r.name === spec.dst || r.name === `refs/heads/${branch}`) + if (current && current.sha !== sha) { + const ff = await isAncestor(opts.gitDir, current.sha, sha) + if (!ff && !spec.force) { + return { ok: false, dst: spec.dst, error: 'non-fast-forward' } + } + if (current.sha !== (await remoteSha(opts, branch))) { + return { ok: false, dst: spec.dst, error: 'token sha mismatch' } + } + } + + const prev = await currentToken(opts, branch) + const genesis = !prev && (opts.origin === '' || opts.origin === 'new') + const plan = await planCommit({ + files, + prevRoot: prev ? parseOutpoint(prev.root) : undefined, + store: prev ? opts.store : undefined, + }) + const labels = [pushLabel(sha)] + const content = await opts.publisher.publishContent(plan, labels) + await opts.store.put(content.txid, content.bytes) + await savePending(sha, [{ phase: 'content', txid: content.txid, bytes: content.bytes }], opts.home) + + const root = { txid: content.txid, vout: plan.rootIndex } + const scratch = await mkdtemp(join(tmpdir(), 'gib-val-')) + await validateRoot(opts.store, root, commit, scratch) + + const origin = genesis ? formatOutpoint(root, '_') : prev?.origin ?? opts.origin + const { publicKey } = await opts.wallet.getPublicKey({ identityKey: true }) + const token = { + origin, + branch, + root: formatOutpoint(root, '_'), + identityPubkey: publicKey, + } + const spend = prev + ? await loadSpend(opts.wallet, prev.outpoint) + : undefined + const head = await opts.publisher.publishHead({ + token, + commitBytes: commit, + labels, + tags: headTags(origin, branch), + spend, + }) + await opts.store.put(head.txid, head.bytes) + await clearPending(sha, opts.home) + return { ok: true, dst: spec.dst, origin, sha } + } catch (e) { + return { + ok: false, + dst: spec.dst, + error: e instanceof Error ? e.message : String(e), + } + } +} + +async function burnRef( + opts: { + wallet: WalletInterface + publisher: Publisher + store: TxStore + origin: string + }, + dst: string, + branch: string, +): Promise { + const prev = await currentToken(opts, branch) + if (!prev) return { ok: false, dst, error: 'no such ref' } + const spend = await loadSpend(opts.wallet, prev.outpoint) + if (!spend) return { ok: false, dst, error: 'token not in wallet' } + await opts.publisher.burnHead({ + outpoint: spend.outpoint, + beef: spend.beef, + keyID: prev.root, + labels: [pushLabel('delete')], + }) + return { ok: true, dst, origin: opts.origin, sha: '0000000000000000000000000000000000000000' } +} + +async function currentToken( + opts: { wallet: WalletInterface; origin: string }, + branch: string, +): Promise<{ origin: string; root: string; outpoint: string } | undefined> { + if (opts.origin === '' || opts.origin === 'new') return undefined + const listed = await opts.wallet.listOutputs({ + basket: GIB_BASKET, + tags: [originTag(opts.origin), branchTag(branch)], + tagQueryMode: 'all', + include: 'locking scripts', + limit: 1, + }) + const o = listed.outputs?.[0] + if (!o?.lockingScript) return undefined + const t = decodeCommitToken(o.lockingScript) + return { origin: t.origin, root: t.root, outpoint: o.outpoint } +} + +async function remoteSha( + opts: { wallet: WalletInterface; store: TxStore; origin: string }, + branch: string, +): Promise { + const refs = await advertise(opts.wallet, opts.store, opts.origin) + return refs.find((r) => r.name === `refs/heads/${branch}`)?.sha +} + +async function loadSpend( + wallet: WalletInterface, + outpoint: string, +): Promise<{ outpoint: string; beef: number[] } | undefined> { + const listed = await wallet.listOutputs({ + basket: GIB_BASKET, + include: 'entire transactions', + limit: 100, + }) + const want = outpoint.replace('_', '.') + const hit = listed.outputs?.find((o) => o.outpoint === want) + if (!hit || !listed.BEEF?.length) return undefined + return { outpoint: want, beef: Array.from(listed.BEEF) } +} diff --git a/src/recovery.ts b/src/recovery.ts new file mode 100644 index 0000000..d953dd8 --- /dev/null +++ b/src/recovery.ts @@ -0,0 +1,42 @@ +import type { WalletInterface } from '@bsv/sdk' +import { pushLabel } from './token.ts' +import type { TxStore } from './txstore.ts' + +export type PushAction = { + txid?: string + status?: string + reference?: string +} + +export type RecoveryPlan = + | { kind: 'resume'; txid: string } + | { kind: 'refetch'; txid: string } + | { kind: 'abort'; reference: string } + | { kind: 'unknown' } + +export async function recoverPush( + wallet: WalletInterface, + store: TxStore, + sha: string, +): Promise { + const listed = await wallet.listActions({ + labels: [pushLabel(sha)], + labelQueryMode: 'any', + }) + const actions = (listed.actions ?? []) as PushAction[] + const plans: RecoveryPlan[] = [] + for (const a of actions) { + if (a.txid) { + const have = await store.get(a.txid) + if (have) plans.push({ kind: 'resume', txid: a.txid }) + else plans.push({ kind: 'refetch', txid: a.txid }) + continue + } + if (a.reference && (a.status === 'unsigned' || a.status === 'nosend')) { + plans.push({ kind: 'abort', reference: a.reference }) + continue + } + plans.push({ kind: 'unknown' }) + } + return plans +} diff --git a/src/remote/advertise.ts b/src/remote/advertise.ts new file mode 100644 index 0000000..cf0e9d1 --- /dev/null +++ b/src/remote/advertise.ts @@ -0,0 +1,60 @@ +import type { WalletInterface } from '@bsv/sdk' +import { payloadFromScript } from '../content.ts' +import { gitHash } from '../git.ts' +import { parseOutpoint } from '../outpoint.ts' +import { loadTx } from '../resolver.ts' +import { + branchTag, + decodeCommitToken, + GIB_BASKET, + originTag, +} from '../token.ts' +import type { TxStore } from '../txstore.ts' + +export function originFromUrl(url: string): string { + return url.replace(/^gib:\/\//, '').replace(/\/$/, '') +} + +export async function advertise( + wallet: WalletInterface, + store: TxStore, + origin: string, +): Promise> { + if (!origin || origin === 'new') return [] + const listed = await wallet.listOutputs({ + basket: GIB_BASKET, + tags: [originTag(origin)], + tagQueryMode: 'all', + include: 'locking scripts', + limit: 10000, + }) + const refs: Array<{ sha: string; name: string }> = [] + for (const o of listed.outputs ?? []) { + if (!o.lockingScript) continue + let token: ReturnType + try { + token = decodeCommitToken(o.lockingScript) + } catch { + continue + } + if (token.origin !== origin) continue + const tags = o.tags ?? [] + if (!tags.includes(branchTag(token.branch))) continue + const op = parseOutpoint(o.outpoint.replace('.', '_')) + const sha = await commitShaFromHead(store, op) + refs.push({ sha, name: `refs/heads/${token.branch}` }) + } + return refs +} + +async function commitShaFromHead( + store: TxStore, + op: ReturnType, +): Promise { + const tx = await loadTx(store, op.txid) + const out = tx.outputs[op.vout] + if (!out) throw new Error(`missing commit head ${op.txid}_${op.vout}`) + const payload = payloadFromScript(out.lockingScript) + if (!payload) throw new Error('commit head has no inscription') + return gitHash('commit', payload.bytes) +} diff --git a/src/remote/helper.ts b/src/remote/helper.ts new file mode 100644 index 0000000..6bc2368 --- /dev/null +++ b/src/remote/helper.ts @@ -0,0 +1,130 @@ +import type { WalletInterface } from '@bsv/sdk' +import { payloadFromScript } from '../content.ts' +import { parseOutpoint } from '../outpoint.ts' +import { loadTx, resolveOutpoint } from '../resolver.ts' +import { collectTree, materializeGit } from '../tree.ts' +import { decodeCommitToken } from '../token.ts' +import { pushLine } from '../push.ts' +import { walletPublisher } from '../publish.ts' +import { advertise, originFromUrl } from './advertise.ts' +import type { TxStore } from '../txstore.ts' + +export type HelperIo = { + read: () => Promise + write: (s: string) => void +} + +export { advertise, originFromUrl } + +export async function runHelper(opts: { + url: string + store: TxStore + wallet?: WalletInterface + gitDir: string + io: HelperIo + home?: string +}): Promise { + const origin = originFromUrl(opts.url) + for (;;) { + const line = await opts.io.read() + if (line === null) return + const cmd = line.trim() + if (cmd === 'capabilities') { + opts.io.write('fetch\npush\noption\n\n') + continue + } + if (cmd === 'list' || cmd === 'list for-push') { + const refs = opts.wallet + ? await advertise(opts.wallet, opts.store, origin) + : [] + for (const r of refs) opts.io.write(`${r.sha} ${r.name}\n`) + opts.io.write('\n') + continue + } + if (cmd.startsWith('option ')) { + opts.io.write('unsupported\n') + continue + } + if (cmd.startsWith('fetch ')) { + const fetches = [cmd, ...(await readUntilBlank(opts.io))] + if (!opts.wallet) throw new Error('fetch requires wallet') + const refs = await advertise(opts.wallet, opts.store, origin) + for (const f of fetches) { + const sha = f.split(' ')[1] + const hit = refs.find((r) => r.sha === sha) + if (!hit) throw new Error(`unknown sha ${sha}`) + const listed = await opts.wallet.listOutputs({ + basket: 'gib', + tags: [ + `origin:${origin}`, + `branch:${hit.name.replace('refs/heads/', '')}`, + ], + tagQueryMode: 'all', + include: 'locking scripts', + limit: 1, + }) + const o = listed.outputs?.[0] + if (!o) throw new Error(`no token for ${hit.name}`) + await importCommit(opts.store, opts.gitDir, o.outpoint.replace('.', '_')) + } + opts.io.write('\n') + continue + } + if (cmd.startsWith('push ')) { + const pushes = [cmd, ...(await readUntilBlank(opts.io))] + if (!opts.wallet) { + for (const p of pushes) { + const dst = p.slice(p.lastIndexOf(':') + 1) + opts.io.write(`error ${dst} no wallet\n`) + } + opts.io.write('\n') + continue + } + const publisher = walletPublisher(opts.wallet) + for (const p of pushes) { + const r = await pushLine({ + line: p, + gitDir: opts.gitDir, + store: opts.store, + wallet: opts.wallet, + publisher, + origin, + home: opts.home, + }) + if (r.ok) opts.io.write(`ok ${r.dst}\n`) + else opts.io.write(`error ${r.dst} ${r.error}\n`) + } + opts.io.write('\n') + continue + } + if (cmd === '') continue + } +} + +async function readUntilBlank(io: HelperIo): Promise { + const lines: string[] = [] + for (;;) { + const line = await io.read() + if (line === null || line.trim() === '') break + lines.push(line.trim()) + } + return lines +} + +export async function importCommit( + store: TxStore, + gitDir: string, + headOutpoint: string, +): Promise<{ commit: string; tree: string }> { + const op = parseOutpoint(headOutpoint) + const tx = await loadTx(store, op.txid) + const out = tx.outputs[op.vout] + if (!out) throw new Error(`missing head ${headOutpoint}`) + const payload = payloadFromScript(out.lockingScript) + if (!payload) throw new Error('commit head has no inscription') + const token = decodeCommitToken(out.lockingScript) + const root = parseOutpoint(token.root) + await resolveOutpoint(store, root) + const files = await collectTree(store, root) + return materializeGit(gitDir, files, payload.bytes) +} diff --git a/src/resolver.ts b/src/resolver.ts new file mode 100644 index 0000000..6bab0a6 --- /dev/null +++ b/src/resolver.ts @@ -0,0 +1,134 @@ +import { Transaction } from '@bsv/sdk' +import { payloadFromScript } from './content.ts' +import { + DIR_CONTENT_TYPE, + dirDecode, + dirDefault, + dirNameString, + type DirEntry, + JSON_MANIFEST_CONTENT_TYPE_LEGACY, +} from './ordfs/dir.ts' +import { + PATCH_CONTENT_TYPE, + patchApply, + patchDecode, +} from './ordfs/patch.ts' +import { formatOutpoint, type Outpoint, parseOutpoint } from './outpoint.ts' +import type { TxStore } from './txstore.ts' + +export const MAX_DIRECTORY_DEPTH = 8 + +export type Resolved = { + contentType: string + bytes: Uint8Array + outpoint: Outpoint +} + +export class ResolveError extends Error { + constructor(message: string) { + super(message) + this.name = 'ResolveError' + } +} + +export async function loadTx( + store: TxStore, + txid: string, +): Promise { + const bytes = await store.get(txid) + if (!bytes) throw new ResolveError(`missing tx ${txid}`) + return Transaction.fromBinary(Array.from(bytes)) +} + +export async function resolveOutpoint( + store: TxStore, + op: Outpoint, + seen = new Set(), +): Promise { + const key = formatOutpoint(op) + if (seen.has(key)) throw new ResolveError(`patch cycle at ${key}`) + const tx = await loadTx(store, op.txid) + const out = tx.outputs[op.vout] + if (!out) throw new ResolveError(`missing output ${formatOutpoint(op)}`) + const payload = payloadFromScript(out.lockingScript) + if (!payload) { + throw new ResolveError(`no content at ${formatOutpoint(op)}`) + } + if (payload.contentType === PATCH_CONTENT_TYPE) { + seen.add(key) + const rec = patchDecode(payload.bytes) + const base = await resolveOutpoint(store, rec.base, seen) + const bytes = await patchApply(rec, base.bytes) + return { contentType: base.contentType, bytes, outpoint: op } + } + return { + contentType: payload.contentType, + bytes: payload.bytes, + outpoint: op, + } +} + +export async function resolvePath( + store: TxStore, + root: Outpoint, + path = '', +): Promise { + const segments = path.split('/').filter((s) => s.length > 0) + return walkDir(store, root, segments, 0) +} + +async function walkDir( + store: TxStore, + op: Outpoint, + segments: string[], + depth: number, +): Promise { + if (depth > MAX_DIRECTORY_DEPTH) { + throw new ResolveError('max directory depth exceeded') + } + const node = await resolveOutpoint(store, op) + const isDir = + node.contentType === DIR_CONTENT_TYPE || + node.contentType === JSON_MANIFEST_CONTENT_TYPE_LEGACY + if (!isDir) { + if (segments.length) { + throw new ResolveError(`not a directory: ${formatOutpoint(op)}`) + } + return node + } + if (node.contentType === JSON_MANIFEST_CONTENT_TYPE_LEGACY) { + throw new ResolveError('legacy ord-fs/json not implemented in resolver') + } + const manifest = dirDecode(node.bytes) + if (!segments.length) { + const def = dirDefault(manifest) + if (!def) return node + return walkEntry(store, op.txid, def, [], depth) + } + const name = segments[0] + const entry = manifest.entries.find((e) => dirNameString(e.name) === name) + if (!entry) throw new ResolveError(`no entry "${name}"`) + return walkEntry(store, op.txid, entry, segments.slice(1), depth) +} + +async function walkEntry( + store: TxStore, + parentTxid: string, + entry: DirEntry, + rest: string[], + depth: number, +): Promise { + const child: Outpoint = + entry.ref.kind === 'same-tx' + ? { txid: parentTxid, vout: entry.ref.vout } + : { txid: entry.ref.txid.toLowerCase(), vout: entry.ref.vout } + if (entry.isDir) return walkDir(store, child, rest, depth + 1) + if (rest.length) { + throw new ResolveError(`not a directory: ${dirNameString(entry.name)}`) + } + return resolveOutpoint(store, child) +} + +export function parseRoot(s: string): Outpoint { + return parseOutpoint(s) +} diff --git a/src/script.ts b/src/script.ts new file mode 100644 index 0000000..5d9392a --- /dev/null +++ b/src/script.ts @@ -0,0 +1,33 @@ +import { LockingScript, OP, Script, Utils } from '@bsv/sdk' +import { B_PREFIX } from './content.ts' + +export function bLockingScript(contentType: string, body: Uint8Array): LockingScript { + const s = new Script() + s.writeOpCode(OP.OP_FALSE) + s.writeOpCode(OP.OP_RETURN) + s.writeBin(Utils.toArray(B_PREFIX, 'utf8')) + s.writeBin(Array.from(body)) + s.writeBin(Utils.toArray(contentType, 'utf8')) + s.writeBin(Utils.toArray('binary', 'utf8')) + return LockingScript.fromBinary(s.toBinary()) +} + +export function appendOrdEnvelope( + prefix: Script, + contentType: string, + body: Uint8Array, +): LockingScript { + const s = new Script() + for (const c of prefix.chunks) s.chunks.push(c) + s.writeOpCode(OP.OP_FALSE) + s.writeOpCode(OP.OP_IF) + s.writeBin(Utils.toArray('ord', 'utf8')) + s.writeOpCode(OP.OP_1) + s.writeBin(Utils.toArray(contentType, 'utf8')) + s.writeOpCode(OP.OP_0) + s.writeBin(Array.from(body)) + s.writeOpCode(OP.OP_ENDIF) + return LockingScript.fromBinary(s.toBinary()) +} + +export const GIT_COMMIT_TYPE = 'application/x-git-commit' diff --git a/src/seal.ts b/src/seal.ts new file mode 100644 index 0000000..8187bdc --- /dev/null +++ b/src/seal.ts @@ -0,0 +1,29 @@ +import { PushDrop, type WalletInterface } from '@bsv/sdk' +import { + type CommitToken, + commitTokenFields, + GIB_PROTOCOL, + gibKeyId, +} from './token.ts' + +export async function sealCommitLock( + wallet: WalletInterface, + token: CommitToken, +) { + return new PushDrop(wallet).lock( + commitTokenFields(token), + GIB_PROTOCOL, + gibKeyId(token.root), + 'anyone', + true, + true, + ) +} + +export function commitHeadCustomInstructions(root: string): string { + return JSON.stringify({ + protocolID: GIB_PROTOCOL, + keyID: gibKeyId(root), + counterparty: 'anyone', + }) +} diff --git a/src/token.ts b/src/token.ts new file mode 100644 index 0000000..c7af502 --- /dev/null +++ b/src/token.ts @@ -0,0 +1,78 @@ +import { LockingScript, OP, PushDrop, Script, Utils } from '@bsv/sdk' + +export const GIB_PROTOCOL: [0 | 1 | 2, string] = [1, 'gib branch'] +export const GIB_BASKET = 'gib' +export const GIB_FIELD0 = 'gib' + +export type CommitToken = { + origin: string + branch: string + root: string + identityPubkey: string +} + +export function gibKeyId(rootOutpoint: string): string { + return rootOutpoint +} + +export function originTag(origin: string): string { + return `origin:${origin}` +} + +export function branchTag(branch: string): string { + return `branch:${branch}` +} + +export function pushLabel(sha: string): string { + return `push:${sha}` +} + +function prefixBeforeOrd(script: Script): Script { + const chunks = script.chunks + for (let i = 0; i < chunks.length - 2; i++) { + if ( + chunks[i]?.op === OP.OP_0 && + chunks[i + 1]?.op === OP.OP_IF && + chunks[i + 2]?.data != null && + chunks[i + 2].data.length === 3 && + Utils.toUTF8(chunks[i + 2].data) === 'ord' + ) { + const p = new Script() + for (let j = 0; j < i; j++) p.chunks.push(chunks[j]) + return p + } + } + return script +} + +export function decodeCommitToken(lockingScript: LockingScript | string): CommitToken { + const script = + typeof lockingScript === 'string' + ? LockingScript.fromHex(lockingScript) + : lockingScript + const { fields } = PushDrop.decode(prefixBeforeOrd(script)) + const str = (i: number) => { + const f = fields[i] + if (!f) throw new Error(`commit token missing field ${i}`) + return Utils.toUTF8(f) + } + if (str(0) !== GIB_FIELD0) { + throw new Error(`not a gib commit token (field0=${str(0)})`) + } + return { + origin: str(1), + branch: str(2), + root: str(3), + identityPubkey: str(4), + } +} + +export function commitTokenFields(t: CommitToken): number[][] { + return [ + Utils.toArray(GIB_FIELD0, 'utf8'), + Utils.toArray(t.origin, 'utf8'), + Utils.toArray(t.branch, 'utf8'), + Utils.toArray(t.root, 'utf8'), + Utils.toArray(t.identityPubkey, 'utf8'), + ] +} diff --git a/src/tree.ts b/src/tree.ts new file mode 100644 index 0000000..67c6b35 --- /dev/null +++ b/src/tree.ts @@ -0,0 +1,101 @@ +import { DIR_CONTENT_TYPE, dirDecode, dirNameString } from './ordfs/dir.ts' +import { formatOutpoint, type Outpoint } from './outpoint.ts' +import { resolveOutpoint } from './resolver.ts' +import type { TxStore } from './txstore.ts' +import { encodeTree, gitHash, treeEntryMode, writeGitObject } from './git.ts' + +export type FileEntry = { + path: string + bytes: Uint8Array + contentType: string + outpoint: Outpoint + exec?: boolean + symlink?: boolean +} + +export async function collectTree( + store: TxStore, + root: Outpoint, + prefix = '', +): Promise { + const node = await resolveOutpoint(store, root) + if (node.contentType !== DIR_CONTENT_TYPE) { + return [ + { + path: prefix || formatOutpoint(root), + bytes: node.bytes, + contentType: node.contentType, + outpoint: node.outpoint, + }, + ] + } + const manifest = dirDecode(node.bytes) + const out: FileEntry[] = [] + for (const e of manifest.entries) { + const name = dirNameString(e.name) + const child: Outpoint = + e.ref.kind === 'same-tx' + ? { txid: root.txid, vout: e.ref.vout } + : { txid: e.ref.txid.toLowerCase(), vout: e.ref.vout } + if (e.isDir) { + out.push(...(await collectTree(store, child, prefix ? `${prefix}/${name}` : name))) + } else { + const file = await resolveOutpoint(store, child) + out.push({ + path: prefix ? `${prefix}/${name}` : name, + bytes: file.bytes, + contentType: file.contentType, + outpoint: file.outpoint, + exec: e.exec, + symlink: e.symlink, + }) + } + } + return out +} + +export async function materializeGit( + gitDir: string, + files: FileEntry[], + commitBytes: Uint8Array, +): Promise<{ commit: string; tree: string }> { + type Node = { files: FileEntry[]; dirs: Map } + const root: Node = { files: [], dirs: new Map() } + for (const f of files) { + const parts = f.path.split('/') + let n = root + for (let i = 0; i < parts.length - 1; i++) { + let d = n.dirs.get(parts[i]) + if (!d) { + d = { files: [], dirs: new Map() } + n.dirs.set(parts[i], d) + } + n = d + } + n.files.push(f) + } + + async function writeNode(n: Node): Promise { + const entries: Array<{ mode: string; name: string; sha: string }> = [] + for (const [name, child] of n.dirs) { + const sha = await writeNode(child) + entries.push({ mode: treeEntryMode({ dir: true }), name, sha }) + } + for (const f of n.files) { + const sha = await writeGitObject(gitDir, 'blob', f.bytes) + const name = f.path.split('/').pop() ?? f.path + entries.push({ + mode: treeEntryMode({ exec: f.exec, symlink: f.symlink }), + name, + sha, + }) + } + const tree = encodeTree(entries) + return writeGitObject(gitDir, 'tree', tree) + } + + const tree = await writeNode(root) + const commit = gitHash('commit', commitBytes) + await writeGitObject(gitDir, 'commit', commitBytes) + return { commit, tree } +} diff --git a/src/txstore.ts b/src/txstore.ts new file mode 100644 index 0000000..136da8d --- /dev/null +++ b/src/txstore.ts @@ -0,0 +1,48 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' +import { Transaction } from '@bsv/sdk' +import { normalizeTxid } from './outpoint.ts' + +export type TxStore = { + get(txid: string): Promise + put(txid: string, signedBytes: Uint8Array): Promise +} + +export function defaultGibHome(): string { + return process.env.GIB_HOME ?? join(homedir(), '.gib') +} + +export function fileTxStore(root?: string): TxStore { + const base = join(root ?? defaultGibHome(), 'txstore') + return { + async get(txid) { + const id = normalizeTxid(txid) + try { + return new Uint8Array(await readFile(shardPath(base, id))) + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw e + } + }, + async put(txid, signedBytes) { + const id = normalizeTxid(txid) + const got = txidOf(signedBytes) + if (got !== id) { + throw new Error(`txstore put: bytes hash to ${got}, not ${id}`) + } + const path = shardPath(base, id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, signedBytes) + }, + } +} + +export function txidOf(signedBytes: Uint8Array): string { + const tx = Transaction.fromBinary(Array.from(signedBytes)) + return tx.id('hex') +} + +function shardPath(base: string, txid: string): string { + return join(base, txid.slice(0, 2), txid.slice(2, 4), txid) +} diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..38b5577 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,25 @@ +import { gitHash } from './git.ts' +import { treeShaFromCommit } from './gitread.ts' +import { formatOutpoint, type Outpoint } from './outpoint.ts' +import { collectTree, materializeGit } from './tree.ts' +import type { TxStore } from './txstore.ts' + +export async function validateRoot( + store: TxStore, + root: Outpoint, + commitBytes: Uint8Array, + scratchGitDir: string, +): Promise { + const files = await collectTree(store, root) + const got = await materializeGit(scratchGitDir, files, commitBytes) + const expectTree = treeShaFromCommit(commitBytes) + if (got.tree !== expectTree) { + throw new Error( + `validation: resolved tree ${got.tree} != commit tree ${expectTree} (root ${formatOutpoint(root)})`, + ) + } + const expectCommit = gitHash('commit', commitBytes) + if (got.commit !== expectCommit) { + throw new Error(`validation: commit hash ${got.commit} != ${expectCommit}`) + } +} diff --git a/src/wallet.ts b/src/wallet.ts new file mode 100644 index 0000000..b0aea95 --- /dev/null +++ b/src/wallet.ts @@ -0,0 +1,8 @@ +import { HTTPWalletJSON, type WalletInterface } from '@bsv/sdk' + +export const DEFAULT_WALLET_URL = 'http://127.0.0.1:3321' +export const ORIGINATOR = 'gib' + +export function connectWallet(url = DEFAULT_WALLET_URL): WalletInterface { + return new HTTPWalletJSON(ORIGINATOR, url) +} diff --git a/test/cascade.test.ts b/test/cascade.test.ts new file mode 100644 index 0000000..74174b0 --- /dev/null +++ b/test/cascade.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'bun:test' +import { planCommit } from '../src/cascade.ts' +import { DIR_CONTENT_TYPE, dirDecode, dirNameString } from '../src/ordfs/dir.ts' +import { PATCH_CONTENT_TYPE } from '../src/ordfs/patch.ts' +import { bScript, memStore, txWithOutputs } from './helpers.ts' + +const enc = (s: string) => new TextEncoder().encode(s) + +describe('planCommit', () => { + it('genesis: leaves then root dir', async () => { + const plan = await planCommit({ + files: [ + { path: 'README.md', bytes: enc('hi'), contentType: 'text/markdown' }, + { path: 'src/a.ts', bytes: enc('a'), contentType: 'text/plain' }, + ], + }) + expect(plan.outputs[plan.rootIndex].contentType).toBe(DIR_CONTENT_TYPE) + const root = dirDecode(plan.outputs[plan.rootIndex].bytes) + expect(root.entries.map((e) => dirNameString(e.name))).toEqual(['README.md', 'src']) + const files = plan.outputs.filter((o) => o.contentType !== DIR_CONTENT_TYPE) + expect(files).toHaveLength(2) + }) + + it('unchanged file is cited, not patched', async () => { + const body = enc('same') + const genesis = await planCommit({ + files: [{ path: 'f', bytes: body, contentType: 'text/plain' }], + }) + const scripts = genesis.outputs.map((o) => bScript(o.contentType, o.bytes)) + const { txid, bytes } = txWithOutputs(scripts) + const store = memStore() + await store.put(txid, bytes) + const next = await planCommit({ + files: [ + { path: 'f', bytes: body, contentType: 'text/plain' }, + { path: 'g', bytes: enc('new'), contentType: 'text/plain' }, + ], + prevRoot: { txid, vout: genesis.rootIndex }, + store, + }) + const patches = next.outputs.filter((o) => o.contentType === PATCH_CONTENT_TYPE) + expect(patches).toHaveLength(0) + const root = dirDecode(next.outputs[next.rootIndex].bytes) + const f = root.entries.find((e) => dirNameString(e.name) === 'f') + expect(f?.ref.kind).toBe('outpoint') + }) +}) diff --git a/test/git.test.ts b/test/git.test.ts new file mode 100644 index 0000000..61d3366 --- /dev/null +++ b/test/git.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { gitHash, writeGitObject } from '../src/git.ts' + +describe('git objects', () => { + it('hashes a blob like git hash-object', () => { + const body = new TextEncoder().encode('hello\n') + expect(gitHash('blob', body)).toBe('ce013625030ba8dba906f756967f9e9ca394464a') + }) + + it('writes a loose object git can see', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gib-git-')) + try { + await Bun.spawn(['git', 'init', '-q', dir]).exited + const gitDir = join(dir, '.git') + const sha = await writeGitObject( + gitDir, + 'blob', + new TextEncoder().encode('hello\n'), + ) + expect(sha).toBe('ce013625030ba8dba906f756967f9e9ca394464a') + const proc = Bun.spawn( + ['git', '--git-dir', gitDir, 'cat-file', '-p', sha], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + expect(err).toBe('') + expect(code).toBe(0) + expect(out).toBe('hello\n') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/test/gitread.test.ts b/test/gitread.test.ts new file mode 100644 index 0000000..86f6da0 --- /dev/null +++ b/test/gitread.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { gitHash } from '../src/git.ts' +import { + commitBytes, + filesAtCommit, + parsePushLine, + revParse, + treeShaFromCommit, +} from '../src/gitread.ts' + +async function git(cwd: string, args: string[]) { + const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' }) + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(err || out) + return out.trim() +} + +describe('gitread', () => { + it('reads commit bytes and files', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gib-gr-')) + try { + await git(dir, ['init', '-q', '-b', 'main']) + await git(dir, ['config', 'user.email', 't@t']) + await git(dir, ['config', 'user.name', 't']) + await writeFile(join(dir, 'a.txt'), 'hello\n') + await git(dir, ['add', 'a.txt']) + await git(dir, ['commit', '-q', '-m', 'one']) + const gitDir = join(dir, '.git') + const sha = await revParse(gitDir, 'HEAD') + const body = await commitBytes(gitDir, sha) + expect(gitHash('commit', body)).toBe(sha) + expect(treeShaFromCommit(body)).toHaveLength(40) + const files = await filesAtCommit(gitDir, sha) + expect(files).toHaveLength(1) + expect(files[0].path).toBe('a.txt') + expect(new TextDecoder().decode(files[0].bytes)).toBe('hello\n') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('parses push specs', () => { + expect(parsePushLine('push refs/heads/main:refs/heads/main')).toEqual({ + force: false, + src: 'refs/heads/main', + dst: 'refs/heads/main', + del: false, + }) + expect(parsePushLine('push +refs/heads/main:refs/heads/main').force).toBe(true) + expect(parsePushLine('push :refs/heads/old').del).toBe(true) + }) +}) diff --git a/test/helper-protocol.test.ts b/test/helper-protocol.test.ts new file mode 100644 index 0000000..c6c9b07 --- /dev/null +++ b/test/helper-protocol.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test' +import { originFromUrl, runHelper } from '../src/remote/helper.ts' +import { memStore } from './helpers.ts' + +describe('git-remote-gib protocol', () => { + it('parses gib:// origin', () => { + expect(originFromUrl('gib://aa'.repeat(1) + '_0')).toContain('_0') + expect(originFromUrl('gib://abc_1/')).toBe('abc_1') + }) + + it('advertises capabilities', async () => { + const lines = ['capabilities', 'list', ''] + let i = 0 + const out: string[] = [] + await runHelper({ + url: 'gib://aa'.repeat(32).slice(0, 6 + 64) + '_0', + store: memStore(), + gitDir: '/tmp', + io: { + async read() { + return i < lines.length ? lines[i++] : null + }, + write(s) { + out.push(s) + }, + }, + }) + expect(out.join('')).toContain('fetch') + expect(out.join('')).toContain('push') + }) +}) diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..200bc24 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,51 @@ +import { OP, P2PKH, PrivateKey, Script, Transaction, Utils } from '@bsv/sdk' +import type { TxStore } from '../src/txstore.ts' + +export function memStore(seed?: Map): TxStore { + const m = seed ?? new Map() + return { + async get(txid) { + return m.get(txid.toLowerCase()) + }, + async put(txid, bytes) { + m.set(txid.toLowerCase(), bytes) + }, + } +} + +export function ordScript(contentType: string, body: Uint8Array): Script { + const s = new Script() + s.writeOpCode(OP.OP_FALSE) + s.writeOpCode(OP.OP_IF) + s.writeBin(Utils.toArray('ord', 'utf8')) + s.writeOpCode(OP.OP_1) + s.writeBin(Utils.toArray(contentType, 'utf8')) + s.writeOpCode(OP.OP_0) + s.writeBin(Array.from(body)) + s.writeOpCode(OP.OP_ENDIF) + return s +} + +export function bScript(contentType: string, body: Uint8Array): Script { + const s = new Script() + s.writeOpCode(OP.OP_FALSE) + s.writeOpCode(OP.OP_RETURN) + s.writeBin(Utils.toArray('19HxigV4QyBv3tHpQVcUEQyq1pzZVdoAut', 'utf8')) + s.writeBin(Array.from(body)) + s.writeBin(Utils.toArray(contentType, 'utf8')) + s.writeBin(Utils.toArray('binary', 'utf8')) + return s +} + +export function txWithOutputs(scripts: Script[]): { txid: string; bytes: Uint8Array } { + const tx = new Transaction() + const addr = PrivateKey.fromRandom().toPublicKey().toAddress() + for (const lockingScript of scripts) { + tx.addOutput({ satoshis: 1, lockingScript: lockingScript as never }) + } + if (!scripts.length) { + tx.addOutput({ satoshis: 1, lockingScript: new P2PKH().lock(addr) }) + } + const bin = tx.toBinary() + return { txid: tx.id('hex'), bytes: new Uint8Array(bin) } +} diff --git a/test/pending.test.ts b/test/pending.test.ts new file mode 100644 index 0000000..e862950 --- /dev/null +++ b/test/pending.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { clearPending, loadPending, savePending } from '../src/pending.ts' + +describe('pending cache', () => { + it('saves and clears', async () => { + const home = await mkdtemp(join(tmpdir(), 'gib-pend-')) + try { + await savePending( + 'abc', + [{ phase: 'content', txid: 'aa'.repeat(32), bytes: new Uint8Array([1, 2]) }], + home, + ) + const got = await loadPending('abc', home) + expect(got?.[0].txid).toBe('aa'.repeat(32)) + await clearPending('abc', home) + expect(await loadPending('abc', home)).toBeUndefined() + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/test/push.test.ts b/test/push.test.ts new file mode 100644 index 0000000..9f0f68e --- /dev/null +++ b/test/push.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { localPublisher } from '../src/publish-local.ts' +import { pushLine } from '../src/push.ts' +import { memStore } from './helpers.ts' + +async function git(cwd: string, args: string[]) { + const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' }) + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(err || out) + return out.trim() +} + +describe('pushLine genesis', () => { + it('publishes content, validates, seals head', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gib-push-')) + const home = await mkdtemp(join(tmpdir(), 'gib-home-')) + try { + await git(dir, ['init', '-q', '-b', 'main']) + await git(dir, ['config', 'user.email', 't@t']) + await git(dir, ['config', 'user.name', 't']) + await writeFile(join(dir, 'README.md'), '# x\n') + await git(dir, ['add', 'README.md']) + await git(dir, ['commit', '-q', '-m', 'init']) + const wallet = new ProtoWallet(new PrivateKey(4242)) + const store = memStore() + const r = await pushLine({ + line: 'push HEAD:refs/heads/main', + gitDir: join(dir, '.git'), + store, + wallet, + publisher: localPublisher(wallet), + origin: 'new', + home, + }) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.origin).toMatch(/_[0-9]+$/) + expect(r.sha).toHaveLength(40) + } + } finally { + await rm(dir, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/test/recovery.test.ts b/test/recovery.test.ts new file mode 100644 index 0000000..c801811 --- /dev/null +++ b/test/recovery.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'bun:test' +import type { WalletInterface } from '@bsv/sdk' +import { recoverPush } from '../src/recovery.ts' +import { memStore } from './helpers.ts' +import { txWithOutputs } from './helpers.ts' + +describe('recovery', () => { + it('resume when txstore has the signed bytes', async () => { + const { txid, bytes } = txWithOutputs([]) + const store = memStore() + await store.put(txid, bytes) + const wallet = { + async listActions() { + return { actions: [{ txid, status: 'completed' }] } + }, + } as unknown as WalletInterface + expect(await recoverPush(wallet, store, 'deadbeef')).toEqual([ + { kind: 'resume', txid }, + ]) + }) + + it('abort unsigned by reference', async () => { + const store = memStore() + const wallet = { + async listActions() { + return { actions: [{ reference: 'abc', status: 'unsigned' }] } + }, + } as unknown as WalletInterface + expect(await recoverPush(wallet, store, 'deadbeef')).toEqual([ + { kind: 'abort', reference: 'abc' }, + ]) + }) +}) diff --git a/test/resolver.test.ts b/test/resolver.test.ts new file mode 100644 index 0000000..6c27692 --- /dev/null +++ b/test/resolver.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test' +import { DIR_CONTENT_TYPE, dirEncode, dirName } from '../src/ordfs/dir.ts' +import { PATCH_CONTENT_TYPE, patchFromContent } from '../src/ordfs/patch.ts' +import { resolveOutpoint, resolvePath } from '../src/resolver.ts' +import { bScript, memStore, ordScript, txWithOutputs } from './helpers.ts' + +const enc = (s: string) => new TextEncoder().encode(s) + +describe('resolver', () => { + it('resolves a B file payload', async () => { + const body = enc('hello') + const { txid, bytes } = txWithOutputs([bScript('text/plain', body)]) + const store = memStore() + await store.put(txid, bytes) + const r = await resolveOutpoint(store, { txid, vout: 0 }) + expect(r.contentType).toBe('text/plain') + expect(r.bytes).toEqual(body) + }) + + it('walks ordfs/dir same-tx children and default entry', async () => { + const readme = enc('# hi') + const manifest = dirEncode({ + version: 1, + entries: [ + { + name: dirName('index.html'), + isDir: false, + ref: { kind: 'same-tx', vout: 0 }, + }, + ], + }) + const { txid, bytes } = txWithOutputs([ + ordScript('text/html', readme), + ordScript(DIR_CONTENT_TYPE, manifest), + ]) + const store = memStore() + await store.put(txid, bytes) + const def = await resolvePath(store, { txid, vout: 1 }, '') + expect(def.bytes).toEqual(readme) + const named = await resolvePath(store, { txid, vout: 1 }, 'index.html') + expect(named.bytes).toEqual(readme) + }) + + it('applies ordfs/patch against a base', async () => { + const source = enc('hello world, this is the base file') + const target = enc('hello world, this is the base file PLUS') + const baseTx = txWithOutputs([ordScript('text/plain', source)]) + const patch = await patchFromContent({ + base: { txid: baseTx.txid, vout: 0 }, + source, + target, + }) + const patchTx = txWithOutputs([ordScript(PATCH_CONTENT_TYPE, patch)]) + const store = memStore() + await store.put(baseTx.txid, baseTx.bytes) + await store.put(patchTx.txid, patchTx.bytes) + const r = await resolveOutpoint(store, { txid: patchTx.txid, vout: 0 }) + expect(r.contentType).toBe('text/plain') + expect(r.bytes).toEqual(target) + }) +}) diff --git a/test/token.test.ts b/test/token.test.ts new file mode 100644 index 0000000..5123bb6 --- /dev/null +++ b/test/token.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'bun:test' +import { PrivateKey, ProtoWallet, PushDrop } from '@bsv/sdk' +import { sealCommitLock } from '../src/seal.ts' +import { decodeCommitToken } from '../src/token.ts' + +describe('commit token', () => { + it('seals and decodes fields', async () => { + const wallet = new ProtoWallet(new PrivateKey(9001)) + const { publicKey } = await wallet.getPublicKey({ identityKey: true }) + const token = { + origin: `${'ab'.repeat(32)}_0`, + branch: 'main', + root: `${'cd'.repeat(32)}_1`, + identityPubkey: publicKey, + } + const script = await sealCommitLock(wallet, token) + const got = decodeCommitToken(script) + expect(got.origin).toBe(token.origin) + expect(got.branch).toBe('main') + expect(got.root).toBe(token.root) + expect(PushDrop.decode(script).fields.length).toBeGreaterThanOrEqual(5) + }) +}) diff --git a/test/txstore.test.ts b/test/txstore.test.ts new file mode 100644 index 0000000..d061e8b --- /dev/null +++ b/test/txstore.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileTxStore, txidOf } from '../src/txstore.ts' +import { txWithOutputs } from './helpers.ts' + +describe('txstore', () => { + it('put verifies txid and get round-trips', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gib-tx-')) + try { + const store = fileTxStore(dir) + const { txid, bytes } = txWithOutputs([]) + expect(txidOf(bytes)).toBe(txid) + await store.put(txid, bytes) + expect(await store.get(txid)).toEqual(bytes) + expect(await store.get('aa'.repeat(32))).toBeUndefined() + await expect(store.put('bb'.repeat(32), bytes)).rejects.toThrow(/hash to/) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0f8724c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["bun"] + }, + "include": ["src", "test"] +} From 566fb777c16575cfd58f530cca6872d33e7b0a53 Mon Sep 17 00:00:00 2001 From: David Case Date: Fri, 18 Sep 2026 20:11:50 -0400 Subject: [PATCH 02/34] fix: ordfs outpoints use internal txid byte order Match Bitcoin tx-input layout and @1sat/templates. Spec text now states internal-order txid + LE vout. --- docs/plans/ordfs-formats.html | 5 ++-- src/ordfs/dir.ts | 47 +++++++++++++++-------------------- src/ordfs/outpoint.ts | 45 +++++++++++++++++++++++++++++++++ src/ordfs/patch.ts | 44 +++++++++++++++----------------- 4 files changed, 88 insertions(+), 53 deletions(-) create mode 100644 src/ordfs/outpoint.ts diff --git a/docs/plans/ordfs-formats.html b/docs/plans/ordfs-formats.html index bd047af..c2816f7 100644 --- a/docs/plans/ordfs-formats.html +++ b/docs/plans/ordfs-formats.html @@ -64,7 +64,8 @@

    ordfs/dir — binary directory manifest

    [target] REFTYPE 0: [1B vout] sibling output in the same tx REFTYPE 1: [32B txid][4B vout] exact Bitcoin outpoint bytes - (vout little-endian, as in a tx input) + (txid internal/LE byte order, + vout little-endian, as in a tx input)

    Entry size = 3 + name + (1 or 36). A directory is 3 + Σ(entries). Example — root with LICENSE from an earlier tx and three same-tx children (sorted: LICENSE, README.md, package.json, src):

    @@ -92,7 +93,7 @@

    Invariants (canonical form)

    ordfs/patch — record

    patch :=
       [1B  version]            0x00
    -  [36B base outpoint]      native Bitcoin outpoint bytes (txid + LE vout)
    +  [36B base outpoint]      native Bitcoin outpoint (internal-order txid + LE vout)
       [..  vcdiff delta]       RFC 3284 — standard header (D6 C3 C4 00) + windows
    • Base is any content — ordinal, B-protocol output, or another diff --git a/src/ordfs/dir.ts b/src/ordfs/dir.ts index b57e496..ca64fe2 100644 --- a/src/ordfs/dir.ts +++ b/src/ordfs/dir.ts @@ -25,12 +25,14 @@ * [target] * REFTYPE 0: [1B vout] sibling output in the same tx * REFTYPE 1: [32B txid][4B vout] exact Bitcoin outpoint bytes - * (vout little-endian) + * (txid internal order, vout LE) * * Spec: docs/plans/ordfs-formats.html in the gib repo. Writers MUST emit * canonical form; readers MUST reject anything else. */ +import { outpointFromWire, outpointToWire } from './outpoint.js' + /** Content type written on `ordfs/dir` inscription outputs. */ export const DIR_CONTENT_TYPE = 'ordfs/dir' @@ -95,22 +97,6 @@ export class DirFormatError extends Error { const utf8Encoder = new TextEncoder() const utf8Decoder = new TextDecoder('utf-8', { fatal: false }) -const toHex = (b: Uint8Array): string => - Array.from(b) - .map((x) => x.toString(16).padStart(2, '0')) - .join('') - -const hexToBytes = (hex: string): Uint8Array => { - if (!/^([0-9a-fA-F]{2})*$/.test(hex) || hex.length !== 64) { - throw new DirFormatError(`invalid txid hex: ${hex}`) - } - const out = new Uint8Array(32) - for (let i = 0; i < 32; i++) { - out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) - } - return out -} - /** Compare two byte arrays lexicographically (unsigned bytes). Returns <0, 0, >0. */ function compareBytes(a: Uint8Array, b: Uint8Array): number { const n = Math.min(a.length, b.length) @@ -203,11 +189,14 @@ export function dirEncode(manifest: DirManifest): Uint8Array { if (e.ref.kind === 'same-tx') { out[p++] = e.ref.vout } else { - out.set(hexToBytes(e.ref.txid), p) - p += 32 - // native Bitcoin outpoint: vout is little-endian uint32 - view.setUint32(p, e.ref.vout, true) - p += 4 + try { + out.set(outpointToWire(e.ref.txid, e.ref.vout), p) + } catch (err) { + throw new DirFormatError( + err instanceof Error ? err.message : 'invalid outpoint', + ) + } + p += 36 } } return out @@ -259,11 +248,15 @@ export function dirDecode(bytes: Uint8Array): DirManifest { ref = { kind: 'same-tx', vout: bytes[p++] } } else { if (p + 36 > bytes.length) throw new DirFormatError('truncated outpoint') - const txid = toHex(bytes.subarray(p, p + 32)) - p += 32 - const vout = view.getUint32(p, true) - p += 4 - ref = { kind: 'outpoint', txid, vout } + try { + const op = outpointFromWire(bytes.subarray(p, p + 36)) + ref = { kind: 'outpoint', txid: op.txid, vout: op.vout } + } catch (err) { + throw new DirFormatError( + err instanceof Error ? err.message : 'invalid outpoint', + ) + } + p += 36 } entries.push({ diff --git a/src/ordfs/outpoint.ts b/src/ordfs/outpoint.ts new file mode 100644 index 0000000..7370049 --- /dev/null +++ b/src/ordfs/outpoint.ts @@ -0,0 +1,45 @@ +/** Display-hex txid → 32-byte internal (tx-input) order. */ +export function txidToWire(hex: string): Uint8Array { + if (!/^([0-9a-fA-F]{2})*$/.test(hex) || hex.length !== 64) { + throw new Error(`invalid txid hex: ${hex}`) + } + const out = new Uint8Array(32) + for (let i = 0; i < 32; i++) { + out[31 - i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return out +} + +/** 32-byte internal order → display-hex txid. */ +export function txidFromWire(bytes: Uint8Array): string { + if (bytes.length !== 32) throw new Error('txid wire length must be 32') + let hex = '' + for (let i = 31; i >= 0; i--) { + hex += bytes[i].toString(16).padStart(2, '0') + } + return hex +} + +/** 36-byte outpoint: reversed txid + little-endian vout. */ +export function outpointToWire(txid: string, vout: number): Uint8Array { + if (!Number.isInteger(vout) || vout < 0 || vout > 0xffffffff) { + throw new Error(`outpoint vout ${vout} out of range`) + } + const out = new Uint8Array(36) + out.set(txidToWire(txid), 0) + const view = new DataView(out.buffer) + view.setUint32(32, vout, true) + return out +} + +export function outpointFromWire(bytes: Uint8Array): { + txid: string + vout: number +} { + if (bytes.length !== 36) throw new Error('outpoint wire length must be 36') + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + return { + txid: txidFromWire(bytes.subarray(0, 32)), + vout: view.getUint32(32, true), + } +} diff --git a/src/ordfs/patch.ts b/src/ordfs/patch.ts index f946b5e..53a9bd0 100644 --- a/src/ordfs/patch.ts +++ b/src/ordfs/patch.ts @@ -1,3 +1,4 @@ +import { outpointFromWire, outpointToWire } from './outpoint.js' import { VcdiffError, vcdiffDecode, vcdiffEncode } from './vcdiff.js' /** Content type written on `ordfs/patch` inscription outputs. */ @@ -13,7 +14,7 @@ export class PatchFormatError extends Error { } } -/** Native Bitcoin outpoint (txid bytes as hex + little-endian vout). */ +/** Display-hex txid + vout; wire form is internal-order txid + LE vout. */ export interface PatchOutpoint { txid: string vout: number @@ -25,22 +26,6 @@ export interface PatchRecord { delta: Uint8Array } -const toHex = (b: Uint8Array): string => - Array.from(b) - .map((x) => x.toString(16).padStart(2, '0')) - .join('') - -const hexToBytes = (hex: string): Uint8Array => { - if (!/^([0-9a-fA-F]{2})*$/.test(hex) || hex.length !== 64) { - throw new PatchFormatError(`invalid txid hex: ${hex}`) - } - const out = new Uint8Array(32) - for (let i = 0; i < 32; i++) { - out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) - } - return out -} - function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false @@ -65,11 +50,17 @@ export function patchEncode(record: PatchRecord): Uint8Array { if (record.delta.length < 5) { throw new PatchFormatError('empty vcdiff delta is invalid') } + let wire: Uint8Array + try { + wire = outpointToWire(record.base.txid, record.base.vout) + } catch (err) { + throw new PatchFormatError( + err instanceof Error ? err.message : 'invalid outpoint', + ) + } const out = new Uint8Array(1 + 36 + record.delta.length) - const view = new DataView(out.buffer) out[0] = record.version - out.set(hexToBytes(record.base.txid), 1) - view.setUint32(33, record.base.vout, true) + out.set(wire, 1) out.set(record.delta, 37) return out } @@ -81,16 +72,21 @@ export function patchDecode(bytes: Uint8Array): PatchRecord { if (bytes[0] !== PATCH_VERSION) { throw new PatchFormatError(`unsupported patch version ${bytes[0]}`) } - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) - const txid = toHex(bytes.subarray(1, 33)) - const vout = view.getUint32(33, true) + let base: PatchOutpoint + try { + base = outpointFromWire(bytes.subarray(1, 37)) + } catch (err) { + throw new PatchFormatError( + err instanceof Error ? err.message : 'invalid outpoint', + ) + } const delta = bytes.subarray(37) if (delta.length < 5) { throw new PatchFormatError('empty vcdiff delta is invalid') } return { version: PATCH_VERSION, - base: { txid, vout }, + base, delta, } } From 1dab62327473aa49c1a379c3b14e9c57fec674a3 Mon Sep 17 00:00:00 2001 From: David Case Date: Fri, 18 Sep 2026 22:19:28 -0400 Subject: [PATCH 03/34] fix: cascade cites untouched dirs, validate before broadcast, spend by id Previous head unlocks from CI keyID only. Empty payloads decode. collectTree fails on cycles. txstore atomic put + optional BEEF fetch. Recovery abort on unsigned push actions. Tree sort matches git. --- docs/plans/ordfs-formats.html | 8 ++- package.json | 1 + src/cascade.ts | 76 ++++++++++++++++--------- src/content.ts | 25 +++++---- src/git-remote-gib.ts | 4 +- src/git.ts | 20 +++++-- src/ids.ts | 46 +++++++++++++++ src/ordfs/patch.ts | 15 ++++- src/ordfs/vcdiff.ts | 52 +++++++++++------ src/preview.ts | 30 ++++++++++ src/publish-local.ts | 4 +- src/publish.ts | 21 ++++--- src/push.ts | 103 +++++++++++++++++++++++----------- src/recovery.ts | 1 + src/remote/helper.ts | 6 +- src/seal.ts | 6 +- src/token.ts | 7 ++- src/tree.ts | 52 ++++++++++++----- src/txstore.ts | 45 +++++++++++---- src/validate.ts | 5 -- test/cascade.test.ts | 25 +++++++++ test/content.test.ts | 17 ++++++ test/push.test.ts | 4 +- tsconfig.json | 1 + 24 files changed, 420 insertions(+), 154 deletions(-) create mode 100644 src/ids.ts create mode 100644 src/preview.ts create mode 100644 test/content.test.ts diff --git a/docs/plans/ordfs-formats.html b/docs/plans/ordfs-formats.html index c2816f7..1bb13ba 100644 --- a/docs/plans/ordfs-formats.html +++ b/docs/plans/ordfs-formats.html @@ -110,10 +110,12 @@

      ordfs/patch — record

    VCDIFF profile (on-chain contract)

    -

    The delta is RFC 3284 VCDIFF with a plain header. Writers MUST emit this -profile; readers MUST reject anything else. Equivalent to +

    The delta is RFC 3284 VCDIFF. Writers MUST emit a plain +header so every reader can apply it. Equivalent to xdelta3 -e -n -S none -A (and to xdelta3-wasm -xd3_encode_memory with flags 0).

    +xd3_encode_memory with flags 0). Readers apply any delta their +decoder can handle; they are not required to reject extra window features +they understand.

    header := D6 C3 C4 00 00
               magic | ver | Hdr_Indicator = 0
      diff --git a/package.json b/package.json index a0c15d9..eeae5f8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "test": "bun test", + "typecheck": "tsc --noEmit", "gib": "bun run src/main.ts", "git-remote-gib": "bun run src/git-remote-gib.ts" }, diff --git a/src/cascade.ts b/src/cascade.ts index 20361d1..8b912a3 100644 --- a/src/cascade.ts +++ b/src/cascade.ts @@ -1,7 +1,7 @@ import { DIR_CONTENT_TYPE, dirEncode, dirName, type DirEntry } from './ordfs/dir.ts' import { PATCH_CONTENT_TYPE, patchFromContent } from './ordfs/patch.ts' import type { Outpoint } from './outpoint.ts' -import { collectTree, type FileEntry } from './tree.ts' +import { collectSnapshot, type FileEntry } from './tree.ts' import type { TxStore } from './txstore.ts' export type IncomingFile = { @@ -39,20 +39,25 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { return true } +type DirRef = + | { kind: 'same-tx'; vout: number } + | { kind: 'outpoint'; txid: string; vout: number } + export async function planCommit(opts: { files: IncomingFile[] prevRoot?: Outpoint store?: TxStore }): Promise { const old = new Map() + const oldDirs = new Map() if (opts.prevRoot && opts.store) { - for (const f of await collectTree(opts.store, opts.prevRoot)) { - old.set(f.path, f) - } + const snap = await collectSnapshot(opts.store, opts.prevRoot) + for (const f of snap.files) old.set(f.path, f) + for (const [p, op] of snap.dirs) oldDirs.set(p, op) } const outputs: PlannedOutput[] = [] - const fileVout = new Map() + const fileVout = new Map() const touchedDirs = new Set(['']) for (const f of opts.files) { @@ -112,38 +117,55 @@ export async function planCommit(opts: { children.get(p)!.add(f.path) } - const sortedDirs = [...dirs].sort((a, b) => b.split('/').filter(Boolean).length - a.split('/').filter(Boolean).length) + const sortedDirs = [...dirs].sort( + (a, b) => b.split('/').filter(Boolean).length - a.split('/').filter(Boolean).length, + ) const dirVout = new Map() + const dirCite = new Map() for (const d of sortedDirs) { - if (!touchedDirs.has(d) && d !== '') continue + if (!touchedDirs.has(d) && d !== '') { + const prev = oldDirs.get(d) + if (prev) dirCite.set(d, prev) + continue + } const names = [...(children.get(d) ?? [])].sort() const entries: DirEntry[] = [] for (const name of names) { const base = basename(name) if (dirs.has(name)) { const v = dirVout.get(name) - if (v === undefined) continue - entries.push({ - name: dirName(base), - isDir: true, - ref: { kind: 'same-tx', vout: v }, - }) - } else { - const ref = fileVout.get(name) - if (!ref) continue - const file = opts.files.find((x) => x.path === name) - entries.push({ - name: dirName(base), - isDir: false, - exec: file?.exec, - symlink: file?.symlink, - ref: - ref.kind === 'same-tx' - ? { kind: 'same-tx', vout: ref.vout } - : { kind: 'outpoint', txid: ref.txid, vout: ref.vout }, - }) + if (v !== undefined) { + entries.push({ + name: dirName(base), + isDir: true, + ref: { kind: 'same-tx', vout: v }, + }) + continue + } + const cited = dirCite.get(name) + if (cited) { + entries.push({ + name: dirName(base), + isDir: true, + ref: { kind: 'outpoint', txid: cited.txid, vout: cited.vout }, + }) + } + continue } + const ref = fileVout.get(name) + if (!ref) continue + const file = opts.files.find((x) => x.path === name) + entries.push({ + name: dirName(base), + isDir: false, + exec: file?.exec, + symlink: file?.symlink, + ref: + ref.kind === 'same-tx' + ? { kind: 'same-tx', vout: ref.vout } + : { kind: 'outpoint', txid: ref.txid, vout: ref.vout }, + }) } dirVout.set(d, outputs.length) outputs.push({ diff --git a/src/content.ts b/src/content.ts index f67da78..4b2827b 100644 --- a/src/content.ts +++ b/src/content.ts @@ -19,12 +19,13 @@ function decodeOrd(script: Script): Payload | undefined { const a = chunks[i] const b = chunks[i + 1] const c = chunks[i + 2] + const cdata = c?.data if ( a?.op !== OP.OP_0 || b?.op !== OP.OP_IF || - c?.data == null || - c.data.length !== 3 || - Utils.toUTF8(c.data) !== 'ord' + cdata == null || + cdata.length !== 3 || + Utils.toUTF8(cdata) !== 'ord' ) { continue } @@ -47,10 +48,9 @@ function decodeOrd(script: Script): Payload | undefined { } pos++ if (pos >= chunks.length) break + const body = chunks[pos] const data = - chunks[pos]?.data != null - ? new Uint8Array(chunks[pos].data) - : new Uint8Array(0) + body?.data != null ? new Uint8Array(body.data) : new Uint8Array(0) pos++ if (fieldNum === 0) content = data else if (fieldNum === 1) { @@ -61,7 +61,6 @@ function decodeOrd(script: Script): Payload | undefined { } } } - if (content.length === 0) return undefined return { contentType, bytes: content } } return undefined @@ -79,11 +78,13 @@ function decodeB(script: Script): Payload | undefined { } const prefix = rest[0]?.data if (!prefix || Utils.toUTF8(prefix) !== B_PREFIX) return undefined - const data = rest[1]?.data - const type = rest[2]?.data - if (!data || !type) return undefined + const dataChunk = rest[1] + const typeChunk = rest[2] + if (dataChunk == null || typeChunk?.data == null) return undefined + const data = + dataChunk.data != null ? new Uint8Array(dataChunk.data) : new Uint8Array(0) return { - contentType: Utils.toUTF8(type), - bytes: new Uint8Array(data), + contentType: Utils.toUTF8(typeChunk.data), + bytes: data, } } diff --git a/src/git-remote-gib.ts b/src/git-remote-gib.ts index 9f82535..6663b3c 100644 --- a/src/git-remote-gib.ts +++ b/src/git-remote-gib.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { createInterface } from 'node:readline' import { connectWallet } from './wallet.ts' -import { fileTxStore } from './txstore.ts' +import { defaultFetchRawTx, fileTxStore } from './txstore.ts' import { runHelper } from './remote/helper.ts' const url = process.argv[3] ?? process.argv[2] @@ -15,7 +15,7 @@ const iter = rl[Symbol.asyncIterator]() await runHelper({ url, - store: fileTxStore(), + store: fileTxStore(undefined, defaultFetchRawTx()), wallet: connectWallet(), gitDir: process.env.GIT_DIR ?? '.git', io: { diff --git a/src/git.ts b/src/git.ts index 8c4346b..ff8e374 100644 --- a/src/git.ts +++ b/src/git.ts @@ -34,12 +34,22 @@ export function treeEntryMode(opts: { exec?: boolean; symlink?: boolean; dir?: b return '100644' } +function gitSortKey(mode: string, name: string): Uint8Array { + return new TextEncoder().encode(mode === '40000' ? `${name}/` : name) +} + +function compareBytes(a: Uint8Array, b: Uint8Array): number { + const n = Math.min(a.length, b.length) + for (let i = 0; i < n; i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return a.length - b.length +} + export function encodeTree(entries: Array<{ mode: string; name: string; sha: string }>): Uint8Array { - const sorted = [...entries].sort((a, b) => { - const an = a.mode === '40000' ? `${a.name}/` : a.name - const bn = b.mode === '40000' ? `${b.name}/` : b.name - return an < bn ? -1 : an > bn ? 1 : 0 - }) + const sorted = [...entries].sort((a, b) => + compareBytes(gitSortKey(a.mode, a.name), gitSortKey(b.mode, b.name)), + ) const parts: Uint8Array[] = [] let n = 0 for (const e of sorted) { diff --git a/src/ids.ts b/src/ids.ts new file mode 100644 index 0000000..bba68f4 --- /dev/null +++ b/src/ids.ts @@ -0,0 +1,46 @@ +import type { CreateActionArgs } from '@bsv/sdk' +import { Utils } from '@bsv/sdk' + +export function randomActionId(): string { + const bytes = new Uint8Array(8) + crypto.getRandomValues(bytes) + return Utils.toHex(Array.from(bytes)) +} + +export function stampManagedOutputIds(args: CreateActionArgs): string { + const existing = actionIdFromOutputTags(args) + const actionId = existing ?? randomActionId() + if (args.outputs) { + for (const [i, output] of args.outputs.entries()) { + if (!output.basket) continue + const tag = `id:${actionId}_${i}` + const tags = (output.tags ?? []).filter((t) => !t.startsWith('id:')) + output.tags = [...tags, tag] + } + } + return actionId +} + +export function idTagFromTags(tags: string[] | undefined): string | undefined { + return tags?.find((t) => t.startsWith('id:')) +} + +function actionIdFromOutputTags(args: CreateActionArgs): string | undefined { + const ids = new Set() + for (const output of args.outputs ?? []) { + for (const tag of output.tags ?? []) { + if (!tag.startsWith('id:')) continue + const rest = tag.slice(3) + const us = rest.lastIndexOf('_') + if (us <= 0) continue + const id = rest.slice(0, us) + const index = rest.slice(us + 1) + if (id && /^\d+$/.test(index)) ids.add(id) + } + } + if (ids.size === 0) return undefined + if (ids.size > 1) { + throw new Error(`outputs carry more than one action id (${[...ids].join(', ')})`) + } + return [...ids][0] +} diff --git a/src/ordfs/patch.ts b/src/ordfs/patch.ts index 53a9bd0..c09fb55 100644 --- a/src/ordfs/patch.ts +++ b/src/ordfs/patch.ts @@ -1,5 +1,10 @@ import { outpointFromWire, outpointToWire } from './outpoint.js' -import { VcdiffError, vcdiffDecode, vcdiffEncode } from './vcdiff.js' +import { + VcdiffError, + assertPlainRfc, + vcdiffDecode, + vcdiffEncode, +} from './vcdiff.js' /** Content type written on `ordfs/patch` inscription outputs. */ export const PATCH_CONTENT_TYPE = 'ordfs/patch' @@ -47,8 +52,12 @@ export function patchEncode(record: PatchRecord): Uint8Array { ) { throw new PatchFormatError(`outpoint vout ${record.base.vout} out of range`) } - if (record.delta.length < 5) { - throw new PatchFormatError('empty vcdiff delta is invalid') + try { + assertPlainRfc(record.delta) + } catch (err) { + throw new PatchFormatError( + err instanceof Error ? err.message : 'invalid vcdiff delta', + ) } let wire: Uint8Array try { diff --git a/src/ordfs/vcdiff.ts b/src/ordfs/vcdiff.ts index 4945af2..caa3846 100644 --- a/src/ordfs/vcdiff.ts +++ b/src/ordfs/vcdiff.ts @@ -1,16 +1,10 @@ -import { - init, - xd3_decode_memory, - xd3_encode_memory, - xd3_smatch_cfg, -} from 'xdelta3-wasm' - /** * RFC 3284 VCDIFF encode/decode via xdelta3-wasm. * - * On-chain profile (must match xdelta3 CLI ` -e -n -S none -A `): + * Writer profile (must match xdelta3 CLI ` -e -n -S none -A `): * header D6 C3 C4 00, Hdr_Indicator 0 (no secondary compression, - * no custom code table, no app header). Decoders reject anything else. + * no custom code table, no app header). + * Readers apply any delta the wasm decoder can handle. */ export class VcdiffError extends Error { @@ -20,17 +14,22 @@ export class VcdiffError extends Error { } } -const ENOSPC = 51 -const PLAIN_MAX_GROW = 8 +type Xd3 = typeof import('xdelta3-wasm') +let lib: Xd3 | undefined let ready: Promise | undefined -export function vcdiffReady(): Promise { - if (!ready) ready = init() - return ready +export async function vcdiffReady(): Promise { + if (!ready) { + ready = (async () => { + lib = await import('xdelta3-wasm') + await lib.init() + })() + } + await ready } -function assertPlainRfc(delta: Uint8Array): void { +export function assertPlainRfc(delta: Uint8Array): void { if (delta.length < 5) { throw new VcdiffError('vcdiff delta too short') } @@ -47,11 +46,19 @@ function assertPlainRfc(delta: Uint8Array): void { } } +const PLAIN_MAX_GROW = 8 + +function xd3(): Xd3 { + if (!lib) throw new VcdiffError('vcdiff not initialized') + return lib +} + export async function vcdiffEncode( target: Uint8Array, source: Uint8Array = new Uint8Array(0), ): Promise { await vcdiffReady() + const { xd3_encode_memory, xd3_smatch_cfg, WASI_ERRNO } = xd3() let max = Math.max(64, target.length + 1024) for (let i = 0; i < PLAIN_MAX_GROW; i++) { const r = xd3_encode_memory(target, source, max, xd3_smatch_cfg.DEFAULT) @@ -59,10 +66,15 @@ export async function vcdiffEncode( assertPlainRfc(r.output) return r.output } - if (r.ret === ENOSPC) { + if (r.ret === WASI_ERRNO.ENOSPC) { max *= 2 continue } + if (r.ret === WASI_ERRNO.ENOMEM || r.str === 'ENOMEM') { + throw new VcdiffError( + `vcdiff encode failed: input too large for xdelta3-wasm memory (${target.length} target bytes)`, + ) + } throw new VcdiffError(`vcdiff encode failed: ${r.str} (${r.ret})`) } throw new VcdiffError('vcdiff encode failed: output too large') @@ -74,14 +86,20 @@ export async function vcdiffDecode( ): Promise { await vcdiffReady() assertPlainRfc(delta) + const { xd3_decode_memory, WASI_ERRNO } = xd3() let max = Math.max(source.length * 2, 1 << 20) for (let i = 0; i < PLAIN_MAX_GROW; i++) { const r = xd3_decode_memory(delta, source, max) if (r.ret === 0) return r.output - if (r.ret === ENOSPC) { + if (r.ret === WASI_ERRNO.ENOSPC) { max *= 2 continue } + if (r.ret === WASI_ERRNO.ENOMEM || r.str === 'ENOMEM') { + throw new VcdiffError( + 'vcdiff decode failed: output too large for xdelta3-wasm memory', + ) + } throw new VcdiffError(`vcdiff decode failed: ${r.str} (${r.ret})`) } throw new VcdiffError('vcdiff decode failed: output too large') diff --git a/src/preview.ts b/src/preview.ts new file mode 100644 index 0000000..1f4a349 --- /dev/null +++ b/src/preview.ts @@ -0,0 +1,30 @@ +import { Transaction } from '@bsv/sdk' +import type { CommitPlan } from './cascade.ts' +import { bLockingScript } from './script.ts' +import type { Outpoint } from './outpoint.ts' +import type { TxStore } from './txstore.ts' + +export function previewContentStore( + plan: CommitPlan, + backing: TxStore, +): { store: TxStore; root: Outpoint; bytes: Uint8Array } { + const tx = new Transaction() + for (const o of plan.outputs) { + tx.addOutput({ + satoshis: 0, + lockingScript: bLockingScript(o.contentType, o.bytes), + }) + } + const bytes = new Uint8Array(tx.toBinary()) + const txid = tx.id('hex') + const store: TxStore = { + async get(id) { + if (id.toLowerCase() === txid) return bytes + return backing.get(id) + }, + async put(id, b) { + return backing.put(id, b) + }, + } + return { store, root: { txid, vout: plan.rootIndex }, bytes } +} diff --git a/src/publish-local.ts b/src/publish-local.ts index 53edb56..0b075cf 100644 --- a/src/publish-local.ts +++ b/src/publish-local.ts @@ -1,10 +1,10 @@ -import { PrivateKey, ProtoWallet, Transaction } from '@bsv/sdk' +import { Transaction } from '@bsv/sdk' import type { CommitPlan } from './cascade.ts' import type { Publisher, PublishedTx } from './publish.ts' import { GIT_COMMIT_TYPE, appendOrdEnvelope, bLockingScript } from './script.ts' import { sealCommitLock } from './seal.ts' -export function localPublisher(wallet = new ProtoWallet(new PrivateKey(4242))): Publisher { +export function localPublisher(wallet: Pick): Publisher { return { async publishContent(plan: CommitPlan): Promise { const tx = new Transaction() diff --git a/src/publish.ts b/src/publish.ts index 61b9aba..c1eb726 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -8,13 +8,12 @@ import { import type { CommitPlan } from './cascade.ts' import { GIT_COMMIT_TYPE, appendOrdEnvelope, bLockingScript } from './script.ts' import { commitHeadCustomInstructions, sealCommitLock } from './seal.ts' +import { stampManagedOutputIds } from './ids.ts' import { GIB_BASKET, GIB_PROTOCOL, branchTag, - gibKeyId, originTag, - pushLabel, type CommitToken, } from './token.ts' @@ -27,7 +26,7 @@ export type Publisher = { commitBytes: Uint8Array labels: string[] tags: string[] - spend?: { outpoint: string; beef: number[] } + spend?: { outpoint: string; beef: number[]; keyID: string } }): Promise burnHead(opts: { outpoint: string @@ -53,18 +52,22 @@ export function walletPublisher(wallet: WalletInterface): Publisher { satoshis: 0, outputDescription: o.path ?? `gib content ${i}`, })) - const r = await wallet.createAction({ + const args = { description: `gib content ${labels[0] ?? ''}`.slice(0, 50), outputs, labels, options: { randomizeOutputs: false, signAndProcess: true }, - }) + } + const r = await wallet.createAction(args) return rawTxFromResult(r) }, async publishHead(opts) { + if (opts.spend && !opts.spend.keyID) { + throw new Error('spend missing customInstructions keyID') + } const pd = await sealCommitLock(wallet, opts.token) const locking = appendOrdEnvelope(pd, GIT_COMMIT_TYPE, opts.commitBytes) - const r = await wallet.createAction({ + const args = { description: `gib head ${opts.token.branch}`.slice(0, 50), ...(opts.spend ? { inputBEEF: opts.spend.beef } : {}), inputs: opts.spend @@ -88,7 +91,9 @@ export function walletPublisher(wallet: WalletInterface): Publisher { ], labels: opts.labels, options: { randomizeOutputs: false, signAndProcess: !opts.spend }, - }) + } + stampManagedOutputIds(args) + const r = await wallet.createAction(args) if (r.txid) return rawTxFromResult(r) if (!r.signableTransaction || !opts.spend) { throw new Error('unexpected createAction response for commit head') @@ -104,7 +109,7 @@ export function walletPublisher(wallet: WalletInterface): Publisher { if (!src) throw new Error('token input source missing') const unlock = new PushDrop(wallet).unlock( GIB_PROTOCOL, - gibKeyId(opts.token.root), + opts.spend.keyID, 'anyone', 'all', false, diff --git a/src/push.ts b/src/push.ts index 87ee0a9..9b5fb56 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1,6 +1,7 @@ import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { WalletInterface } from '@bsv/sdk' import { planCommit } from './cascade.ts' import { commitBytes, @@ -9,9 +10,12 @@ import { parsePushLine, revParse, } from './gitread.ts' +import { idTagFromTags } from './ids.ts' import { formatOutpoint, parseOutpoint } from './outpoint.ts' -import { clearPending, savePending } from './pending.ts' +import { clearPending, loadPending, savePending } from './pending.ts' +import { previewContentStore } from './preview.ts' import { headTags, type Publisher } from './publish.ts' +import { recoverPush } from './recovery.ts' import { advertise } from './remote/advertise.ts' import { validateRoot } from './validate.ts' import { @@ -22,7 +26,6 @@ import { pushLabel, } from './token.ts' import type { TxStore } from './txstore.ts' -import type { WalletInterface } from '@bsv/sdk' export type PushResult = | { ok: true; dst: string; origin: string; sha: string } @@ -44,18 +47,19 @@ export async function pushLine(opts: { } try { const sha = await revParse(opts.gitDir, spec.src) + await recoverForPush(opts.wallet, opts.store, sha) + const commit = await commitBytes(opts.gitDir, sha) const files = await filesAtCommit(opts.gitDir, sha) const refs = await advertise(opts.wallet, opts.store, opts.origin) - const current = refs.find((r) => r.name === spec.dst || r.name === `refs/heads/${branch}`) + const current = refs.find( + (r) => r.name === spec.dst || r.name === `refs/heads/${branch}`, + ) if (current && current.sha !== sha) { const ff = await isAncestor(opts.gitDir, current.sha, sha) if (!ff && !spec.force) { return { ok: false, dst: spec.dst, error: 'non-fast-forward' } } - if (current.sha !== (await remoteSha(opts, branch))) { - return { ok: false, dst: spec.dst, error: 'token sha mismatch' } - } } const prev = await currentToken(opts, branch) @@ -66,14 +70,23 @@ export async function pushLine(opts: { store: prev ? opts.store : undefined, }) const labels = [pushLabel(sha)] - const content = await opts.publisher.publishContent(plan, labels) + const scratch = await mkdtemp(join(tmpdir(), 'gib-val-')) + const preview = previewContentStore(plan, opts.store) + await validateRoot(preview.store, preview.root, commit, scratch) + + const pending = await loadPending(sha, opts.home) + const resumed = pending?.find((t) => t.phase === 'content') + let content = resumed + ? { txid: resumed.txid, bytes: resumed.bytes } + : await opts.publisher.publishContent(plan, labels) await opts.store.put(content.txid, content.bytes) - await savePending(sha, [{ phase: 'content', txid: content.txid, bytes: content.bytes }], opts.home) + await savePending( + sha, + [{ phase: 'content', txid: content.txid, bytes: content.bytes }], + opts.home, + ) const root = { txid: content.txid, vout: plan.rootIndex } - const scratch = await mkdtemp(join(tmpdir(), 'gib-val-')) - await validateRoot(opts.store, root, commit, scratch) - const origin = genesis ? formatOutpoint(root, '_') : prev?.origin ?? opts.origin const { publicKey } = await opts.wallet.getPublicKey({ identityKey: true }) const token = { @@ -82,9 +95,10 @@ export async function pushLine(opts: { root: formatOutpoint(root, '_'), identityPubkey: publicKey, } - const spend = prev - ? await loadSpend(opts.wallet, prev.outpoint) - : undefined + const spend = prev ? await loadSpendById(opts.wallet, prev.id) : undefined + if (prev && !spend) { + throw new Error(`cannot spend previous head ${prev.outpoint}: missing id/BEEF/CI`) + } const head = await opts.publisher.publishHead({ token, commitBytes: commit, @@ -104,6 +118,19 @@ export async function pushLine(opts: { } } +async function recoverForPush( + wallet: WalletInterface, + store: TxStore, + sha: string, +): Promise { + const plans = await recoverPush(wallet, store, sha) + for (const p of plans) { + if (p.kind === 'abort') { + await wallet.abortAction({ reference: p.reference }) + } + } +} + async function burnRef( opts: { wallet: WalletInterface @@ -116,12 +143,12 @@ async function burnRef( ): Promise { const prev = await currentToken(opts, branch) if (!prev) return { ok: false, dst, error: 'no such ref' } - const spend = await loadSpend(opts.wallet, prev.outpoint) + const spend = await loadSpendById(opts.wallet, prev.id) if (!spend) return { ok: false, dst, error: 'token not in wallet' } await opts.publisher.burnHead({ outpoint: spend.outpoint, beef: spend.beef, - keyID: prev.root, + keyID: spend.keyID, labels: [pushLabel('delete')], }) return { ok: true, dst, origin: opts.origin, sha: '0000000000000000000000000000000000000000' } @@ -130,40 +157,48 @@ async function burnRef( async function currentToken( opts: { wallet: WalletInterface; origin: string }, branch: string, -): Promise<{ origin: string; root: string; outpoint: string } | undefined> { +): Promise<{ origin: string; root: string; outpoint: string; id: string } | undefined> { if (opts.origin === '' || opts.origin === 'new') return undefined const listed = await opts.wallet.listOutputs({ basket: GIB_BASKET, tags: [originTag(opts.origin), branchTag(branch)], tagQueryMode: 'all', include: 'locking scripts', + includeTags: true, + includeCustomInstructions: true, limit: 1, }) const o = listed.outputs?.[0] if (!o?.lockingScript) return undefined + const id = idTagFromTags(o.tags) + if (!id) throw new Error('commit token missing id: tag') const t = decodeCommitToken(o.lockingScript) - return { origin: t.origin, root: t.root, outpoint: o.outpoint } -} - -async function remoteSha( - opts: { wallet: WalletInterface; store: TxStore; origin: string }, - branch: string, -): Promise { - const refs = await advertise(opts.wallet, opts.store, opts.origin) - return refs.find((r) => r.name === `refs/heads/${branch}`)?.sha + return { origin: t.origin, root: t.root, outpoint: o.outpoint, id: id.slice(3) } } -async function loadSpend( +async function loadSpendById( wallet: WalletInterface, - outpoint: string, -): Promise<{ outpoint: string; beef: number[] } | undefined> { + id: string, +): Promise<{ outpoint: string; beef: number[]; keyID: string } | undefined> { const listed = await wallet.listOutputs({ basket: GIB_BASKET, + tags: [`id:${id}`], + tagQueryMode: 'all', include: 'entire transactions', - limit: 100, + includeTags: true, + includeCustomInstructions: true, + limit: 1, }) - const want = outpoint.replace('_', '.') - const hit = listed.outputs?.find((o) => o.outpoint === want) - if (!hit || !listed.BEEF?.length) return undefined - return { outpoint: want, beef: Array.from(listed.BEEF) } + const row = listed.outputs?.[0] + if (!row || !listed.BEEF?.length) return undefined + if (!row.customInstructions) { + throw new Error('commit token missing customInstructions') + } + const ci = JSON.parse(row.customInstructions) as { keyID?: string } + if (!ci.keyID) throw new Error('customInstructions missing keyID') + return { + outpoint: row.outpoint, + beef: Array.from(listed.BEEF), + keyID: ci.keyID, + } } diff --git a/src/recovery.ts b/src/recovery.ts index d953dd8..e519355 100644 --- a/src/recovery.ts +++ b/src/recovery.ts @@ -19,6 +19,7 @@ export async function recoverPush( store: TxStore, sha: string, ): Promise { + if (typeof wallet.listActions !== 'function') return [] const listed = await wallet.listActions({ labels: [pushLabel(sha)], labelQueryMode: 'any', diff --git a/src/remote/helper.ts b/src/remote/helper.ts index 6bc2368..30179a7 100644 --- a/src/remote/helper.ts +++ b/src/remote/helper.ts @@ -30,7 +30,7 @@ export async function runHelper(opts: { if (line === null) return const cmd = line.trim() if (cmd === 'capabilities') { - opts.io.write('fetch\npush\noption\n\n') + opts.io.write('fetch\npush\n\n') continue } if (cmd === 'list' || cmd === 'list for-push') { @@ -41,10 +41,6 @@ export async function runHelper(opts: { opts.io.write('\n') continue } - if (cmd.startsWith('option ')) { - opts.io.write('unsupported\n') - continue - } if (cmd.startsWith('fetch ')) { const fetches = [cmd, ...(await readUntilBlank(opts.io))] if (!opts.wallet) throw new Error('fetch requires wallet') diff --git a/src/seal.ts b/src/seal.ts index 8187bdc..bfd8270 100644 --- a/src/seal.ts +++ b/src/seal.ts @@ -1,4 +1,6 @@ import { PushDrop, type WalletInterface } from '@bsv/sdk' + +type SealWallet = Pick import { type CommitToken, commitTokenFields, @@ -7,10 +9,10 @@ import { } from './token.ts' export async function sealCommitLock( - wallet: WalletInterface, + wallet: SealWallet, token: CommitToken, ) { - return new PushDrop(wallet).lock( + return new PushDrop(wallet as WalletInterface).lock( commitTokenFields(token), GIB_PROTOCOL, gibKeyId(token.root), diff --git a/src/token.ts b/src/token.ts index c7af502..f77b597 100644 --- a/src/token.ts +++ b/src/token.ts @@ -30,12 +30,13 @@ export function pushLabel(sha: string): string { function prefixBeforeOrd(script: Script): Script { const chunks = script.chunks for (let i = 0; i < chunks.length - 2; i++) { + const marker = chunks[i + 2] if ( chunks[i]?.op === OP.OP_0 && chunks[i + 1]?.op === OP.OP_IF && - chunks[i + 2]?.data != null && - chunks[i + 2].data.length === 3 && - Utils.toUTF8(chunks[i + 2].data) === 'ord' + marker?.data != null && + marker.data.length === 3 && + Utils.toUTF8(marker.data) === 'ord' ) { const p = new Script() for (let j = 0; j < i; j++) p.chunks.push(chunks[j]) diff --git a/src/tree.ts b/src/tree.ts index 67c6b35..ef5925c 100644 --- a/src/tree.ts +++ b/src/tree.ts @@ -13,36 +13,62 @@ export type FileEntry = { symlink?: boolean } +export type TreeSnapshot = { + files: FileEntry[] + dirs: Map +} + export async function collectTree( store: TxStore, root: Outpoint, prefix = '', ): Promise { + return (await collectSnapshot(store, root, prefix)).files +} + +export async function collectSnapshot( + store: TxStore, + root: Outpoint, + prefix = '', + seen = new Set(), +): Promise { + const key = formatOutpoint(root) + if (seen.has(key)) { + throw new Error(`directory cycle at ${key}`) + } const node = await resolveOutpoint(store, root) if (node.contentType !== DIR_CONTENT_TYPE) { - return [ - { - path: prefix || formatOutpoint(root), - bytes: node.bytes, - contentType: node.contentType, - outpoint: node.outpoint, - }, - ] + return { + files: [ + { + path: prefix || key, + bytes: node.bytes, + contentType: node.contentType, + outpoint: node.outpoint, + }, + ], + dirs: new Map(), + } } + seen.add(key) const manifest = dirDecode(node.bytes) - const out: FileEntry[] = [] + const files: FileEntry[] = [] + const dirs = new Map([[prefix, root]]) for (const e of manifest.entries) { const name = dirNameString(e.name) const child: Outpoint = e.ref.kind === 'same-tx' ? { txid: root.txid, vout: e.ref.vout } : { txid: e.ref.txid.toLowerCase(), vout: e.ref.vout } + const path = prefix ? `${prefix}/${name}` : name if (e.isDir) { - out.push(...(await collectTree(store, child, prefix ? `${prefix}/${name}` : name))) + const sub = await collectSnapshot(store, child, path, seen) + files.push(...sub.files) + for (const [k, v] of sub.dirs) dirs.set(k, v) } else { const file = await resolveOutpoint(store, child) - out.push({ - path: prefix ? `${prefix}/${name}` : name, + files.push({ + path, bytes: file.bytes, contentType: file.contentType, outpoint: file.outpoint, @@ -51,7 +77,7 @@ export async function collectTree( }) } } - return out + return { files, dirs } } export async function materializeGit( diff --git a/src/txstore.ts b/src/txstore.ts index 136da8d..e29d575 100644 --- a/src/txstore.ts +++ b/src/txstore.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { Transaction } from '@bsv/sdk' @@ -13,7 +13,20 @@ export function defaultGibHome(): string { return process.env.GIB_HOME ?? join(homedir(), '.gib') } -export function fileTxStore(root?: string): TxStore { +export type FetchRawTx = (txid: string) => Promise + +export function defaultFetchRawTx( + baseUrl = process.env.GIB_BEEF_URL ?? 'https://api.1sat.app', +): FetchRawTx { + return async (txid) => { + const res = await fetch(`${baseUrl.replace(/\/$/, '')}/1sat/beef/${txid}/tx`) + if (res.status === 404) return undefined + if (!res.ok) throw new Error(`beef fetch ${txid}: ${res.status}`) + return new Uint8Array(await res.arrayBuffer()) + } +} + +export function fileTxStore(root?: string, fetchTx?: FetchRawTx): TxStore { const base = join(root ?? defaultGibHome(), 'txstore') return { async get(txid) { @@ -21,23 +34,33 @@ export function fileTxStore(root?: string): TxStore { try { return new Uint8Array(await readFile(shardPath(base, id))) } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined - throw e + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e } + if (!fetchTx) return undefined + const remote = await fetchTx(id) + if (!remote) return undefined + await putLocal(base, id, remote) + return remote }, async put(txid, signedBytes) { const id = normalizeTxid(txid) - const got = txidOf(signedBytes) - if (got !== id) { - throw new Error(`txstore put: bytes hash to ${got}, not ${id}`) - } - const path = shardPath(base, id) - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, signedBytes) + await putLocal(base, id, signedBytes) }, } } +async function putLocal(base: string, id: string, signedBytes: Uint8Array): Promise { + const got = txidOf(signedBytes) + if (got !== id) { + throw new Error(`txstore put: bytes hash to ${got}, not ${id}`) + } + const path = shardPath(base, id) + await mkdir(dirname(path), { recursive: true }) + const tmp = `${path}.${process.pid}.tmp` + await writeFile(tmp, signedBytes) + await rename(tmp, path) +} + export function txidOf(signedBytes: Uint8Array): string { const tx = Transaction.fromBinary(Array.from(signedBytes)) return tx.id('hex') diff --git a/src/validate.ts b/src/validate.ts index 38b5577..22e199b 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,4 +1,3 @@ -import { gitHash } from './git.ts' import { treeShaFromCommit } from './gitread.ts' import { formatOutpoint, type Outpoint } from './outpoint.ts' import { collectTree, materializeGit } from './tree.ts' @@ -18,8 +17,4 @@ export async function validateRoot( `validation: resolved tree ${got.tree} != commit tree ${expectTree} (root ${formatOutpoint(root)})`, ) } - const expectCommit = gitHash('commit', commitBytes) - if (got.commit !== expectCommit) { - throw new Error(`validation: commit hash ${got.commit} != ${expectCommit}`) - } } diff --git a/test/cascade.test.ts b/test/cascade.test.ts index 74174b0..9516c3a 100644 --- a/test/cascade.test.ts +++ b/test/cascade.test.ts @@ -44,4 +44,29 @@ describe('planCommit', () => { const f = root.entries.find((e) => dirNameString(e.name) === 'f') expect(f?.ref.kind).toBe('outpoint') }) + + it('cites an untouched subdirectory instead of dropping it', async () => { + const genesis = await planCommit({ + files: [ + { path: 'a/x', bytes: enc('x1'), contentType: 'text/plain' }, + { path: 'b/y', bytes: enc('y1'), contentType: 'text/plain' }, + ], + }) + const scripts = genesis.outputs.map((o) => bScript(o.contentType, o.bytes)) + const { txid, bytes } = txWithOutputs(scripts) + const store = memStore() + await store.put(txid, bytes) + const next = await planCommit({ + files: [ + { path: 'a/x', bytes: enc('x2'), contentType: 'text/plain' }, + { path: 'b/y', bytes: enc('y1'), contentType: 'text/plain' }, + ], + prevRoot: { txid, vout: genesis.rootIndex }, + store, + }) + const root = dirDecode(next.outputs[next.rootIndex].bytes) + expect(root.entries.map((e) => dirNameString(e.name)).sort()).toEqual(['a', 'b']) + const b = root.entries.find((e) => dirNameString(e.name) === 'b') + expect(b?.ref.kind).toBe('outpoint') + }) }) diff --git a/test/content.test.ts b/test/content.test.ts new file mode 100644 index 0000000..0fa4d51 --- /dev/null +++ b/test/content.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'bun:test' +import { payloadFromScript } from '../src/content.ts' +import { bScript, ordScript } from './helpers.ts' + +describe('payloadFromScript', () => { + it('decodes an empty B body', () => { + const p = payloadFromScript(bScript('text/plain', new Uint8Array(0))) + expect(p?.contentType).toBe('text/plain') + expect(p?.bytes).toEqual(new Uint8Array(0)) + }) + + it('decodes an empty ord body', () => { + const p = payloadFromScript(ordScript('text/plain', new Uint8Array(0))) + expect(p?.contentType).toBe('text/plain') + expect(p?.bytes).toEqual(new Uint8Array(0)) + }) +}) diff --git a/test/push.test.ts b/test/push.test.ts index 9f0f68e..5b0e2fb 100644 --- a/test/push.test.ts +++ b/test/push.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { PrivateKey, ProtoWallet, type WalletInterface } from '@bsv/sdk' import { localPublisher } from '../src/publish-local.ts' import { pushLine } from '../src/push.ts' import { memStore } from './helpers.ts' @@ -29,7 +29,7 @@ describe('pushLine genesis', () => { await writeFile(join(dir, 'README.md'), '# x\n') await git(dir, ['add', 'README.md']) await git(dir, ['commit', '-q', '-m', 'init']) - const wallet = new ProtoWallet(new PrivateKey(4242)) + const wallet = new ProtoWallet(new PrivateKey(4242)) as unknown as WalletInterface const store = memStore() const r = await pushLine({ line: 'push HEAD:refs/heads/main', diff --git a/tsconfig.json b/tsconfig.json index 0f8724c..c88d202 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "strict": true, "skipLibCheck": true, "noEmit": true, + "allowImportingTsExtensions": true, "types": ["bun"] }, "include": ["src", "test"] From 4056589bd6cafd6c9fd6d5778e7deb41542abec7 Mon Sep 17 00:00:00 2001 From: David Case Date: Fri, 18 Sep 2026 22:32:57 -0400 Subject: [PATCH 04/34] refactor: use @1sat/actions, templates, and client instead of copies stampManagedOutputIds, loadBasketOutputBeef, completeSignedAction, B.lock, Inscription.create, BeefClient.getRawTx. Drop src/ids.ts and hand-rolled B/ord builders and spend/sign loops. --- bun.lock | 55 ++++++++++++++++++ package.json | 3 + src/content.ts | 3 +- src/ids.ts | 46 --------------- src/publish.ts | 151 ++++++++++++++++++++++--------------------------- src/push.ts | 37 ++++-------- src/script.ts | 33 +++-------- src/seal.ts | 2 +- src/txstore.ts | 11 ++-- 9 files changed, 153 insertions(+), 188 deletions(-) delete mode 100644 src/ids.ts diff --git a/bun.lock b/bun.lock index 25b006b..8470174 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,9 @@ "": { "name": "gib-cli", "dependencies": { + "@1sat/actions": "^0.0.221", + "@1sat/client": "^0.0.55", + "@1sat/templates": "^0.0.37", "@bsv/sdk": "^2.6.0", "xdelta3-wasm": "^1.0.0", }, @@ -15,18 +18,70 @@ }, }, "packages": { + "@1sat/actions": ["@1sat/actions@0.0.221", "", { "dependencies": { "@1sat/client": "0.0.55", "@1sat/templates": "0.0.37", "@1sat/types": "0.0.48", "@1sat/utils": "0.0.38", "@1sat/wallet": "0.0.110", "@bsv/message-box-client": "^2.5.0" }, "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-HsmUVJbBtl1ZxUOLDNi4hzTQeQY08crEBso5OSeVUrug5jKD9EzeAvj27UD3Yu2kmzIki+zXXnqXm6+/sp3D0A=="], + + "@1sat/client": ["@1sat/client@0.0.55", "", { "dependencies": { "@1sat/types": "0.0.48", "@bsv/wallet-toolbox-client": "npm:@bopen-io/wallet-toolbox-client@2.6.2-brc153.5" }, "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-7qVsWPZLRoH2fZv8PIUUQIk0euFO4BKIjLfxAIFJz87ARzOgQ3lyeXYOanP9oTJwpH/GY8PuahNKQVy8r/KuYQ=="], + + "@1sat/templates": ["@1sat/templates@0.0.37", "", { "dependencies": { "@1sat/types": "0.0.48", "cbor2": "^2.3.0" }, "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-Tvi1UIcngUzsM1GmcSL4ieWGs4/DLdGNQAWokotaMz9u1ojW0Exr9qyNugYTjPQgmpwBelpLrai3Pr4/tofqmw=="], + + "@1sat/types": ["@1sat/types@0.0.48", "", { "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-qIYPbT1Hd/+ixV6Vw2SbuReGPtLmbW/45uGP+oESzLf+W2KXxod+ERja3qch+PGpgzJNaA6F/Mw98BO2NvR1IQ=="], + + "@1sat/utils": ["@1sat/utils@0.0.38", "", { "dependencies": { "@1sat/types": "0.0.48", "image-meta": "^0.2.2" }, "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-75ORi/P1CmnfUf38RSLv7w7B5hGDcaOSJDEWORA/Jjhb0KeggFYLU4t5IuctCGXImu+diBSSDT2YNnxag/7sow=="], + + "@1sat/wallet": ["@1sat/wallet@0.0.110", "", { "dependencies": { "@1sat/client": "0.0.55", "@1sat/templates": "0.0.37", "@1sat/types": "0.0.48", "@bopen-io/templates": "^1.2.3", "@msgpack/msgpack": "^3.1.3", "fflate": "^0.8.2" }, "peerDependencies": { "@bsv/sdk": "^2.6.0", "@bsv/wallet-toolbox-client": "npm:@bopen-io/wallet-toolbox-client@2.6.2-brc153.5" } }, "sha512-linrpPamIOTw20YbcrUASm/Yz/SYdoq5euUesYYiThWgYwNx69GULugWX/On4wIW8UY0mVBZRxCkdIwkU9IA0A=="], + + "@bopen-io/templates": ["@bopen-io/templates@1.2.3", "", { "dependencies": { "@bsv/sdk": "^2.0.4" }, "peerDependencies": { "sigma-protocol": "^0.1.9" }, "optionalPeers": ["sigma-protocol"] }, "sha512-O8gDbqiqRIhk/2kmj3wnmcWC452uEb6Htu/pCQ3HQ9W/0DrIYarP8ulD4amCuqcdkDsxMbY53YvMx+OqfVuutA=="], + + "@bsv/authsocket-client": ["@bsv/authsocket-client@2.1.6", "", { "dependencies": { "socket.io-client": "^4.8.3" }, "peerDependencies": { "@bsv/sdk": "^2.4.1" } }, "sha512-UwT9aoQnDL9yDtW3taB6pqem6LnSm855CSfWX3m4cp4Ohjr8JEIcS7lYEk8PCDkIOZecJm1VrB+D/XYW2b+XEA=="], + + "@bsv/message-box-client": ["@bsv/message-box-client@2.5.1", "", { "dependencies": { "@bsv/authsocket-client": "^2.1.6" }, "peerDependencies": { "@bsv/sdk": "^2.4.1" } }, "sha512-1MiXqTHEirmBNUrcyU2dApFVxBqdN5xGgsco1An5Z5Z2KiFtTIfgy6K5XevpB9qn4ilkoLdU13yEwWjjrOnnvg=="], + "@bsv/sdk": ["@bsv/sdk@2.7.1", "", {}, "sha512-1dcvOwCVGZz3A5FTJeOil1MALp2iQ1bTrZ8C9v97CC26sd8t0+Y3bFP39Rw/q0zmcbh6c+ZkIt6tKfvje+pD2A=="], + "@bsv/wallet-toolbox-client": ["@bopen-io/wallet-toolbox-client@2.6.2-brc153.5", "", { "dependencies": { "hash-wasm": "^4.12.0", "idb": "^8.0.3" }, "peerDependencies": { "@bsv/sdk": "^2.1.8" } }, "sha512-+EB9ohtVi08KOizQA796lsNBRzBym4G7hvxIWZtaPfKCqaNFcXkoOU7l5tXFRqXrY1JTqIEuPcjwBh/+88CMMw=="], + + "@cto.af/wtf8": ["@cto.af/wtf8@0.0.5", "", {}, "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg=="], + + "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="], + + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/node": ["@types/node@26.6.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "cbor2": ["cbor2@2.3.0", "", { "dependencies": { "@cto.af/wtf8": "0.0.5" } }, "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + + "hash-wasm": ["hash-wasm@4.12.0", "", {}, "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ=="], + + "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], + + "image-meta": ["image-meta@0.2.2", "", {}, "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.7", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "xdelta3-wasm": ["xdelta3-wasm@1.0.0", "", {}, "sha512-vhS28BhVaE3S/PGG1KQIwjBVqJecuS5Sdh82UAZysbnYaU93KS6l8ZPtsqonNZMeuyFgrGW9D44hh2lwQCsidA=="], + + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], } } diff --git a/package.json b/package.json index eeae5f8..bbd5368 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "git-remote-gib": "bun run src/git-remote-gib.ts" }, "dependencies": { + "@1sat/actions": "^0.0.221", + "@1sat/client": "^0.0.55", + "@1sat/templates": "^0.0.37", "@bsv/sdk": "^2.6.0", "xdelta3-wasm": "^1.0.0" }, diff --git a/src/content.ts b/src/content.ts index 4b2827b..ca21b75 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,7 +1,6 @@ +import { B_PREFIX } from '@1sat/templates' import { LockingScript, OP, Script, Utils } from '@bsv/sdk' -export const B_PREFIX = '19HxigV4QyBv3tHpQVcUEQyq1pzZVdoAut' - export type Payload = { contentType: string bytes: Uint8Array diff --git a/src/ids.ts b/src/ids.ts deleted file mode 100644 index bba68f4..0000000 --- a/src/ids.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { CreateActionArgs } from '@bsv/sdk' -import { Utils } from '@bsv/sdk' - -export function randomActionId(): string { - const bytes = new Uint8Array(8) - crypto.getRandomValues(bytes) - return Utils.toHex(Array.from(bytes)) -} - -export function stampManagedOutputIds(args: CreateActionArgs): string { - const existing = actionIdFromOutputTags(args) - const actionId = existing ?? randomActionId() - if (args.outputs) { - for (const [i, output] of args.outputs.entries()) { - if (!output.basket) continue - const tag = `id:${actionId}_${i}` - const tags = (output.tags ?? []).filter((t) => !t.startsWith('id:')) - output.tags = [...tags, tag] - } - } - return actionId -} - -export function idTagFromTags(tags: string[] | undefined): string | undefined { - return tags?.find((t) => t.startsWith('id:')) -} - -function actionIdFromOutputTags(args: CreateActionArgs): string | undefined { - const ids = new Set() - for (const output of args.outputs ?? []) { - for (const tag of output.tags ?? []) { - if (!tag.startsWith('id:')) continue - const rest = tag.slice(3) - const us = rest.lastIndexOf('_') - if (us <= 0) continue - const id = rest.slice(0, us) - const index = rest.slice(us + 1) - if (id && /^\d+$/.test(index)) ids.add(id) - } - } - if (ids.size === 0) return undefined - if (ids.size > 1) { - throw new Error(`outputs carry more than one action id (${[...ids].join(', ')})`) - } - return [...ids][0] -} diff --git a/src/publish.ts b/src/publish.ts index c1eb726..0d10550 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -1,6 +1,10 @@ import { + completeSignedAction, + stampManagedOutputIds, +} from '@1sat/actions' +import { + type CreateActionArgs, type CreateActionResult, - LockingScript, PushDrop, Transaction, type WalletInterface, @@ -8,7 +12,6 @@ import { import type { CommitPlan } from './cascade.ts' import { GIT_COMMIT_TYPE, appendOrdEnvelope, bLockingScript } from './script.ts' import { commitHeadCustomInstructions, sealCommitLock } from './seal.ts' -import { stampManagedOutputIds } from './ids.ts' import { GIB_BASKET, GIB_PROTOCOL, @@ -19,6 +22,12 @@ import { export type PublishedTx = { txid: string; bytes: Uint8Array } +export type SpendHead = { + outpoint: string + beef: number[] + keyID: string +} + export type Publisher = { publishContent(plan: CommitPlan, labels: string[]): Promise publishHead(opts: { @@ -26,24 +35,58 @@ export type Publisher = { commitBytes: Uint8Array labels: string[] tags: string[] - spend?: { outpoint: string; beef: number[]; keyID: string } - }): Promise - burnHead(opts: { - outpoint: string - beef: number[] - keyID: string - labels: string[] + spend?: SpendHead }): Promise + burnHead(opts: SpendHead & { labels: string[] }): Promise } function rawTxFromResult(r: CreateActionResult): PublishedTx { if (!r.txid) throw new Error('createAction returned no txid') if (!r.tx?.length) throw new Error('createAction returned no tx bytes') - const beef = Array.from(r.tx) - const tx = Transaction.fromBEEF(beef) + const tx = Transaction.fromBEEF(Array.from(r.tx)) return { txid: r.txid, bytes: new Uint8Array(tx.toBinary()) } } +async function unlockPushDrop( + wallet: WalletInterface, + keyID: string, + outpoint: string, + beef: number[], + createResult: CreateActionResult, +): Promise { + const done = await completeSignedAction( + wallet, + createResult, + beef, + async (tx) => { + const want = outpoint.split('.')[0] + const idx = tx.inputs.findIndex((i) => (i.sourceTXID ?? '') === want) + if (idx < 0) throw new Error('token input missing from funded tx') + const input = tx.inputs[idx] + const src = input.sourceTransaction?.outputs[input.sourceOutputIndex] + if (!src) throw new Error('token input source missing') + const script = await new PushDrop(wallet) + .unlock( + GIB_PROTOCOL, + keyID, + 'anyone', + 'all', + false, + src.satoshis ?? 1, + src.lockingScript, + ) + .sign(tx, idx) + return { [idx]: { unlockingScript: script.toHex() } } + }, + { acceptDelayedBroadcast: false }, + ) + if (done.error || !done.txid || !done.tx) { + throw new Error(done.error ?? 'signAction returned no tx') + } + const signedTx = Transaction.fromBEEF(Array.from(done.tx)) + return { txid: done.txid, bytes: new Uint8Array(signedTx.toBinary()) } +} + export function walletPublisher(wallet: WalletInterface): Publisher { return { async publishContent(plan, labels) { @@ -52,13 +95,12 @@ export function walletPublisher(wallet: WalletInterface): Publisher { satoshis: 0, outputDescription: o.path ?? `gib content ${i}`, })) - const args = { + const r = await wallet.createAction({ description: `gib content ${labels[0] ?? ''}`.slice(0, 50), outputs, labels, options: { randomizeOutputs: false, signAndProcess: true }, - } - const r = await wallet.createAction(args) + }) return rawTxFromResult(r) }, async publishHead(opts) { @@ -67,7 +109,7 @@ export function walletPublisher(wallet: WalletInterface): Publisher { } const pd = await sealCommitLock(wallet, opts.token) const locking = appendOrdEnvelope(pd, GIT_COMMIT_TYPE, opts.commitBytes) - const args = { + const args: CreateActionArgs = { description: `gib head ${opts.token.branch}`.slice(0, 50), ...(opts.spend ? { inputBEEF: opts.spend.beef } : {}), inputs: opts.spend @@ -95,44 +137,18 @@ export function walletPublisher(wallet: WalletInterface): Publisher { stampManagedOutputIds(args) const r = await wallet.createAction(args) if (r.txid) return rawTxFromResult(r) - if (!r.signableTransaction || !opts.spend) { - throw new Error('unexpected createAction response for commit head') - } - const { reference, tx: txBeef } = r.signableTransaction - try { - const tx = Transaction.fromBEEF(txBeef) - const want = opts.spend.outpoint.split('.')[0] - const idx = tx.inputs.findIndex((i) => (i.sourceTXID ?? '') === want) - if (idx < 0) throw new Error('token input missing from funded tx') - const input = tx.inputs[idx] - const src = input.sourceTransaction?.outputs[input.sourceOutputIndex] - if (!src) throw new Error('token input source missing') - const unlock = new PushDrop(wallet).unlock( - GIB_PROTOCOL, - opts.spend.keyID, - 'anyone', - 'all', - false, - src.satoshis ?? 1, - src.lockingScript, - ) - const script = await unlock.sign(tx, idx) - const signed = await wallet.signAction({ - reference, - spends: { [idx]: { unlockingScript: script.toHex() } }, - options: { acceptDelayedBroadcast: false }, - }) - if (!signed.txid || !signed.tx) throw new Error('signAction returned no tx') - const signedTx = Transaction.fromBEEF(Array.from(signed.tx)) - return { txid: signed.txid, bytes: new Uint8Array(signedTx.toBinary()) } - } catch (e) { - await wallet.abortAction({ reference }).catch(() => {}) - throw e - } + if (!opts.spend) throw new Error('unexpected createAction response for commit head') + return unlockPushDrop( + wallet, + opts.spend.keyID, + opts.spend.outpoint, + opts.spend.beef, + r, + ) }, async burnHead(opts) { const r = await wallet.createAction({ - description: 'gib burn ref'.slice(0, 50), + description: 'gib burn ref', inputBEEF: opts.beef, inputs: [ { @@ -144,38 +160,7 @@ export function walletPublisher(wallet: WalletInterface): Publisher { labels: opts.labels, options: { signAndProcess: false }, }) - if (!r.signableTransaction) throw new Error('burn: expected signable tx') - const { reference, tx: txBeef } = r.signableTransaction - try { - const tx = Transaction.fromBEEF(txBeef) - const want = opts.outpoint.split('.')[0] - const idx = tx.inputs.findIndex((i) => (i.sourceTXID ?? '') === want) - if (idx < 0) throw new Error('burn input missing') - const input = tx.inputs[idx] - const src = input.sourceTransaction?.outputs[input.sourceOutputIndex] - if (!src) throw new Error('burn source missing') - const unlock = new PushDrop(wallet).unlock( - GIB_PROTOCOL, - opts.keyID, - 'anyone', - 'all', - false, - src.satoshis ?? 1, - src.lockingScript, - ) - const script = await unlock.sign(tx, idx) - const signed = await wallet.signAction({ - reference, - spends: { [idx]: { unlockingScript: script.toHex() } }, - options: { acceptDelayedBroadcast: false }, - }) - if (!signed.txid || !signed.tx) throw new Error('signAction returned no tx') - const signedTx = Transaction.fromBEEF(Array.from(signed.tx)) - return { txid: signed.txid, bytes: new Uint8Array(signedTx.toBinary()) } - } catch (e) { - await wallet.abortAction({ reference }).catch(() => {}) - throw e - } + return unlockPushDrop(wallet, opts.keyID, opts.outpoint, opts.beef, r) }, } } @@ -183,5 +168,3 @@ export function walletPublisher(wallet: WalletInterface): Publisher { export function headTags(origin: string, branch: string): string[] { return [originTag(origin), branchTag(branch)] } - -export { LockingScript } diff --git a/src/push.ts b/src/push.ts index 9b5fb56..adc742b 100644 --- a/src/push.ts +++ b/src/push.ts @@ -10,7 +10,7 @@ import { parsePushLine, revParse, } from './gitread.ts' -import { idTagFromTags } from './ids.ts' +import { loadBasketOutputBeef } from '@1sat/actions' import { formatOutpoint, parseOutpoint } from './outpoint.ts' import { clearPending, loadPending, savePending } from './pending.ts' import { previewContentStore } from './preview.ts' @@ -96,9 +96,6 @@ export async function pushLine(opts: { identityPubkey: publicKey, } const spend = prev ? await loadSpendById(opts.wallet, prev.id) : undefined - if (prev && !spend) { - throw new Error(`cannot spend previous head ${prev.outpoint}: missing id/BEEF/CI`) - } const head = await opts.publisher.publishHead({ token, commitBytes: commit, @@ -144,11 +141,8 @@ async function burnRef( const prev = await currentToken(opts, branch) if (!prev) return { ok: false, dst, error: 'no such ref' } const spend = await loadSpendById(opts.wallet, prev.id) - if (!spend) return { ok: false, dst, error: 'token not in wallet' } await opts.publisher.burnHead({ - outpoint: spend.outpoint, - beef: spend.beef, - keyID: spend.keyID, + ...spend, labels: [pushLabel('delete')], }) return { ok: true, dst, origin: opts.origin, sha: '0000000000000000000000000000000000000000' } @@ -170,35 +164,26 @@ async function currentToken( }) const o = listed.outputs?.[0] if (!o?.lockingScript) return undefined - const id = idTagFromTags(o.tags) + const id = o.tags?.find((t) => t.startsWith('id:')) if (!id) throw new Error('commit token missing id: tag') const t = decodeCommitToken(o.lockingScript) - return { origin: t.origin, root: t.root, outpoint: o.outpoint, id: id.slice(3) } + return { origin: t.origin, root: t.root, outpoint: o.outpoint, id } } async function loadSpendById( wallet: WalletInterface, id: string, -): Promise<{ outpoint: string; beef: number[]; keyID: string } | undefined> { - const listed = await wallet.listOutputs({ - basket: GIB_BASKET, - tags: [`id:${id}`], - tagQueryMode: 'all', - include: 'entire transactions', - includeTags: true, - includeCustomInstructions: true, - limit: 1, - }) - const row = listed.outputs?.[0] - if (!row || !listed.BEEF?.length) return undefined - if (!row.customInstructions) { +): Promise<{ outpoint: string; beef: number[]; keyID: string }> { + const loaded = await loadBasketOutputBeef(wallet, GIB_BASKET, id) + if ('error' in loaded) throw new Error(loaded.error) + if (!loaded.output.customInstructions) { throw new Error('commit token missing customInstructions') } - const ci = JSON.parse(row.customInstructions) as { keyID?: string } + const ci = JSON.parse(loaded.output.customInstructions) as { keyID?: string } if (!ci.keyID) throw new Error('customInstructions missing keyID') return { - outpoint: row.outpoint, - beef: Array.from(listed.BEEF), + outpoint: loaded.output.outpoint, + beef: loaded.beef, keyID: ci.keyID, } } diff --git a/src/script.ts b/src/script.ts index 5d9392a..65a00b9 100644 --- a/src/script.ts +++ b/src/script.ts @@ -1,33 +1,16 @@ -import { LockingScript, OP, Script, Utils } from '@bsv/sdk' -import { B_PREFIX } from './content.ts' +import { B, Encoding, Inscription } from '@1sat/templates' +import type { Script } from '@bsv/sdk' -export function bLockingScript(contentType: string, body: Uint8Array): LockingScript { - const s = new Script() - s.writeOpCode(OP.OP_FALSE) - s.writeOpCode(OP.OP_RETURN) - s.writeBin(Utils.toArray(B_PREFIX, 'utf8')) - s.writeBin(Array.from(body)) - s.writeBin(Utils.toArray(contentType, 'utf8')) - s.writeBin(Utils.toArray('binary', 'utf8')) - return LockingScript.fromBinary(s.toBinary()) +export const GIT_COMMIT_TYPE = 'application/x-git-commit' + +export function bLockingScript(contentType: string, body: Uint8Array) { + return B.lock(body, contentType, Encoding.Binary) } export function appendOrdEnvelope( prefix: Script, contentType: string, body: Uint8Array, -): LockingScript { - const s = new Script() - for (const c of prefix.chunks) s.chunks.push(c) - s.writeOpCode(OP.OP_FALSE) - s.writeOpCode(OP.OP_IF) - s.writeBin(Utils.toArray('ord', 'utf8')) - s.writeOpCode(OP.OP_1) - s.writeBin(Utils.toArray(contentType, 'utf8')) - s.writeOpCode(OP.OP_0) - s.writeBin(Array.from(body)) - s.writeOpCode(OP.OP_ENDIF) - return LockingScript.fromBinary(s.toBinary()) +) { + return Inscription.create(body, contentType, { scriptPrefix: prefix }).lock() } - -export const GIT_COMMIT_TYPE = 'application/x-git-commit' diff --git a/src/seal.ts b/src/seal.ts index bfd8270..75ba38e 100644 --- a/src/seal.ts +++ b/src/seal.ts @@ -13,7 +13,7 @@ export async function sealCommitLock( token: CommitToken, ) { return new PushDrop(wallet as WalletInterface).lock( - commitTokenFields(token), + [...commitTokenFields(token)], GIB_PROTOCOL, gibKeyId(token.root), 'anyone', diff --git a/src/txstore.ts b/src/txstore.ts index e29d575..3c6ef6f 100644 --- a/src/txstore.ts +++ b/src/txstore.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' +import { BeefClient } from '@1sat/client' import { Transaction } from '@bsv/sdk' import { normalizeTxid } from './outpoint.ts' @@ -18,11 +19,13 @@ export type FetchRawTx = (txid: string) => Promise export function defaultFetchRawTx( baseUrl = process.env.GIB_BEEF_URL ?? 'https://api.1sat.app', ): FetchRawTx { + const client = new BeefClient(baseUrl) return async (txid) => { - const res = await fetch(`${baseUrl.replace(/\/$/, '')}/1sat/beef/${txid}/tx`) - if (res.status === 404) return undefined - if (!res.ok) throw new Error(`beef fetch ${txid}: ${res.status}`) - return new Uint8Array(await res.arrayBuffer()) + try { + return await client.getRawTx(txid) + } catch { + return undefined + } } } From 8542ad55f37a7d02971082ed67fa31d5f396961f Mon Sep 17 00:00:00 2001 From: David Case Date: Sat, 19 Sep 2026 00:45:01 -0400 Subject: [PATCH 05/34] gib init, .gib repository metadata, genesis remote rewrite, push fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `.gib`: committed JSON dotfile at the tree root (name, description, defaultBranch). Labels, not identifiers; the origin stays the id. Spec in docs/plans/gib-cli.html; two open questions closed. - `gib init`: runs git init when needed, prompts with directory name and current branch as defaults (-y to skip), writes .gib, adds the remote as gib://new. - git-remote-gib: after a genesis push, rewrites the remote URL to gib:// and prints it; later refs in the same batch join it. Publisher is injectable for tests. - publish: outputDescription must be 5-50 chars for BRC-100 wallets; manifest outputs like "/" made every genesis push fail with HTTP 400. - advertise: request includeTags, otherwise list is always empty. - @1sat/actions from a vendored pack of 1sat-sdk PR #77 until it ships. Verified on mainnet 2026-09-19: genesis 11de1a94…_10 and a second push d7155289…_3 (patch + new leaf + cascade), served by ORDFS and indexed by the gib overlay. Co-Authored-By: Claude Fable 5.1 --- README.md | 14 ++- bun.lock | 4 +- docs/plans/gib-cli.html | 41 ++++++- docs/plans/questions.md | 8 +- package.json | 2 +- src/git-remote-gib.ts | 4 + src/init.ts | 112 ++++++++++++++++++ src/main.ts | 63 +++++++++- src/publish.ts | 3 +- src/remote/advertise.ts | 1 + src/remote/helper.ts | 41 ++++++- src/repo-meta.ts | 52 ++++++++ test/helper-genesis.test.ts | 68 +++++++++++ test/init.test.ts | 94 +++++++++++++++ .../1sat-actions-0.0.221-feat-ordfs-patch.tgz | Bin 0 -> 308372 bytes 15 files changed, 491 insertions(+), 16 deletions(-) create mode 100644 src/init.ts create mode 100644 src/repo-meta.ts create mode 100644 test/helper-genesis.test.ts create mode 100644 test/init.test.ts create mode 100644 vendor/1sat-actions-0.0.221-feat-ordfs-patch.tgz diff --git a/README.md b/README.md index 5eba41c..fa26087 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,19 @@ remote helper. Git stays git — only push/fetch touch the chain. design. `gib-status.html` — what was proven on mainnet (full txids inside). - `docs/plans/questions.md` — open items vs answered decisions. -**This branch is docs-only.** The working tree is greenfield: no implementation yet. +**Use it:** + +```bash +bun install +ln -s "$PWD/src/git-remote-gib.ts" ~/.local/bin/git-remote-gib # git finds helpers on PATH +gib init # in your project: writes .gib, adds remote gib://new (runs git init if needed) +git push origin main # genesis: mints the repo; the remote is repointed to gib:// +``` + +Needs a BRC-100 wallet on `http://127.0.0.1:3321` (`1sat serve wallet-api`) and its +monitor running (`1sat serve monitor`) so delayed broadcasts go out. + +**History.** The working tree is greenfield: no implementation yet. The first prototype (clone/commit/push proven end-to-end on mainnet, wallet API, push-drop token chain) lives on branch **`archive/prototype`**. It uses a superseded model (full-tree republish, `.gib` project state, ORDFS content reads, pre-final token diff --git a/bun.lock b/bun.lock index 8470174..807e9bf 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "gib-cli", "dependencies": { - "@1sat/actions": "^0.0.221", + "@1sat/actions": "file:vendor/1sat-actions-0.0.221-feat-ordfs-patch.tgz", "@1sat/client": "^0.0.55", "@1sat/templates": "^0.0.37", "@bsv/sdk": "^2.6.0", @@ -18,7 +18,7 @@ }, }, "packages": { - "@1sat/actions": ["@1sat/actions@0.0.221", "", { "dependencies": { "@1sat/client": "0.0.55", "@1sat/templates": "0.0.37", "@1sat/types": "0.0.48", "@1sat/utils": "0.0.38", "@1sat/wallet": "0.0.110", "@bsv/message-box-client": "^2.5.0" }, "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-HsmUVJbBtl1ZxUOLDNi4hzTQeQY08crEBso5OSeVUrug5jKD9EzeAvj27UD3Yu2kmzIki+zXXnqXm6+/sp3D0A=="], + "@1sat/actions": ["@1sat/actions@vendor/1sat-actions-0.0.221-feat-ordfs-patch.tgz", { "dependencies": { "@1sat/client": "0.0.55", "@1sat/templates": "0.0.37", "@1sat/types": "0.0.48", "@1sat/utils": "0.0.38", "@1sat/wallet": "0.0.110", "@bsv/message-box-client": "^2.5.0", "xdelta3-wasm": "^1.0.0" }, "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-YSgjforQXlkxcFHTKS0usMCKtg3jxcaE16xuTo1wqZ+l6gWR+n2gu9STuKTTK+3CcWhSEWjk9QG1VLAg+7Ilrg=="], "@1sat/client": ["@1sat/client@0.0.55", "", { "dependencies": { "@1sat/types": "0.0.48", "@bsv/wallet-toolbox-client": "npm:@bopen-io/wallet-toolbox-client@2.6.2-brc153.5" }, "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-7qVsWPZLRoH2fZv8PIUUQIk0euFO4BKIjLfxAIFJz87ARzOgQ3lyeXYOanP9oTJwpH/GY8PuahNKQVy8r/KuYQ=="], diff --git a/docs/plans/gib-cli.html b/docs/plans/gib-cli.html index a34bc87..4b943ca 100644 --- a/docs/plans/gib-cli.html +++ b/docs/plans/gib-cli.html @@ -131,11 +131,48 @@

      Local state — there is no .gib (decided 2026-09-18)

      from files in the working tree.
    +

    Repository metadata: the .gib file (decided 2026-09-19)

    +

    Git has no notion of a repository name; hosts keep that in their own database. +gib keeps it in the tree: a committed dotfile .gib at the root, JSON, +published and cited like any other file.

    +
    {
    +  "name": "gib-test",
    +  "description": "Small repo for exercising gibhub.net",
    +  "defaultBranch": "main"
    +}
    +
      +
    • name, description: display metadata. Labels, not +identifiers — the origin outpoint stays the repository's identity, and two repositories +may claim the same name. Uniqueness and user/repo URLs are a registry +concern above this layer (1sat.name for the user part).
    • +
    • defaultBranch: the branch clones should check out — git's +HEAD symref, which otherwise has no chain home. The helper advertises +@refs/heads/<defaultBranch> HEAD when the file resolves.
    • +
    • Readers: gibhub and the overlay fetch /content/<root>/.gib from a +head's root and store name and default branch alongside the head. gib itself never +needs the file. Unknown keys are ignored; a missing file means "unnamed".
    • +
    • Written by gib init; forks inherit it until edited. This is a file in +the repository, not the local .gib/ state directory (which no longer +exists).
    • +
    +

    gib init

    +

    gib init [-y] [--name] [--description] [--default-branch] [--remote origin] +[--force] runs in the project directory. If it is not a git repository yet it +runs git init -b <defaultBranch>, so a gib-first user needs one +command. It prompts for name (default: directory name), description, and default branch +(default: current branch), writes .gib, and adds the remote as +gib://new unless a gib remote already exists. Nothing touches chain. The +user commits and pushes as usual.

    +

    On the first push the helper mints the repository, then rewrites the remote URL to +gib://<origin> and prints it, so the next push extends the same +repository instead of minting another. Refs pushed in the same batch join the new +origin.

    +

    Interface requirements (git-remote-gib)