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
+
+- Hdr_Indicator = 0: no
VCD_DECOMPRESS (secondary
+compression), no custom code table, no application header.
+- Delta_Indicator = 0 on every window (sections uncompressed).
+- No xdelta3 Adler-32 window checksum (
Win_Indicator bit 0x10 unset).
+- xdelta3 CLI default output is not this profile (djw secondary + app
+header). Do not write it. Interop test: encode with the flags above, decode with
+any RFC-plain decoder (
xdelta3-wasm, @ably/vcdiff-decoder,
+xdelta3 -d).
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.
+
+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)
- Advertise: wallet list-by-tag → per branch: latest commit head →
decode its inscription → sha →
<sha> refs/heads/<name>. HEAD-as-symref
-(default branch) has no chain home yet — convention-file candidate.
+(default branch) comes from .gib defaultBranch (see metadata).
- Push intake: old-sha in git's ref-update must equal the sha our token
names (double-check); ingest pack → objects → records + cascade + commit inscription +
token spend. Ref lifecycle = coin lifecycle: create=mint, delete=burn, ff=spend forward,
@@ -168,8 +205,6 @@
Open
- File modes: git tree entries carry exec bit/symlink marks; manifests
don't. Add modes to manifest entries, or store tree objects verbatim.
-- Default-branch designation (git's HEAD symref) — home TBD.
-- Project-manifest convention file: name (and what else), filename TBD.
- Cascade fee economics and tx batching; coalescing tradeoff (big tx vs per-file fetch).
- Annotated-tag tx shape (own fused tx vs riding a commit).
- Clone-from-stranger flow: mint own token citing foreign root; new repo ID on fork —
diff --git a/docs/plans/questions.md b/docs/plans/questions.md
index 369c994..b25430e 100644
--- a/docs/plans/questions.md
+++ b/docs/plans/questions.md
@@ -1,7 +1,5 @@
# gib open questions (one per turn; answered ones move out)
-- [ ] default-branch designation (git HEAD symref) — no chain home yet
-- [ ] project-manifest convention file: name/filename TBD
- [ ] wallet label scheme (BRC-100): basket `gib`; tx label push: on every tx of a
push; output tags origin:/branch: on commit heads. Txstore stores SIGNED bytes
only (txid changes at signing — build-time writes are useless). Recovery =
@@ -16,6 +14,12 @@
shared txs must NOT duplicate) so unused projects can be pruned. GC-style reachability,
not ownership. Note now, solve later
+# answered 2026-09-19 (details in gib-cli.html §metadata)
+- repository metadata = committed dotfile `.gib` (JSON: name, description, defaultBranch);
+ labels not identifiers, origin stays the id; default branch = git HEAD symref home
+- `gib init`: git init if needed, write `.gib`, add remote `gib://new`; helper rewrites the
+ remote to `gib://` after the genesis push and reports it
+
# answered 2026-09-18 → decisions (details in gib-token.html / gib-cli.html / gib-format.html)
- file modes: SOLVED by ordfs/dir flags byte (EXEC/SYMLINK bits; symlink leaf bytes =
target path) — see ordfs-formats.html; content types settled: ordfs/dir, ordfs/patch,
diff --git a/package.json b/package.json
index bbd5368..b533f7b 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"git-remote-gib": "bun run src/git-remote-gib.ts"
},
"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",
diff --git a/src/git-remote-gib.ts b/src/git-remote-gib.ts
index 6663b3c..3b9865a 100644
--- a/src/git-remote-gib.ts
+++ b/src/git-remote-gib.ts
@@ -4,7 +4,10 @@ import { connectWallet } from './wallet.ts'
import { defaultFetchRawTx, fileTxStore } from './txstore.ts'
import { runHelper } from './remote/helper.ts'
+// git invokes `git-remote-gib ` for a named remote and
+// `git-remote-gib ` for a bare URL.
const url = process.argv[3] ?? process.argv[2]
+const remoteName = process.argv[3] ? process.argv[2] : undefined
if (!url) {
console.error('git-remote-gib: missing remote url')
process.exit(1)
@@ -15,6 +18,7 @@ const iter = rl[Symbol.asyncIterator]()
await runHelper({
url,
+ remoteName,
store: fileTxStore(undefined, defaultFetchRawTx()),
wallet: connectWallet(),
gitDir: process.env.GIT_DIR ?? '.git',
diff --git a/src/init.ts b/src/init.ts
new file mode 100644
index 0000000..e29c5ce
--- /dev/null
+++ b/src/init.ts
@@ -0,0 +1,112 @@
+/**
+ * `gib init` — write `.gib` and point a remote at `gib://new`.
+ *
+ * Runs `git init` first when the directory is not a repository yet, so a
+ * gib-first user needs one command. Touches nothing on chain: the first
+ * `git push` mints the repository and the helper rewrites the remote URL to
+ * the real `gib://`.
+ */
+
+import { existsSync } from 'node:fs'
+import { readFile, writeFile } from 'node:fs/promises'
+import { basename, join, resolve } from 'node:path'
+import { formatRepoMeta, GIB_FILE, parseRepoMeta, type RepoMeta } from './repo-meta.ts'
+
+export const NEW_REMOTE_URL = 'gib://new'
+
+export type InitOptions = {
+ cwd: string
+ name?: string
+ description?: string
+ defaultBranch?: string
+ /** Remote to create or check; defaults to `origin`. */
+ remote?: string
+ /** Overwrite an existing `.gib`. */
+ force?: boolean
+ /** Called with computed defaults when interactive; returns final values. */
+ prompt?: (defaults: Required> & RepoMeta) => Promise
+}
+
+export type InitResult = {
+ root: string
+ file: string
+ meta: RepoMeta
+ remote: string
+ remoteUrl: string
+ remoteAction: 'added' | 'unchanged'
+ /** True when `gib init` ran `git init` for this directory. */
+ gitInitialized: boolean
+}
+
+async function git(cwd: string, args: string[]): Promise<{ code: number; out: string; err: 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,
+ ])
+ return { code, out: out.trim(), err: err.trim() }
+}
+
+/** Current branch name, or `main` when HEAD is unborn/detached. */
+export async function currentBranch(cwd: string): Promise {
+ const r = await git(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
+ return r.code === 0 && r.out ? r.out : 'main'
+}
+
+export async function gibInit(opts: InitOptions): Promise {
+ let gitInitialized = false
+ let top = await git(opts.cwd, ['rev-parse', '--show-toplevel'])
+ if (top.code !== 0) {
+ const branch = opts.defaultBranch ?? 'main'
+ const init = await git(opts.cwd, ['init', '-q', '-b', branch])
+ if (init.code !== 0) throw new Error(`git init: ${init.err}`)
+ gitInitialized = true
+ top = await git(opts.cwd, ['rev-parse', '--show-toplevel'])
+ if (top.code !== 0) throw new Error(`git init succeeded but the repository is unreadable: ${top.err}`)
+ }
+ const root = resolve(top.out)
+ const file = join(root, GIB_FILE)
+ const remote = opts.remote ?? 'origin'
+
+ let existing: RepoMeta = {}
+ if (existsSync(file)) {
+ if (!opts.force) {
+ throw new Error(`${GIB_FILE} already exists (use --force to overwrite)`)
+ }
+ existing = parseRepoMeta(await readFile(file, 'utf8'))
+ }
+
+ const defaults = {
+ name: opts.name ?? existing.name ?? basename(root),
+ description: opts.description ?? existing.description,
+ defaultBranch: opts.defaultBranch ?? existing.defaultBranch ?? (await currentBranch(root)),
+ }
+ const chosen = opts.prompt ? await opts.prompt(defaults) : defaults
+ const meta: RepoMeta = {
+ name: (chosen.name ?? defaults.name).trim() || defaults.name,
+ description: chosen.description?.trim() || undefined,
+ defaultBranch: (chosen.defaultBranch ?? defaults.defaultBranch).trim() || defaults.defaultBranch,
+ }
+ // Round-trip through the parser so a bad branch name fails here, not at push.
+ parseRepoMeta(formatRepoMeta(meta))
+ await writeFile(file, formatRepoMeta(meta))
+
+ const url = await git(root, ['remote', 'get-url', remote])
+ let remoteAction: InitResult['remoteAction']
+ let remoteUrl: string
+ if (url.code !== 0) {
+ const add = await git(root, ['remote', 'add', remote, NEW_REMOTE_URL])
+ if (add.code !== 0) throw new Error(`git remote add ${remote}: ${add.err}`)
+ remoteAction = 'added'
+ remoteUrl = NEW_REMOTE_URL
+ } else if (url.out.startsWith('gib://')) {
+ remoteAction = 'unchanged'
+ remoteUrl = url.out
+ } else {
+ throw new Error(
+ `remote '${remote}' already points at ${url.out}; pick another name with --remote `,
+ )
+ }
+ return { root, file, meta, remote, remoteUrl, remoteAction, gitInitialized }
+}
diff --git a/src/main.ts b/src/main.ts
index 949db97..f2ae272 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,21 +1,78 @@
#!/usr/bin/env bun
+import { createInterface } from 'node:readline/promises'
+import { gibInit } from './init.ts'
+import { GIB_FILE, type RepoMeta } from './repo-meta.ts'
import { defaultGibHome, fileTxStore, txidOf } from './txstore.ts'
import { connectWallet, DEFAULT_WALLET_URL } from './wallet.ts'
-const cmd = process.argv[2] ?? 'help'
+const argv = process.argv.slice(2)
+const cmd = argv[0] ?? 'help'
+
+function flag(name: string): string | undefined {
+ const i = argv.indexOf(name)
+ return i >= 0 ? argv[i + 1] : undefined
+}
+const has = (name: string) => argv.includes(name)
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 init [-y] [--name n] [--description d] [--default-branch b] [--remote origin] [--force]\n' +
+ ` write ${GIB_FILE} and add a gib://new remote; first push mints the repo\n` +
' gib doctor check wallet + txstore\n' +
' gib put store a signed tx (verifies txid)\n',
)
process.exit(0)
}
+if (cmd === 'init') {
+ const interactive = !has('-y') && !has('--yes') && process.stdin.isTTY === true
+ try {
+ const r = await gibInit({
+ cwd: process.cwd(),
+ name: flag('--name'),
+ description: flag('--description'),
+ defaultBranch: flag('--default-branch'),
+ remote: flag('--remote'),
+ force: has('--force'),
+ prompt: interactive
+ ? async (d) => {
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
+ const ask = async (label: string, def?: string) => {
+ const a = (await rl.question(def ? `${label} (${def}): ` : `${label}: `)).trim()
+ return a || def
+ }
+ const meta: RepoMeta = {
+ name: await ask('name', d.name),
+ description: await ask('description', d.description),
+ defaultBranch: await ask('default branch', d.defaultBranch),
+ }
+ rl.close()
+ return meta
+ }
+ : undefined,
+ })
+ if (r.gitInitialized) process.stdout.write(`initialized git repository in ${r.root}\n`)
+ process.stdout.write(`wrote ${r.file}\n`)
+ process.stdout.write(
+ r.remoteAction === 'added'
+ ? `remote '${r.remote}' -> ${r.remoteUrl}\n`
+ : `remote '${r.remote}' already ${r.remoteUrl}\n`,
+ )
+ if (r.remoteUrl === 'gib://new') {
+ process.stdout.write(
+ `next: git add ${GIB_FILE} && git commit && git push ${r.remote} ${r.meta.defaultBranch}\n`,
+ )
+ }
+ } catch (e) {
+ console.error(`gib init: ${e instanceof Error ? e.message : e}`)
+ process.exit(1)
+ }
+ process.exit(0)
+}
+
if (cmd === 'doctor') {
const home = defaultGibHome()
process.stdout.write(`GIB_HOME=${home}\n`)
@@ -31,7 +88,7 @@ if (cmd === 'doctor') {
}
if (cmd === 'put') {
- const file = process.argv[3]
+ const file = argv[1]
if (!file) {
console.error('usage: gib put ')
process.exit(1)
diff --git a/src/publish.ts b/src/publish.ts
index 0d10550..730c182 100644
--- a/src/publish.ts
+++ b/src/publish.ts
@@ -93,7 +93,8 @@ export function walletPublisher(wallet: WalletInterface): Publisher {
const outputs = plan.outputs.map((o, i) => ({
lockingScript: bLockingScript(o.contentType, o.bytes).toHex(),
satoshis: 0,
- outputDescription: o.path ?? `gib content ${i}`,
+ // BRC-100 wallets require 5-50 chars here; paths like "/" are shorter.
+ outputDescription: `gib ${o.path ?? `content ${i}`}`.slice(0, 50),
}))
const r = await wallet.createAction({
description: `gib content ${labels[0] ?? ''}`.slice(0, 50),
diff --git a/src/remote/advertise.ts b/src/remote/advertise.ts
index cf0e9d1..a6bf79f 100644
--- a/src/remote/advertise.ts
+++ b/src/remote/advertise.ts
@@ -26,6 +26,7 @@ export async function advertise(
tags: [originTag(origin)],
tagQueryMode: 'all',
include: 'locking scripts',
+ includeTags: true,
limit: 10000,
})
const refs: Array<{ sha: string; name: string }> = []
diff --git a/src/remote/helper.ts b/src/remote/helper.ts
index 30179a7..912705f 100644
--- a/src/remote/helper.ts
+++ b/src/remote/helper.ts
@@ -5,7 +5,7 @@ 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 { type Publisher, walletPublisher } from '../publish.ts'
import { advertise, originFromUrl } from './advertise.ts'
import type { TxStore } from '../txstore.ts'
@@ -18,13 +18,20 @@ export { advertise, originFromUrl }
export async function runHelper(opts: {
url: string
+ /** Remote name git invoked us with; lets a genesis push rewrite its URL. */
+ remoteName?: string
store: TxStore
wallet?: WalletInterface
+ /** Overrides the wallet-backed publisher (tests). */
+ publisher?: Publisher
gitDir: string
io: HelperIo
home?: string
+ /** Where progress lines go; defaults to stderr, which git relays to the user. */
+ log?: (s: string) => void
}): Promise {
- const origin = originFromUrl(opts.url)
+ let origin = originFromUrl(opts.url)
+ const log = opts.log ?? ((s: string) => process.stderr.write(s))
for (;;) {
const line = await opts.io.read()
if (line === null) return
@@ -76,7 +83,7 @@ export async function runHelper(opts: {
opts.io.write('\n')
continue
}
- const publisher = walletPublisher(opts.wallet)
+ const publisher = opts.publisher ?? walletPublisher(opts.wallet)
for (const p of pushes) {
const r = await pushLine({
line: p,
@@ -89,6 +96,24 @@ export async function runHelper(opts: {
})
if (r.ok) opts.io.write(`ok ${r.dst}\n`)
else opts.io.write(`error ${r.dst} ${r.error}\n`)
+ if (r.ok && isNewOrigin(origin) && r.origin && !isNewOrigin(r.origin)) {
+ // Genesis: the repository now has an identity. Later refs in
+ // this batch join it, and the remote is repointed so the next
+ // push does not mint a second repository.
+ origin = r.origin
+ const url = `gib://${r.origin}`
+ log(`gib: minted repository ${url}\n`)
+ if (opts.remoteName) {
+ const set = await setRemoteUrl(opts.gitDir, opts.remoteName, url)
+ log(
+ set
+ ? `gib: remote '${opts.remoteName}' now points at ${url}\n`
+ : `gib: could not update remote '${opts.remoteName}'; run: git remote set-url ${opts.remoteName} ${url}\n`,
+ )
+ } else {
+ log(`gib: add it as a remote: git remote add origin ${url}\n`)
+ }
+ }
}
opts.io.write('\n')
continue
@@ -97,6 +122,16 @@ export async function runHelper(opts: {
}
}
+const isNewOrigin = (o: string) => o === '' || o === 'new'
+
+async function setRemoteUrl(gitDir: string, remote: string, url: string): Promise {
+ const proc = Bun.spawn(['git', '--git-dir', gitDir, 'remote', 'set-url', remote, url], {
+ stdout: 'pipe',
+ stderr: 'pipe',
+ })
+ return (await proc.exited) === 0
+}
+
async function readUntilBlank(io: HelperIo): Promise {
const lines: string[] = []
for (;;) {
diff --git a/src/repo-meta.ts b/src/repo-meta.ts
new file mode 100644
index 0000000..58bb168
--- /dev/null
+++ b/src/repo-meta.ts
@@ -0,0 +1,52 @@
+/**
+ * `.gib` — repository metadata, a committed dotfile at the tree root.
+ *
+ * { "name": "my-repo", "description": "…", "defaultBranch": "main" }
+ *
+ * gib itself never needs it: the origin outpoint is the repository's
+ * identity. The file gives humans and indexers (gibhub, the overlay) a
+ * display name and tells clones which branch HEAD should point at.
+ * `gib init` writes it; it travels with the tree like any other file.
+ */
+
+export const GIB_FILE = '.gib'
+
+export type RepoMeta = {
+ name?: string
+ description?: string
+ defaultBranch?: string
+}
+
+const BRANCH_RE = /^[^\s~^:?*[\\]+$/
+
+export function parseRepoMeta(text: string): RepoMeta {
+ let raw: unknown
+ try {
+ raw = JSON.parse(text)
+ } catch (e) {
+ throw new Error(`${GIB_FILE}: invalid JSON (${e instanceof Error ? e.message : e})`)
+ }
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ throw new Error(`${GIB_FILE}: must be a JSON object`)
+ }
+ const o = raw as Record
+ const meta: RepoMeta = {}
+ for (const key of ['name', 'description', 'defaultBranch'] as const) {
+ const v = o[key]
+ if (v === undefined) continue
+ if (typeof v !== 'string') throw new Error(`${GIB_FILE}: ${key} must be a string`)
+ meta[key] = v
+ }
+ if (meta.defaultBranch !== undefined && !BRANCH_RE.test(meta.defaultBranch)) {
+ throw new Error(`${GIB_FILE}: defaultBranch is not a valid branch name`)
+ }
+ return meta
+}
+
+export function formatRepoMeta(meta: RepoMeta): string {
+ const out: RepoMeta = {}
+ if (meta.name) out.name = meta.name
+ if (meta.description) out.description = meta.description
+ if (meta.defaultBranch) out.defaultBranch = meta.defaultBranch
+ return `${JSON.stringify(out, null, 2)}\n`
+}
diff --git a/test/helper-genesis.test.ts b/test/helper-genesis.test.ts
new file mode 100644
index 0000000..2612b21
--- /dev/null
+++ b/test/helper-genesis.test.ts
@@ -0,0 +1,68 @@
+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, type WalletInterface } from '@bsv/sdk'
+import { localPublisher } from '../src/publish-local.ts'
+import { runHelper } from '../src/remote/helper.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('git-remote-gib genesis push', () => {
+ it('mints the origin, repoints the remote, and reports the URL', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'gib-genesis-'))
+ 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 git(dir, ['remote', 'add', 'origin', 'gib://new'])
+ 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)) as unknown as WalletInterface
+ const lines = ['capabilities', 'push HEAD:refs/heads/main', '']
+ let i = 0
+ const out: string[] = []
+ const log: string[] = []
+ await runHelper({
+ url: 'gib://new',
+ remoteName: 'origin',
+ store: memStore(),
+ wallet,
+ publisher: localPublisher(wallet),
+ gitDir: join(dir, '.git'),
+ home,
+ log: (s) => log.push(s),
+ io: {
+ async read() {
+ return i < lines.length ? lines[i++] : null
+ },
+ write(s) {
+ out.push(s)
+ },
+ },
+ })
+
+ expect(out.join('')).toContain('ok refs/heads/main')
+ const url = await git(dir, ['remote', 'get-url', 'origin'])
+ expect(url).toMatch(/^gib:\/\/[0-9a-f]{64}_\d+$/)
+ expect(log.join('')).toContain(`minted repository ${url}`)
+ expect(log.join('')).toContain(`remote 'origin' now points at ${url}`)
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ await rm(home, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/test/init.test.ts b/test/init.test.ts
new file mode 100644
index 0000000..11e7f3e
--- /dev/null
+++ b/test/init.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'bun:test'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { basename, join } from 'node:path'
+import { gibInit } from '../src/init.ts'
+import { formatRepoMeta, parseRepoMeta } from '../src/repo-meta.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()
+}
+
+async function repo(branch = 'main') {
+ const dir = await mkdtemp(join(tmpdir(), 'gib-init-'))
+ await git(dir, ['init', '-q', '-b', branch])
+ return dir
+}
+
+describe('gib init', () => {
+ it('defaults name to the directory and branch to the current one', async () => {
+ const dir = await repo('trunk')
+ try {
+ const r = await gibInit({ cwd: dir })
+ expect(r.meta).toEqual({ name: basename(dir), defaultBranch: 'trunk', description: undefined })
+ expect(parseRepoMeta(await readFile(join(dir, '.gib'), 'utf8'))).toEqual({
+ name: basename(dir),
+ defaultBranch: 'trunk',
+ })
+ expect(r.remoteAction).toBe('added')
+ expect(await git(dir, ['remote', 'get-url', 'origin'])).toBe('gib://new')
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('takes explicit values and prompt answers, keeps an existing gib remote', async () => {
+ const dir = await repo()
+ try {
+ await git(dir, ['remote', 'add', 'origin', 'gib://abc_0'])
+ const r = await gibInit({
+ cwd: dir,
+ description: 'from flag',
+ prompt: async (d) => ({ ...d, name: 'chosen', defaultBranch: 'dev' }),
+ })
+ expect(r.meta).toEqual({ name: 'chosen', description: 'from flag', defaultBranch: 'dev' })
+ expect(r.remoteAction).toBe('unchanged')
+ expect(r.remoteUrl).toBe('gib://abc_0')
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('refuses to overwrite .gib without --force and to hijack a non-gib remote', async () => {
+ const dir = await repo()
+ try {
+ await writeFile(join(dir, '.gib'), formatRepoMeta({ name: 'keep' }))
+ await expect(gibInit({ cwd: dir })).rejects.toThrow(/already exists/)
+ const r = await gibInit({ cwd: dir, force: true })
+ expect(r.meta.name).toBe('keep')
+ await git(dir, ['remote', 'set-url', 'origin', 'https://example.com/x.git'])
+ await expect(gibInit({ cwd: dir, force: true })).rejects.toThrow(/already points at/)
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('runs git init when the directory is not a repository', async () => {
+ const plain = await mkdtemp(join(tmpdir(), 'gib-plain-'))
+ try {
+ const r = await gibInit({ cwd: plain, defaultBranch: 'trunk' })
+ expect(r.gitInitialized).toBe(true)
+ expect(r.meta.defaultBranch).toBe('trunk')
+ expect(await git(plain, ['symbolic-ref', '--short', 'HEAD'])).toBe('trunk')
+ expect(await git(plain, ['remote', 'get-url', 'origin'])).toBe('gib://new')
+ } finally {
+ await rm(plain, { recursive: true, force: true })
+ }
+ })
+
+ it('rejects a bad default branch', async () => {
+ const dir = await repo()
+ try {
+ await expect(gibInit({ cwd: dir, defaultBranch: 'bad name' })).rejects.toThrow(/branch/)
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/vendor/1sat-actions-0.0.221-feat-ordfs-patch.tgz b/vendor/1sat-actions-0.0.221-feat-ordfs-patch.tgz
new file mode 100644
index 0000000000000000000000000000000000000000..60bd19fa61015efe87b83bc9fbb606b36ae33ce1
GIT binary patch
literal 308372
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
- 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
NLIge`sGk&E2>4Uwq
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